SLOPSHOPPER

dracula-theme-mod

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

newspinnerrowsguardtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dracula-theme-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines head ⏺ Bash(bun test) ⎿ 3 pass, 1 fail Done. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s · done 8:53 AM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Turn line
✻ Worked for 42s · done 8:53 AM
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
head
README

dracula-theme-mod

Restyles the Claude Code transcript in Dracula colours.

  • Tool calls are drawn as cards, with the tool name and its arguments.
  • Status lines are coloured, and turn timings are shown.
  • Inline code is drawn as a pill.
  • Headings, bold and italic text use Dracula colours.
  • The final reply of each turn has a faint frame, and text between your prompt and that reply is dimmed.
  • Replies are drawn without the opening dot.

It has no options or commands.

Install

/plugin install dracula-theme-mod --marketplace xRyul/claude-plugins-and-mods-by-xryul

Licence

MIT

Source 2 files
hooks/register.tsx 979 lines
1import { 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}
979
types/index.d.ts 19 lines
1export 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