Dracula transcript styling: tool call cards, coloured status lines, inline code pills and no dots

Restyles the Claude Code transcript in Dracula colours.
It has no options or commands.
/plugin install dracula-theme-mod --marketplace xRyul/claude-plugins-and-mods-by-xryul
MIT
hooks/register.tsx 979 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { CodeProps, EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { CardState, FileCall, RunState, TurnTime } from '../types'
5
6// Colours chosen in the Transcript Line Tints mockup
7const TOOL_SUMMARY = '#6272a4'
8const TIMING = '#ffb86c'
9const CODE_TEXT = '#ec7e7e'
10const CODE_BG = '#363848'
11const CARD_BG = '#303241'
12const CARD_NAME = '#ff79c6'
13const CARD_ARGS = '#6272a4'
14
15// Heading colours chosen in the Transcript Heading Styles mockup
16const HEADING_1 = '#ff79c6'
17const HEADING_2 = '#bd93f9'
18const HEADING_REST = '#f8f8f2'
19const HEADING_MARK = '#6272a4'
20
21// Emphasis colours chosen in the Transcript Emphasis Styles mockup; bold italic takes the bold colour
22const BOLD = '#ffb86c'
23const ITALIC = '#f1fa8c'
24
25// The faint frame around each turn's final reply: Dracula's selection colour
26const FRAME = '#44475a'
27
28// Text between a prompt and the turn's final reply is drawn halfway from its colour to
29// Dracula's background, so it keeps its hue
30const BACKGROUND = '#282a36'
31const FOREGROUND = '#f8f8f2'
32// Dracula's red and orange stand in for the theme's error and warning colours, which a
33// theme key cannot be dimmed from
34const ERROR = '#ff5555'
35const WARNING = '#ffb86c'
36
37const MAX_OUTPUT_LINES = 400
38// Cards opened from a summary line sit this far in, two columns past the summary's own text
39const NESTED_INDENT = 4
40
41const cardState = atom({ plugin: 'dracula-theme-mod', key: 'cardState' } as const, 'auto' as CardState)
42const turnTimes = atom({ plugin: 'dracula-theme-mod', key: 'turnTimes' } as const, [] as TurnTime[])
43// The tool calls of the main turn that is still running
44const liveCalls = atom({ plugin: 'dracula-theme-mod', key: 'liveCalls' } as const, [] as string[])
45
46// Back-to-back file changes of finished turns, each run folded into one line
47const fileRuns = atom({ plugin: 'dracula-theme-mod', key: 'fileRuns' } as const, [] as FileCall[][])
48const runState = atom({ plugin: 'dracula-theme-mod', key: 'runState' } as const, 'folded' as RunState)
49// Finished tool groups the person opened from their summary line
50const groupState = atom({ plugin: 'dracula-theme-mod', key: 'groupState' } as const, 'folded' as RunState)
51// Each turn's final reply, by replyKey, so its block is drawn in a frame
52const finalReplies = atom({ plugin: 'dracula-theme-mod', key: 'finalReplies' } as const, [] as string[])
53
54// File changes never join a tool summary, so once their turn ends they fold into a line of their own
55const FILE_VERBS: Record<string, string> = { Edit: 'Edited', Write: 'Wrote', NotebookEdit: 'Edited' }
56
57function basename(path: string) {
58 return path.split('/').pop() ?? path
59}
60
61// A run ends at anything the transcript shows between two file changes: text, a prompt,
62// another tool's row, or a failed change, which keeps its own card
63function findFileRuns(messages: ReadonlyArray<SessionMessage>) {
64 const runs: FileCall[][] = []
65 let run: FileCall[] = []
66 const close = () => {
67 if (run.length > 1) runs.push(run)
68 run = []
69 }
70
71 for (const message of messages) {
72 if (message.role === 'user') {
73 if ((message.toolResults ?? []).length === 0 && message.text.trim() !== '') close()
74 continue
75 }
76 if (message.text.trim() !== '') close()
77 for (const use of message.toolUses) {
78 const verb = FILE_VERBS[use.tool]
79 const file = use.input.file_path ?? use.input.notebook_path
80 if (verb !== undefined && typeof file === 'string' && use.isError !== true) {
81 run.push({ id: use.tool_use_id, verb, file: basename(file) })
82 } else {
83 close()
84 }
85 }
86 }
87 close()
88
89 return runs
90}
91
92// "Edited index.d.ts, register.tsx (6 changes)"
93function describeRun(calls: ReadonlyArray<FileCall>) {
94 const byVerb = new Map<string, string[]>()
95 for (const call of calls) byVerb.set(call.verb, [...(byVerb.get(call.verb) ?? []), call.file])
96
97 return [...byVerb]
98 .map(([verb, files], index) => {
99 const names = [...new Set(files)]
100 const what = names.length <= 3 ? names.join(', ') : `${names.length} files`
101 const repeats = files.length > names.length ? ` (${files.length} changes)` : ''
102 return `${index === 0 ? verb : verb.toLowerCase()} ${what}${repeats}`
103 })
104 .join(', ')
105}
106
107async function refreshFileRuns($: EngineInterface) {
108 const messages = await $.session.messages()
109 await update($, fileRuns, () => findFileRuns(messages))
110 return messages
111}
112
113// A reply is known by its length and an FNV-1a hash of its text, so the list stays small
114function replyKey(text: string) {
115 const trimmed = text.trim()
116 let hash = 0x811c9dc5
117 for (let i = 0; i < trimmed.length; i++) hash = Math.imul(hash ^ trimmed.charCodeAt(i), 0x01000193)
118 return `${trimmed.length}:${(hash >>> 0).toString(36)}`
119}
120
121// A turn's final reply is text with no tool call after it: the transcript ends there,
122// or the next thing is the person's prompt
123function findFinalReplies(messages: ReadonlyArray<SessionMessage>) {
124 return messages
125 .filter((message, index) => {
126 const after = messages[index + 1]
127 const endsTurn = after === undefined || (after.role === 'user' && (after.toolResults ?? []).length === 0)
128 return message.role === 'assistant' && message.toolUses.length === 0 && message.text.trim() !== '' && endsTurn
129 })
130 .map(message => replyKey(message.text))
131 .slice(-500)
132}
133
134// A transcript told apart cheaply: by its length and its last message
135function transcriptKey(messages: ReadonlyArray<SessionMessage>) {
136 return `${messages.length}:${replyKey(messages[messages.length - 1]?.text ?? '')}`
137}
138
139// Reads the transcript a resume put in place once it differs from the one before: checked
140// every quarter second, and read anyway after five seconds
141async function readResumed($: EngineInterface, before: string, tries = 20): Promise<void> {
142 const messages = await $.session.messages()
143 if (transcriptKey(messages) === before && tries > 1) {
144 $.clock.after(250, () => void readResumed($, before, tries - 1).catch(() => undefined))
145 return
146 }
147 await update($, fileRuns, () => findFileRuns(messages))
148 await update($, finalReplies, () => findFinalReplies(messages))
149}
150
151type Span = {
152 text: string
153 code?: boolean
154 bold?: boolean
155 italic?: boolean
156 strike?: boolean
157 href?: string
158}
159
160type Block =
161 | { kind: 'markdown'; text: string }
162 | { kind: 'paragraph'; text: string }
163 | { kind: 'heading'; level: number; text: string }
164 | { kind: 'list'; items: ListItem[] }
165 | { kind: 'table'; source: string; align: Align[]; rows: Span[][][] }
166
167type ListItem = { depth: number; marker: string; text: string }
168type Align = 'left' | 'center' | 'right'
169
170const FENCE = /^\s*(```|~~~)/
171const HEADING = /^(#{1,6})\s+(.*)$/
172const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
173const TABLE = /^\s*\|/
174const QUOTE = /^\s*>/
175const RULE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/
176const TABLE_RULE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
177// Characters a terminal draws two cells wide (CJK, emoji) or none (combining marks, joiners),
178// which the mod cannot line up in a table
179const UNEVEN =
180 /[\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}\u200b-\u200f\ufe0f]|\p{Emoji_Presentation}|\p{Mn}/u
181const INLINE =
182 /(`+)([\s\S]*?[^`])\1(?!`)|\[([^\]\n]+)\]\(([^)\s]+)\)|\*\*\*(?!\s)([\s\S]+?)\*\*\*|\*\*([\s\S]+?)\*\*|__([\s\S]+?)__|~~([\s\S]+?)~~|(?<![\w*])\*(?!\s)([^*\n]+?)\*(?![\w*])|(?<![\w])_(?!\s)([^_\n]+?)_(?![\w])|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])|\\([\\`*_{}[\]()#+\-.!|~<>])/g
183
184function dimmed(color: string) {
185 const channel = (hex: string, at: number) => parseInt(hex.slice(at, at + 2), 16)
186 const mixed = [1, 3, 5].map(at => Math.round((channel(color, at) + channel(BACKGROUND, at)) / 2))
187 return `#${mixed.map(value => value.toString(16).padStart(2, '0')).join('')}`
188}
189
190function formatDuration(ms: number) {
191 const total = Math.max(0, Math.round(ms / 1000))
192 const hours = Math.floor(total / 3600)
193 const minutes = Math.floor((total % 3600) / 60)
194 const seconds = total % 60
195 if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
196 if (minutes > 0) return `${minutes}m ${seconds}s`
197 return `${seconds}s`
198}
199
200function formatClock(ms: number) {
201 const date = new Date(ms)
202 const hours = date.getHours()
203 const minutes = String(date.getMinutes()).padStart(2, '0')
204 return `${hours % 12 || 12}:${minutes} ${hours < 12 ? 'AM' : 'PM'}`
205}
206
207function plural(count: number, one: string, many: string) {
208 return `${count} ${count === 1 ? one : many}`
209}
210
211function summarize(calls: ReadonlyArray<{ tool: string }>) {
212 const counts = { search: 0, read: 0, list: 0, shell: 0, edit: 0, write: 0 }
213 // Any other tool is named, so "Used Artifact" says which one ran
214 const others = new Map<string, number>()
215 for (const call of calls) {
216 if (call.tool === 'Grep' || call.tool === 'Glob') counts.search++
217 else if (call.tool === 'Read') counts.read++
218 else if (call.tool === 'LS') counts.list++
219 else if (call.tool === 'Bash' || call.tool === 'PowerShell') counts.shell++
220 else if (call.tool === 'Edit' || call.tool === 'NotebookEdit') counts.edit++
221 else if (call.tool === 'Write') counts.write++
222 else others.set(displayName(call.tool), (others.get(displayName(call.tool)) ?? 0) + 1)
223 }
224 const parts = [
225 counts.search > 0 && `searched for ${plural(counts.search, 'pattern', 'patterns')}`,
226 counts.read > 0 && `read ${plural(counts.read, 'file', 'files')}`,
227 counts.list > 0 && `listed ${plural(counts.list, 'directory', 'directories')}`,
228 counts.shell > 0 && `ran ${plural(counts.shell, 'shell command', 'shell commands')}`,
229 counts.edit > 0 && `edited ${plural(counts.edit, 'file', 'files')}`,
230 counts.write > 0 && `wrote ${plural(counts.write, 'file', 'files')}`,
231 ...[...others].map(([name, count]) => `used ${name}${count > 1 ? ` ${count} times` : ''}`),
232 ].filter((part): part is string => typeof part === 'string')
233 const text = parts.join(', ')
234 return text.charAt(0).toUpperCase() + text.slice(1)
235}
236
237function displayName(tool: string) {
238 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
239 return mcp ? `${mcp[1]} ${mcp[2]}` : tool
240}
241
242// The card's first line: the input that says what the call did
243function describeInput(tool: string, input: unknown) {
244 const fields = (input ?? {}) as Record<string, unknown>
245 const field = (name: string) => (typeof fields[name] === 'string' ? (fields[name] as string) : undefined)
246 const joined = (...values: Array<string | undefined>) => values.filter(Boolean).join(' ')
247
248 let text: string
249 switch (tool) {
250 case 'Bash':
251 case 'PowerShell':
252 text = field('command') ?? ''
253 break
254 case 'Read':
255 case 'Write':
256 case 'Edit':
257 case 'NotebookEdit':
258 text = field('file_path') ?? field('notebook_path') ?? ''
259 break
260 case 'Grep':
261 text = joined(field('pattern'), field('path'), field('glob'))
262 break
263 case 'Glob':
264 text = joined(field('pattern'), field('path'))
265 break
266 case 'WebFetch':
267 text = field('url') ?? ''
268 break
269 case 'WebSearch':
270 text = field('query') ?? ''
271 break
272 case 'Agent':
273 case 'Task':
274 text = field('description') ?? ''
275 break
276 default:
277 text = Object.values(fields)
278 .filter((value): value is string => typeof value === 'string' && value.length < 300)
279 .slice(0, 2)
280 .join(' ')
281 }
282
283 const lines = text.split('\n')
284 return { first: lines[0] ?? '', more: lines.length - 1 }
285}
286
287// A card nobody has clicked is open only when its call failed
288function isCardOpen(saved: CardState, isErrored: boolean) {
289 return saved === 'auto' ? isErrored : saved === 'open'
290}
291
292function toggled(current: CardState, isErrored: boolean): CardState {
293 return isCardOpen(current, isErrored) ? 'closed' : 'open'
294}
295
296function cap(text: string) {
297 const lines = text.replace(/\s+$/, '').split('\n')
298 return { text: lines.slice(0, MAX_OUTPUT_LINES).join('\n'), more: Math.max(0, lines.length - MAX_OUTPUT_LINES) }
299}
300
301function shellOutput(output: unknown) {
302 if (output === null || typeof output !== 'object') return undefined
303 const { stdout, stderr } = output as { stdout?: unknown; stderr?: unknown }
304 if (typeof stdout !== 'string' && typeof stderr !== 'string') return undefined
305 return [stdout, stderr].filter((part): part is string => typeof part === 'string' && part.length > 0).join('\n')
306}
307
308// The readable text of a result: a string, its text blocks, or its plain fields as "name: value" lines
309function outputText(output: unknown): string {
310 if (typeof output === 'string') return output
311 if (Array.isArray(output)) return output.map(outputText).filter(text => text !== '').join('\n')
312 if (output === null || typeof output !== 'object') return output === undefined ? '' : String(output)
313
314 const fields = output as Record<string, unknown>
315 for (const name of ['text', 'content', 'message', 'result', 'output']) {
316 const value = fields[name]
317 const text = typeof value === 'string' || Array.isArray(value) ? outputText(value) : ''
318 if (text.trim() !== '') return text
319 }
320 if (Array.isArray(fields.filenames)) return fields.filenames.join('\n')
321
322 return Object.entries(fields)
323 .filter(([, value]) => ['string', 'number', 'boolean'].includes(typeof value))
324 .map(([name, value]) => `${name}: ${String(value)}`)
325 .join('\n')
326}
327
328type Patch = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
329
330type Body =
331 | { kind: 'text'; text: string; more: number; isError: boolean }
332 | { kind: 'code'; props: CodeProps; more: number }
333
334// What an opened card shows under its first line, read from the call's stored result.
335// Whole hunks only, since a diff cut mid-hunk draws as plain text
336function describeOutput(tool: string, output: unknown, isErrored: boolean): Body | undefined {
337 if (output === undefined) return undefined
338 const text = (value: string) => {
339 const { text, more } = cap(value)
340 return { kind: 'text' as const, text: text.trim() === '' ? '(No output)' : text, more, isError: isErrored }
341 }
342 if (isErrored) return text(outputText(output))
343
344 const shell = tool === 'Bash' || tool === 'PowerShell' ? shellOutput(output) : undefined
345 if (shell !== undefined) return text(shell)
346
347 const fields = (output ?? {}) as Record<string, unknown>
348 const patches = Array.isArray(fields.structuredPatch) ? (fields.structuredPatch as Patch[]) : []
349 if (patches.length > 0) {
350 const hunks: string[] = []
351 let lines = 0
352 for (const patch of patches) {
353 if (hunks.length > 0 && lines + patch.lines.length > MAX_OUTPUT_LINES) break
354 hunks.push(`@@ -${patch.oldStart},${patch.oldLines} +${patch.newStart},${patch.newLines} @@\n${patch.lines.join('\n')}`)
355 lines += patch.lines.length
356 }
357 const left = patches.slice(hunks.length).reduce((sum, patch) => sum + patch.lines.length, 0)
358 return { kind: 'code', props: { source: hunks.join('\n'), format: 'diff' }, more: left }
359 }
360
361 const file = (fields.file ?? {}) as Record<string, unknown>
362 const path = typeof fields.filePath === 'string' ? fields.filePath : typeof file.filePath === 'string' ? file.filePath : ''
363 const content = typeof fields.content === 'string' ? fields.content : typeof file.content === 'string' ? file.content : undefined
364 if (content !== undefined && path !== '') {
365 const { text, more } = cap(content)
366 const startLine = typeof file.startLine === 'number' ? file.startLine : 1
367 return { kind: 'code', props: { source: text, path, startLine }, more }
368 }
369
370 return text(outputText(output))
371}
372
373function isPlain(span: Span) {
374 return !span.code && !span.bold && !span.italic && !span.strike && !span.href
375}
376
377function parseInline(text: string): Span[] {
378 const spans: Span[] = []
379 // Plain text next to plain text (around an escape, say) joins into one span
380 const push = (span: Span) => {
381 const previous = spans[spans.length - 1]
382 if (previous && isPlain(previous) && isPlain(span)) previous.text += span.text
383 else spans.push(span)
384 }
385 let last = 0
386 for (const match of text.matchAll(INLINE)) {
387 const index = match.index ?? 0
388 if (index > last) push({ text: text.slice(last, index) })
389 const [, ticks, code, linkText, linkHref, boldItalic, bold1, bold2, strike, italic1, italic2, url, escaped] = match
390 if (ticks !== undefined && code !== undefined) {
391 spans.push({ text: /^ .* $/.test(code) ? code.slice(1, -1) : code, code: true })
392 } else if (linkText !== undefined && linkHref !== undefined) {
393 spans.push({ text: linkText, href: linkHref })
394 } else if (boldItalic !== undefined) {
395 spans.push(...parseInline(boldItalic).map(span => ({ ...span, bold: true, italic: true })))
396 } else if (bold1 !== undefined || bold2 !== undefined) {
397 spans.push(...parseInline(bold1 ?? bold2 ?? '').map(span => ({ ...span, bold: true })))
398 } else if (strike !== undefined) {
399 spans.push(...parseInline(strike).map(span => ({ ...span, strike: true })))
400 } else if (italic1 !== undefined || italic2 !== undefined) {
401 spans.push(...parseInline(italic1 ?? italic2 ?? '').map(span => ({ ...span, italic: true })))
402 } else if (url !== undefined) {
403 spans.push({ text: url, href: url })
404 } else if (escaped !== undefined) {
405 push({ text: escaped })
406 }
407 last = index + match[0].length
408 }
409 if (last < text.length) push({ text: text.slice(last) })
410 return spans
411}
412
413// Cells split on pipes outside code spans; `\|` is a pipe inside a cell
414function splitRow(line: string) {
415 const row = line.trim().replace(/^\|/, '').replace(/\|$/, '')
416 const cells: string[] = []
417 let cell = ''
418 let inCode = false
419 for (let i = 0; i < row.length; i++) {
420 const char = row[i] ?? ''
421 if (char === '\\' && row[i + 1] === '|') {
422 cell += '|'
423 i++
424 continue
425 }
426 if (char === '`') inCode = !inCode
427 if (char === '|' && !inCode) {
428 cells.push(cell.trim())
429 cell = ''
430 continue
431 }
432 cell += char
433 }
434 cells.push(cell.trim())
435 return cells
436}
437
438// A table the mod can draw cell by cell; any other is left to the engine's Markdown
439function tableBlock(lines: string[]): Block {
440 const source = lines.join('\n')
441 const [header, rule, ...body] = lines
442 if (header === undefined || rule === undefined || !TABLE_RULE.test(rule)) return { kind: 'markdown', text: source }
443 const align = splitRow(rule).map((cell): Align => (cell.endsWith(':') ? (cell.startsWith(':') ? 'center' : 'right') : 'left'))
444 const cells = [header, ...body].map(splitRow)
445 if (cells.some(row => row.some(cell => UNEVEN.test(cell)))) return { kind: 'markdown', text: source }
446 return { kind: 'table', source, align, rows: cells.map(row => align.map((_, column) => parseInline(row[column] ?? ''))) }
447}
448
449function spansWidth(spans: Span[]) {
450 return spans.reduce((sum, span) => sum + span.text.length + (span.code ? 2 : 0), 0)
451}
452
453// The widest piece a cell holds that wrapping cannot break without cutting it: a pill or a word
454function longestPiece(spans: Span[]) {
455 return spans.reduce(
456 (widest, span) => Math.max(widest, span.code ? span.text.length + 2 : Math.max(0, ...span.text.split(' ').map(word => word.length))),
457 0,
458 )
459}
460
461// Column widths for the room a table has. When the natural widths do not fit, each
462// column keeps its longest piece (or a fair share of the room, if that is narrower) and
463// the rest of the room goes to the columns that want more, in proportion.
464function fitWidths(natural: number[], longest: number[], room: number) {
465 if (natural.reduce((sum, width) => sum + width, 0) <= room) return natural
466 const fair = Math.floor(room / natural.length)
467 const least = natural.map((width, column) => Math.min(width, Math.max(3, Math.min(longest[column] ?? width, fair))))
468 const spare = room - least.reduce((sum, width) => sum + width, 0)
469 const wanted = natural.reduce((sum, width, column) => sum + width - (least[column] ?? 0), 0)
470 return least.map((width, column) => width + Math.floor((spare * ((natural[column] ?? 0) - width)) / Math.max(1, wanted)))
471}
472
473function sameStyle(a: Span, b: Span) {
474 return !a.code && !b.code && a.bold === b.bold && a.italic === b.italic && a.strike === b.strike && a.href === b.href
475}
476
477// A cell's text in lines no wider than its column. Words and pills stay whole unless one
478// alone is wider than the column, and runs of one style stay one span.
479function wrapSpans(spans: Span[], width: number) {
480 const lines: Span[][] = [[]]
481 let used = 0
482 let spaceBefore = false
483 const append = (piece: Span) => {
484 const line = lines[lines.length - 1] ?? []
485 const previous = line[line.length - 1]
486 if (previous && sameStyle(previous, piece)) previous.text += piece.text
487 else line.push(piece)
488 }
489 const place = (piece: Span, size: number) => {
490 const gap = spaceBefore && used > 0 ? 1 : 0
491 spaceBefore = false
492 if (used > 0 && used + gap + size > width) {
493 lines.push([])
494 used = 0
495 } else if (gap) {
496 const line = lines[lines.length - 1] ?? []
497 const previous = line[line.length - 1]
498 append(previous && sameStyle(previous, piece) ? { ...piece, text: ' ' } : { text: ' ' })
499 used += 1
500 }
501 append(piece)
502 used += size
503 }
504
505 for (const span of spans) {
506 if (span.code) {
507 const cut = Math.max(1, width - 2)
508 let text = span.text
509 while (text.length > cut) {
510 place({ ...span, text: text.slice(0, cut) }, cut + 2)
511 text = text.slice(cut)
512 }
513 place({ ...span, text }, text.length + 2)
514 continue
515 }
516 span.text.split(' ').forEach((word, index) => {
517 if (index > 0) spaceBefore = true
518 let rest = word
519 while (rest.length > width) {
520 place({ ...span, text: rest.slice(0, width) }, width)
521 rest = rest.slice(width)
522 }
523 if (rest) place({ ...span, text: rest }, rest.length)
524 })
525 }
526 return lines
527}
528
529function startsBlock(line: string) {
530 return FENCE.test(line) || HEADING.test(line) || LIST_ITEM.test(line) || TABLE.test(line) || QUOTE.test(line) || RULE.test(line)
531}
532
533// Headings, paragraphs, lists and tables are drawn here, so headings get their level's
534// colour, bold and italic theirs, and code its pill; code blocks, quotes and rules go to
535// the engine's own Markdown element.
536function splitBlocks(text: string): Block[] {
537 const lines = text.split('\n')
538 const blocks: Block[] = []
539 let i = 0
540
541 const take = (test: (line: string) => boolean) => {
542 const start = i
543 while (i < lines.length && test(lines[i] ?? '')) i++
544 return lines.slice(start, i)
545 }
546
547 while (i < lines.length) {
548 const line = lines[i] ?? ''
549 if (line.trim() === '') {
550 i++
551 continue
552 }
553
554 if (FENCE.test(line)) {
555 const fence = line.trim().slice(0, 3)
556 const start = i
557 i++
558 while (i < lines.length && !(lines[i] ?? '').trim().startsWith(fence)) i++
559 i = Math.min(i + 1, lines.length)
560 blocks.push({ kind: 'markdown', text: lines.slice(start, i).join('\n') })
561 continue
562 }
563
564 if (RULE.test(line)) {
565 i++
566 blocks.push({ kind: 'markdown', text: line })
567 continue
568 }
569
570 // A table needs its rule as the second line; the outer pipes are optional
571 const second = lines[i + 1] ?? ''
572 if (line.includes('|') && second.includes('|') && TABLE_RULE.test(second)) {
573 blocks.push(tableBlock(take(next => next.includes('|') && next.trim() !== '')))
574 continue
575 }
576
577 if (TABLE.test(line) || QUOTE.test(line)) {
578 const pattern = TABLE.test(line) ? TABLE : QUOTE
579 blocks.push({ kind: 'markdown', text: take(next => pattern.test(next)).join('\n') })
580 continue
581 }
582
583 const heading = HEADING.exec(line)
584 if (heading) {
585 i++
586 blocks.push({ kind: 'heading', level: (heading[1] ?? '#').length, text: heading[2] ?? '' })
587 continue
588 }
589
590 if (LIST_ITEM.test(line)) {
591 const items: ListItem[] = []
592 while (i < lines.length) {
593 const current = lines[i] ?? ''
594 const item = LIST_ITEM.exec(current)
595 if (item) {
596 const indent = (item[1] ?? '').length
597 items.push({ depth: Math.min(4, Math.floor(indent / 2)), marker: /^\d/.test(item[2] ?? '') ? item[2] ?? '' : '-', text: item[3] ?? '' })
598 } else if (/^\s+\S/.test(current) && items.length > 0 && !FENCE.test(current)) {
599 const previous = items[items.length - 1]
600 if (previous) previous.text += ` ${current.trim()}`
601 } else {
602 break
603 }
604 i++
605 }
606 blocks.push({ kind: 'list', items })
607 continue
608 }
609
610 const paragraph = [line]
611 i++
612 while (i < lines.length && (lines[i] ?? '').trim() !== '' && !startsBlock(lines[i] ?? '')) {
613 paragraph.push(lines[i] ?? '')
614 i++
615 }
616 blocks.push({ kind: 'paragraph', text: paragraph.join('\n') })
617 }
618
619 return blocks
620}
621
622export const register: Register = on => {
623 on('turn.start', async ($, e, next) => {
624 if (e.agentId === undefined) await update($, liveCalls, () => [])
625
626 return next(e)
627 })
628
629 // Only records the call; a failure here must never stop the tool
630 on('tool.call', async ($, e, next) => {
631 const id = e.tool_use_id
632 if (e.agentId === undefined && id !== undefined) await update($, liveCalls, ids => [...ids, id].slice(-500))
633
634 return next(e)
635 }).catch(($, e, next) => next(e))
636
637 on('session.start', async ($, e, next) => {
638 const started = await next(e)
639 // A resumed session's earlier final replies get their frame too
640 const messages = await refreshFileRuns($)
641 await update($, finalReplies, () => findFinalReplies(messages))
642
643 return started
644 })
645
646 // A resume swaps the transcript in place with no session.start after it, so its final
647 // replies are read again. The timeline's /resume-at and branch switches are resumes too
648 on('session.end', async ($, e, next) => {
649 if (e.reason !== 'resume') return next(e)
650
651 const before = transcriptKey(await $.session.messages())
652 $.clock.after(250, () => void readResumed($, before).catch(() => undefined))
653
654 return next(e)
655 })
656
657 on('turn.complete', async ($, e, next) => {
658 if (e.agentId !== undefined) return next(e)
659
660 const doneAt = await $.clock.now()
661 await update($, turnTimes, list => [...list, { durationMs: e.durationMs, doneAt }].slice(-500))
662 if (e.answer.trim() !== '') await update($, finalReplies, keys => [...keys, replyKey(e.answer)].slice(-500))
663 const completed = await next(e)
664 await refreshFileRuns($)
665 await update($, liveCalls, () => [])
666
667 return completed
668 })
669
670 // "✻ Cooked for 36s · done 1:14 PM" in dimmed orange
671 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
672 if (e.surface !== 'terminal') return next(e)
673
674 const { Box, Text } = $.ui.resolve(e)
675 const times = await read($, turnTimes)
676 const match = times
677 .filter(time => Math.abs(time.durationMs - e.props.durationMs) <= 1000)
678 .sort((a, b) => Math.abs(a.durationMs - e.props.durationMs) - Math.abs(b.durationMs - e.props.durationMs))[0]
679 const done = match ? ` · done ${formatClock(match.doneAt)}` : ''
680
681 return (
682 <Box flexDirection="row" marginTop={1}>
683 <Box width={2}>
684 <Text color={dimmed(TIMING)}>✻</Text>
685 </Box>
686 <Text color={dimmed(TIMING)}>{`${e.props.word} for ${formatDuration(e.props.durationMs)}${done}`}</Text>
687 </Box>
688 )
689 })
690
691 // A running group unfolds into one card per call; a finished one folds into an italic summary
692 // that opens into its cards, set in under it. Tool rows always sit between a prompt and its
693 // final reply, so every part of them is dimmed
694 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
695 if (e.surface !== 'terminal') return next(e)
696 const expanded = { ...e, props: { ...e.props, isExpanded: true } }
697 if (e.props.isActive) return next(expanded)
698
699 const { Box, Text, Button } = $.ui.resolve(e)
700 const state = memberOf(groupState, { requestId: e.props.calls[0]?.tool_use_id ?? e.requestId })
701 // A fullscreen click on the row expands it too
702 const isOpen = e.props.isExpanded || (await read($, state)) === 'open'
703 const failed = e.props.calls.filter(call => call.isErrored).length
704
705 const summary = (
706 <Box paddingLeft={2}>
707 <Button key="group" plain onPress={() => update($, state, current => (current === 'open' ? 'folded' : 'open'))}>
708 <Text>
709 <Text color={dimmed(TOOL_SUMMARY)} italic>
710 {`${summarize(e.props.calls)}${isOpen ? ' ▾' : ''}`}
711 </Text>
712 {failed > 0 ? <Text color={dimmed(ERROR)} italic>{` · ${failed} failed`}</Text> : ''}
713 </Text>
714 </Button>
715 </Box>
716 )
717
718 return (
719 <Box flexDirection="column" marginTop={1}>
720 {summary}
721 {isOpen ? (
722 <Box flexDirection="column" paddingLeft={NESTED_INDENT}>
723 {await next(expanded)}
724 </Box>
725 ) : null}
726 </Box>
727 )
728 })
729
730 // Each tool call's first line as a card; a click on it shows or hides the result inside it
731 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
732 if (e.surface !== 'terminal') return next(e)
733
734 const { Box, Text, Button, Code } = $.ui.resolve(e)
735 const { tool_use_id: id, tool, input, output, isRunning, isErrored, isInterrupted } = e.props
736 const state = memberOf(cardState, e)
737 const isOpen = !isRunning && isCardOpen(await read($, state), isErrored)
738 const { first, more } = describeInput(tool, input)
739 const toggle = () => update($, state, current => toggled(current, isErrored))
740 const cardBg = dimmed(CARD_BG)
741 const muted = dimmed(CARD_ARGS)
742
743 const head = [
744 <Text weight="bold" color={dimmed(isErrored ? ERROR : CARD_NAME)} backgroundColor={cardBg}>
745 {displayName(tool)}
746 </Text>,
747 first !== '' ? (
748 <Text color={muted} backgroundColor={cardBg}>
749 {` ${first}`}
750 </Text>
751 ) : null,
752 more > 0 ? (
753 <Text color={muted} backgroundColor={cardBg}>
754 {` (+${more} lines)`}
755 </Text>
756 ) : null,
757 isRunning ? (
758 <Text color={dimmed(WARNING)} backgroundColor={cardBg}>
759 {' •••'}
760 </Text>
761 ) : null,
762 isInterrupted ? (
763 <Text color={muted} backgroundColor={cardBg}>
764 {' · interrupted'}
765 </Text>
766 ) : null,
767 ].filter(part => part !== null)
768
769 // The result is drawn here rather than by Claude Code, whose own drawing a card cannot hold
770 // at full width and which a row inside an expanded group does not get
771 const body = isOpen ? describeOutput(tool, output, isErrored) : undefined
772 const card = (
773 <Box flexDirection="column" width="100%" marginTop={1} paddingX={1} paddingY={1} backgroundColor={cardBg}>
774 <Button key="head" plain onPress={toggle}>
775 {head}
776 </Button>
777 {body !== undefined ? (
778 <Box flexDirection="column" marginTop={1} paddingLeft={2}>
779 {body.kind === 'code' ? (
780 <Code {...body.props} />
781 ) : (
782 <Text color={dimmed(body.isError ? ERROR : FOREGROUND)} backgroundColor={cardBg}>
783 {body.text}
784 </Text>
785 )}
786 {body.more > 0 ? (
787 <Text color={muted} backgroundColor={cardBg}>
788 {`… ${plural(body.more, 'more line', 'more lines')}`}
789 </Text>
790 ) : null}
791 </Box>
792 ) : null}
793 </Box>
794 )
795
796 // A file change whose turn has ended is a line; a run of them folds into one summary line
797 // that opens into their cards, each still closed
798 const verb = FILE_VERBS[tool]
799 if (verb === undefined || isRunning || isErrored || (await read($, liveCalls)).includes(id)) return card
800
801 const run = (await read($, fileRuns)).find(calls => calls.some(call => call.id === id))
802 const runMember = memberOf(runState, { requestId: run?.[0]?.id ?? id })
803 const isRunOpen = run !== undefined && (await read($, runMember)) === 'open'
804
805 if (run === undefined) {
806 return isOpen ? (
807 card
808 ) : (
809 <Box marginTop={1} paddingLeft={2}>
810 <Button key="head" plain onPress={toggle}>
811 <Text color={dimmed(TOOL_SUMMARY)} italic>
812 {`${verb} ${basename(first)}`}
813 </Text>
814 </Button>
815 </Box>
816 )
817 }
818
819 const summary = (
820 <Box marginTop={1} paddingLeft={2}>
821 <Button key="run" plain onPress={() => update($, runMember, current => (current === 'open' ? 'folded' : 'open'))}>
822 <Text color={dimmed(TOOL_SUMMARY)} italic>
823 {`${describeRun(run)}${isRunOpen ? ' ▾' : ''}`}
824 </Text>
825 </Button>
826 </Box>
827 )
828 const isFirstOfRun = run[0]?.id === id
829 if (!isRunOpen) return isFirstOfRun ? summary : <Box />
830
831 const nested = (
832 <Box flexDirection="column" paddingLeft={NESTED_INDENT}>
833 {card}
834 </Box>
835 )
836 return isFirstOfRun ? (
837 <Box flexDirection="column">
838 {summary}
839 {nested}
840 </Box>
841 ) : (
842 nested
843 )
844 })
845
846 // The result Claude Code draws under a tool row: the card draws it instead
847 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
848 if (e.surface !== 'terminal') return next(e)
849
850 const { Box } = $.ui.resolve(e)
851 return <Box />
852 })
853
854 // Replies without the opening dot, with inline code drawn as a pill and bold and italic in
855 // colour; every reply before the turn's final one is dimmed, summaries of the text between
856 // tool calls included
857 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
858 if (e.surface !== 'terminal') return next(e)
859
860 const { Box, Text, Markdown, Link } = $.ui.resolve(e)
861 const isFinal = e.props.isSummary !== true && (await read($, finalReplies)).includes(replyKey(e.props.text))
862 const tone = (color: string) => (isFinal ? color : dimmed(color))
863 const plain = isFinal ? undefined : dimmed(FOREGROUND)
864
865 // Plain text goes in as a bare string, which keeps the tree small. Headings pass
866 // emphasis false, so their own colour wins.
867 const draw = (spans: Span[], emphasis = true) =>
868 spans.map(span => {
869 // No-break spaces pad the pill, so a line never breaks inside it
870 if (span.code) return <Text color={tone(CODE_TEXT)} backgroundColor={CODE_BG}>{` ${span.text} `}</Text>
871 // Where the terminal has no hyperlinks a Link draws its text then the URL again, so
872 // a bare URL stays plain text
873 if (span.href) return span.href === span.text ? span.text : <Link href={span.href}>{span.text}</Link>
874 if (!span.bold && !span.italic && !span.strike) return span.text
875 const color = emphasis && (span.bold || span.italic) ? tone(span.bold ? BOLD : ITALIC) : undefined
876 return (
877 <Text weight={span.bold ? 'bold' : undefined} italic={span.italic} strikethrough={span.strike} color={color}>
878 {span.text}
879 </Text>
880 )
881 })
882 const inline = (text: string, emphasis = true) => draw(parseInline(text), emphasis)
883
884 const drawn = splitBlocks(e.props.text).map((block, index) => {
885 switch (block.kind) {
886 case 'markdown':
887 return <Markdown text={block.text} dimColor={!isFinal} />
888 case 'heading': {
889 const color = block.level === 1 ? HEADING_1 : block.level === 2 ? HEADING_2 : HEADING_REST
890 // Level 1 and 2 headings get a second blank line above them, so sections stand apart
891 return (
892 <Box marginTop={index > 0 && block.level <= 2 ? 1 : 0}>
893 <Text>
894 <Text color={tone(HEADING_MARK)}>{`${'#'.repeat(block.level)} `}</Text>
895 <Text weight="bold" color={tone(color)} italic={block.level === 1} underline={block.level === 1}>
896 {inline(block.text, false)}
897 </Text>
898 </Text>
899 </Box>
900 )
901 }
902 case 'paragraph':
903 return <Text color={plain}>{inline(block.text)}</Text>
904 case 'list':
905 return (
906 <Box flexDirection="column">
907 {block.items.map(item => (
908 <Box flexDirection="row" paddingLeft={item.depth * 2}>
909 <Box width={item.marker.length + 1} flexShrink={0}>
910 <Text color={plain}>{item.marker}</Text>
911 </Box>
912 <Box flexGrow={1} flexShrink={1}>
913 <Text color={plain}>{inline(item.text)}</Text>
914 </Box>
915 </Box>
916 ))}
917 </Box>
918 )
919 case 'table': {
920 const columns = block.align.length
921 // Borders and cell padding take 3 cells a column and 1 more, and a final reply's
922 // frame 2 more; the rest is for text
923 const room = (e.viewport?.columns ?? 80) - 4 - (isFinal ? 2 : 0) - (3 * columns + 1)
924 // Too narrow to give each column a few cells: the engine draws it
925 if (room < 4 * columns) return <Markdown text={block.source} dimColor={!isFinal} />
926 const natural = block.align.map((_, column) => Math.max(1, ...block.rows.map(row => spansWidth(row[column] ?? []))))
927 const longest = block.align.map((_, column) => Math.max(1, ...block.rows.map(row => longestPiece(row[column] ?? []))))
928 const widths = fitWidths(natural, longest, room)
929 const wrapped = block.rows.map(cells => cells.map((cell, column) => wrapSpans(cell, widths[column] ?? 1)))
930
931 const rule = (left: string, middle: string, right: string) => (
932 <Text color={plain}>{left + widths.map(width => '─'.repeat(width + 2)).join(middle) + right}</Text>
933 )
934 // One line of a row. The header is centred, as the engine draws it; body cells
935 // follow the rule's colons
936 const line = (cells: Span[][][], index: number, isHeader: boolean) => {
937 const parts = cells.flatMap((lines, column) => {
938 const content = lines[index] ?? []
939 const gap = (widths[column] ?? 0) - spansWidth(content)
940 const align = isHeader ? 'center' : block.align[column]
941 const before = align === 'right' ? gap : align === 'center' ? Math.floor(gap / 2) : 0
942 return [`│ ${' '.repeat(before)}`, ...draw(content), `${' '.repeat(gap - before)} `]
943 })
944 // Borders, padding and plain cells join into as few strings as they can
945 const joined = [...parts, '│'].reduce<typeof parts>((list, part) => {
946 const previous = list[list.length - 1]
947 if (typeof part === 'string' && typeof previous === 'string') list[list.length - 1] = previous + part
948 else list.push(part)
949 return list
950 }, [])
951 return <Text color={plain}>{joined}</Text>
952 }
953 const row = (cells: Span[][][], isHeader: boolean) =>
954 Array.from({ length: Math.max(1, ...cells.map(lines => lines.length)) }, (_, index) => line(cells, index, isHeader))
955
956 return (
957 <Box flexDirection="column">
958 {rule('┌', '┬', '┐')}
959 {wrapped.flatMap((cells, index) => (index === 0 ? row(cells, true) : [rule('├', '┼', '┤'), ...row(cells, false)]))}
960 {rule('└', '┴', '┘')}
961 </Box>
962 )
963 }
964 }
965 })
966
967 // The frame's left edge and padding keep the text where an unframed reply has it
968 return isFinal ? (
969 <Box flexDirection="column" marginTop={1} paddingX={1} gap={1} borderStyle="round" borderColor={FRAME}>
970 {drawn}
971 </Box>
972 ) : (
973 <Box flexDirection="column" marginTop={1} paddingLeft={2} gap={1}>
974 {drawn}
975 </Box>
976 )
977 })
978}
979types/index.d.ts 19 lines1export type CardState = 'auto' | 'open' | 'closed'
2export type RunState = 'folded' | 'open'
3export type TurnTime = { durationMs: number; doneAt: number }
4export type FileCall = { id: string; verb: string; file: string }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'dracula-theme-mod': {
9 cardState: StateFamily<CardState>
10 runState: StateFamily<RunState>
11 groupState: StateFamily<RunState>
12 turnTimes: TurnTime[]
13 liveCalls: string[]
14 fileRuns: FileCall[][]
15 finalReplies: string[]
16 }
17 }
18}
19