Skins for Claude Code: tool rows that open onto their input and answer, highlighted code, diff, table, math, Mermaid and shell cards, a usage band and fifteen…

Install: /plugin install skins --marketplace samuelbfernandes/moddeck
Fifteen colour themes ("skins") for Claude Code, and a nicer drawing of its output: tool-call rows with icons and timings, diff, table, code, math, Mermaid and shell cards, links, folding and a usage band.
This mod is not my work. It is hellosverre/claude-skins (version 0.5.9, MIT, © 2026 hellosverre), copied into moddeck unchanged except for the points below. Every skin, every card and the /skin command are the original author's. The original licence is in LICENSE. If you like it, star the original repository.
What changed in this copy:
plugin.json says where it came from. The skin-designer skill, the tests and the drawing code are as published.Pick a skin from the skins dropdown in the moddeck side bar, or type:
/skin open the skin settings /skin <name> switch to a skin (noir, tokyo-night, dracula, nord, gruvbox, catppuccin, rose-pine, everforest, one-dark, solarized, github, kanagawa, ayu, night-owl, mono) /skin list list them /skin off go back to Claude Code's own look /skin gallery see them all
Skins follow Claude Code's light or dark theme. The full command list is in the original README.
/skin off, or /plugin disable skins.
No model calls, no network, no programs started. Not tested on every terminal; layout was verified only by the original author's tests, which are included.
hooks/register.tsx 1219 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderSurface, Timer } from 'claude-code'
3
4import type { CustomSkin, Disclosure, Prefs, SkinSlot, SkinsMarkdownArgs, Touch, TurnStats, UsageSnap } from '../types'
5import { DEFAULT_PREFS, nextTables, parsePrefs, runSkinCommand, TOGGLES, withCalm } from './command'
6import { buildCustom, resolveSkin, skinNames, withSlot } from './custom'
7import { forTheme, gnomeDark, macDark, resolveLight, windowsDark } from './light'
8import { parseFolders, prefsFor, withFolder, withoutFolder } from './folders'
9import { DESIGN_TOOL, runDesign } from './designer'
10import type { DesignState } from './designer'
11import { clipLines, diffstat, pick } from './format'
12import { bodyOf, inputFields, isOpen } from './detail'
13import { disclosedRow, openedRows } from './detail-rows'
14import { copyOf } from './markdown'
15import { CHART_HINT } from './mermaid'
16import { commandSegments } from './command-output'
17import { askBand, desktopSpinnerRow, diffCard, footerRow, terminalCard, usageBand, groupRow, promptRow, quietResult, replyRows, spinnerRow, toolRow } from './rows'
18import type { Look, SvgElement, Ui } from './rows'
19import { galleryPane } from './gallery'
20import { keepsOwnRow, SHELLS, shellResultOf } from './shell'
21import { OUTPUT_FOLD, shellRows } from './shell-rows'
22import { settingsPane } from './settings'
23import { sightings } from './sightings'
24import { ICONS } from './skin'
25import type { Skin } from './skin'
26import { hunksOf } from './svg-diff'
27import { shellOutputOf } from './svg-terminal'
28import { countdownsOf, limitLabel, metersOf } from './svg-usage'
29import { shortenPath } from './format'
30import { kindOf, summarize, toolLabel } from './tools'
31import { commandOf, errorLine, isQuiet, quietLabel } from './quiet'
32import { MAX_MARKDOWN, replySegments } from './reply'
33import { hyperlinksFrom, touchOf } from './links'
34
35const SETTINGS = 'skins-settings'
36const GALLERY = 'skins-gallery'
37const DESIGN = `mcp__skins__${DESIGN_TOOL.name}`
38const DESIGN_MATCH = /^mcp__skins__design$/
39const DESIGN_TOOL_WAIT_MS = 1000
40
41// A longer prompt keeps Claude Code's own drawing, which folds a big paste.
42const MAX_PROMPT = 4000
43
44// The settings pane's prompt button cycles through these.
45const PROMPT_NEXT: Readonly<Record<Prefs['prompt'], Prefs['prompt']>> = { left: 'right', right: 'plain', plain: 'left' }
46
47const CLIP_HEAD = 8
48const CLIP_TAIL = 4
49const FRAME_MS = 90
50// Countdowns read to the minute, so they are looked at once a minute.
51const COUNTDOWN_MS = 60_000
52const SETTLE_LEAD_MS = FRAME_MS
53const FAST_RETRIES = 4
54const RETRY_MAX_MS = 1000
55const STALE_AHEAD_MS = RETRY_MAX_MS + 1000
56const KEPT_TURNS = 40
57// How often an `auto` theme asks the system again: nothing tells a plugin it changed.
58const APPEARANCE_MS = 5000
59
60const prefsAtom = atom({ plugin: 'skins', key: 'prefs' } as const, DEFAULT_PREFS)
61const customAtom = atom({ plugin: 'skins', key: 'custom' } as const, {})
62const startedAtom = atom({ plugin: 'skins', key: 'startedAt' } as const, 0)
63const frameAtom = atom({ plugin: 'skins', key: 'frame' } as const, 0)
64const turnsAtom = atom({ plugin: 'skins', key: 'turns' } as const, {})
65const durationAtom = atom({ plugin: 'skins', key: 'duration' } as const, -1)
66const editingAtom = atom({ plugin: 'skins', key: 'editing' } as const, 'user' as SkinSlot)
67const lightAtom = atom({ plugin: 'skins', key: 'isLight' } as const, false)
68const imagesAtom = atom({ plugin: 'skins', key: 'images' } as const, false)
69// Whether a call only looked, so quiet output folds its result away.
70const quietAtom = atom({ plugin: 'skins', key: 'quiet' } as const, false)
71const commandAtom = atom({ plugin: 'skins', key: 'command' } as const, '')
72const usageAtom = atom({ plugin: 'skins', key: 'usage' } as const, { context: null, limits: [] } as UsageSnap)
73// The band's countdowns as last drawn; it changes only when one of them reads differently.
74const countdownsAtom = atom({ plugin: 'skins', key: 'countdowns' } as const, {} as Record<string, string>)
75const compactingAtom = atom({ plugin: 'skins', key: 'compacting' } as const, false)
76const pinnedAtom = atom({ plugin: 'skins', key: 'pinned' } as const, false)
77const settleAtom = atom({ plugin: 'skins', key: 'settle' } as const, 0)
78// Whether each tool row is open, as the person left it; `auto` opens a failed call.
79const disclosureAtom = atom({ plugin: 'skins', key: 'disclosure' } as const, 'auto' as Disclosure)
80// Whether an opened row shows its answer whole, past the fold.
81const allAtom = atom({ plugin: 'skins', key: 'showAll' } as const, false)
82// Files this turn created or edited, by absolute path.
83const touchedAtom = atom({ plugin: 'skins', key: 'touched' } as const, {} as Record<string, Touch>)
84// Per drawing, the blocks the person unfolded.
85const foldsAtom = atom({ plugin: 'skins', key: 'folds' } as const, [] as string[])
86// The main loop's last answer, for `/skin copy`.
87const lastReplyAtom = atom({ plugin: 'skins', key: 'lastReply' } as const, '')
88
89const EDITS = new Set(['Edit', 'MultiEdit', 'Write'])
90const WRITES = new Set([...EDITS, 'NotebookEdit'])
91
92// Only a person's own typing becomes a prompt row: a task notification or a peer's
93// message is not theirs to dress as theirs.
94const TYPED = new Set(['composer', 'bridge', 'sdk'])
95
96type Active = { prefs: Prefs; skin: Skin; custom: Record<string, CustomSkin> }
97
98const NO_STATS: TurnStats = { tools: 0, added: 0, removed: 0 }
99
100async function activeSkin($: EngineInterface): Promise<Active | null> {
101 const prefs = await read($, prefsAtom)
102 const custom = await read($, customAtom)
103 const skin = resolveSkin(prefs.skin, custom)
104
105 return skin === undefined ? null : { prefs, skin: forTheme(skin, await read($, lightAtom)), custom }
106}
107
108// Whether the skin-code mod draws this reply's code in a VS Code theme. False when it is not
109// installed (the call throws), is off, or fails to answer.
110async function drawnBySkinCode($: EngineInterface, text: unknown): Promise<boolean> {
111 if (typeof text !== 'string') {
112 return false
113 }
114
115 try {
116 return (await $.skinCode!.draws({ text })) === true
117 } catch {
118 return false
119 }
120}
121
122// Where each system keeps its appearance, and how to read the answer.
123const PROBES = [
124 { argv: ['defaults', 'read', '-g', 'AppleInterfaceStyle'], dark: macDark },
125 {
126 argv: ['reg', 'query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize', '/v', 'AppsUseLightTheme'],
127 dark: windowsDark,
128 },
129 { argv: ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], dark: gnomeDark },
130] as const
131
132// Read once a session or a turn and kept here: each read through `$` is a dispatch, and
133// every row draws often. Whether the terminal draws OSC 8 links, the session's folder, and
134// whether this turn has touched a file at all (the list itself is `touchedAtom`).
135let hyperlinks = false
136let sessionCwd: string | undefined
137let hasTouched = false
138
139const cwdOf = async ($: EngineInterface): Promise<string> => sessionCwd ?? (await $.session.cwd())
140
141// The probe that answered last, so the timer asks one system rather than all three.
142let answering: (typeof PROBES)[number] | undefined
143
144// The system's appearance, for an `auto` theme. Undefined when no probe answers.
145async function systemDark($: EngineInterface): Promise<boolean | undefined> {
146 for (const probe of answering === undefined ? PROBES : [answering, ...PROBES]) {
147 try {
148 const dark = probe.dark(await $.process.run([...probe.argv], { timeoutMs: 2000 }))
149
150 if (dark !== undefined) {
151 answering = probe
152 return dark
153 }
154 } catch {
155 // The other systems' tools are missing here; that is expected, not a failure.
156 }
157 }
158
159 answering = undefined
160 return undefined
161}
162
163// Claude Code's own theme decides whether skins draw for a light or a dark background:
164// `SKINS_THEME` first, then the theme setting, and for `auto` the terminal and the system.
165// `theme` is a value just written, which `$.config.list()` does not answer with yet.
166// Resolves true when the answer came from the system's appearance, which can change under it.
167async function refreshTheme($: EngineInterface, theme?: unknown): Promise<boolean> {
168 const hints = {
169 override: await $.env.get('SKINS_THEME'),
170 theme: theme ?? (await $.config.list()).find(row => row.key === 'theme')?.value,
171 colorfgbg: await $.env.get('COLORFGBG'),
172 }
173 // Asks the system only when nothing before it decided.
174 const needsSystem = resolveLight(hints) !== resolveLight({ ...hints, systemDark: false })
175 const dark = needsSystem ? await systemDark($) : undefined
176 const isLight = resolveLight({ ...hints, systemDark: dark })
177
178 // A write redraws every row, and the system is asked again every few seconds.
179 if ((await read($, lightAtom)) !== isLight) {
180 await update($, lightAtom, () => isLight)
181 }
182
183 return dark !== undefined
184}
185
186// Asks the system again on a timer while the theme follows it; any other theme stops it.
187let appearance: Timer | undefined
188
189async function followTheme($: EngineInterface, theme?: unknown): Promise<void> {
190 const followsSystem = await refreshTheme($, theme)
191
192 appearance?.cancel()
193 appearance = followsSystem
194 ? $.clock.every(APPEARANCE_MS, () => {
195 void refreshTheme($).catch((error: unknown) => {
196 $.ui.log(error instanceof Error ? error.message : String(error), { to: 'debug' })
197 })
198 })
199 : undefined
200}
201
202// Every surface's element table names Svg, but the terminal draws it as nothing, so
203// vector icons are for the other surfaces only.
204const lookOf = (
205 ui: Ui & { Svg?: SvgElement },
206 active: Active,
207 surface: RenderSurface,
208 copy?: (text: string) => void,
209): Look => ({
210 ui,
211 skin: active.skin,
212 icons: ICONS[active.prefs.icons],
213 prefs: active.prefs,
214 surface,
215 ...(surface !== 'terminal' && ui.Svg !== undefined ? { svg: ui.Svg } : {}),
216 // `/skin copy off` takes every copy button away.
217 ...(copy === undefined || !active.prefs.copy ? {} : { copy }),
218})
219
220// What a drawing adds to its look from the session: links where they can be drawn, this
221// turn's files, and the blocks the person unfolded in it. Each is a read on every draw, so
222// a drawing asks only for what it can show.
223type Needs = { links?: boolean; touched?: boolean; folds?: boolean; cwd?: string }
224
225async function sessionLook($: EngineInterface, e: { requestId: string; surface: RenderSurface }, look: Look, needs: Needs): Promise<Look> {
226 const folds = memberOf(foldsAtom, e)
227 const links = needs.links === true && look.prefs.links && (e.surface !== 'terminal' || hyperlinks)
228 const touched = needs.touched === true && hasTouched ? { files: await read($, touchedAtom), cwd: needs.cwd ?? (await cwdOf($)) } : undefined
229 const open = needs.folds === true && look.prefs.fold ? new Set(await read($, folds)) : undefined
230
231 return {
232 ...look,
233 links,
234 ...(touched === undefined ? {} : { touched }),
235 ...(open === undefined
236 ? {}
237 : {
238 folds: {
239 open,
240 toggle: (key: string) =>
241 void update($, folds, keys => (keys.includes(key) ? keys.filter(kept => kept !== key) : [...keys, key])),
242 },
243 }),
244 }
245}
246
247// What a reply's blocks can show: links where it has a URL, file colours where it names a
248// path, fold controls where it has code or a table.
249const replyNeeds = (text: string, segments: readonly { kind: string }[]): Needs => ({
250 links: /https?:\/\//.test(text),
251 touched: /[\w.-]\/[\w.-]|\w\.[A-Za-z]{1,5}\b/.test(text),
252 folds: segments.some(segment => segment.kind === 'code' || segment.kind === 'table'),
253})
254
255// Whether the terminal draws OSC 8 links, from the environment it was started in.
256async function readHyperlinks($: EngineInterface): Promise<void> {
257 // Each name spelled out: the engine lists the variables a module reads from its source.
258 const supported = hyperlinksFrom({
259 FORCE_HYPERLINK: await $.env.get('FORCE_HYPERLINK'),
260 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
261 TERM_PROGRAM_VERSION: await $.env.get('TERM_PROGRAM_VERSION'),
262 TERM: await $.env.get('TERM'),
263 WT_SESSION: await $.env.get('WT_SESSION'),
264 VTE_VERSION: await $.env.get('VTE_VERSION'),
265 KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
266 TMUX: await $.env.get('TMUX'),
267 CI: await $.env.get('CI'),
268 })
269
270 hyperlinks = supported
271}
272
273// A drawing's cards animate on their first draw only, keyed by the drawing's instance
274// (the message id, the tool_use_id) so two replies never share a card.
275const seenCards = sightings()
276const settling = new Map<string, { at: number; cards: string[] }>()
277let settlingNow = new Set<string>()
278let refusals = 0
279
280const drawingOf = (e: { surface: RenderSurface; requestId: string }): string => `${e.surface}:${e.requestId}`
281
282// What Claude Code's `isExpanded` read when a group was first drawn under `expand`.
283const groupStarts = new Map<string, boolean>()
284
285const drawnBy = (drawing: string, drawnAt: number) => (key: string, entranceMs: number) => {
286 const card = `${drawing}:${key}`
287
288 if (settlingNow.delete(card)) {
289 seenCards(card)
290
291 return false
292 }
293
294 const isFirst = seenCards(card)
295
296 if (isFirst) {
297 const pending = settling.get(drawing)
298
299 settling.set(drawing, {
300 at: Math.max(pending?.at ?? 0, drawnAt + entranceMs - SETTLE_LEAD_MS),
301 cards: [...(pending?.cards ?? []), card],
302 })
303 }
304
305 return isFirst
306}
307
308// Calm draws every card settled, its first draw included.
309async function firstDraws($: EngineInterface, e: { surface: RenderSurface; requestId: string }, prefs: Prefs): Promise<(key: string, entranceMs: number) => boolean> {
310 const drawing = drawingOf(e)
311 settling.delete(drawing)
312
313 return prefs.calm === null ? drawnBy(drawing, await $.clock.now()) : () => false
314}
315
316async function withSettle<T>($: EngineInterface, e: { surface: RenderSurface; requestId: string }, drawn: T): Promise<T> {
317 if (settling.has(drawingOf(e))) {
318 await read($, settleAtom)
319 }
320
321 return drawn
322}
323
324async function settleEntrances($: EngineInterface, isForced = false): Promise<boolean> {
325 const now = await $.clock.now()
326 const isDue = (at: number): boolean => at <= now || at > now + STALE_AHEAD_MS
327
328 if (settling.size === 0 || (!isForced && ![...settling.values()].some(pending => isDue(pending.at)))) {
329 return true
330 }
331
332 const moved = [...settling]
333 settlingNow = new Set(moved.flatMap(([, pending]) => pending.cards))
334 settling.clear()
335
336 return update($, settleAtom, round => round + 1).then(
337 () => {
338 refusals = 0
339
340 return true
341 },
342 (error: unknown) => {
343 refusals += 1
344 const retryAt = now + Math.min(RETRY_MAX_MS, refusals <= FAST_RETRIES ? 0 : FRAME_MS * 2 ** (refusals - FAST_RETRIES))
345
346 for (const [drawing, pending] of moved) {
347 if (!settling.has(drawing)) {
348 settling.set(drawing, { ...pending, at: retryAt })
349 }
350 }
351
352 $.ui.log(`settle: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
353
354 return false
355 },
356 )
357}
358
359// Made skins from the store, each checked again: the store may hold an older shape.
360function parseCustom(raw: unknown): Record<string, CustomSkin> {
361 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
362
363 return Object.fromEntries(
364 Object.values(saved)
365 .map(draft => buildCustom(draft as Record<string, unknown>, undefined))
366 .filter((skin): skin is CustomSkin => typeof skin !== 'string')
367 .map(skin => [skin.name, skin]),
368 )
369}
370
371async function load($: EngineInterface): Promise<void> {
372 const custom = parseCustom(await $.store.get('custom'))
373 const names = skinNames(custom)
374 const folders = parseFolders(await $.store.get('folders'), names)
375 const folder = await $.session.cwd()
376 sessionCwd = folder
377 const prefs = prefsFor(folder, folders, parsePrefs(await $.store.get('prefs'), names))
378
379 await update($, customAtom, () => custom)
380 await update($, prefsAtom, () => prefs)
381 await update($, pinnedAtom, () => Object.hasOwn(folders, folder))
382 // Links stay text if the environment cannot be read; the look loads either way.
383 await readHyperlinks($).catch((error: unknown) => {
384 $.ui.log(`hyperlink check: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
385 })
386}
387
388// The session the prefs were last loaded for. /clear, /resume and /branch reset $.state to its
389// defaults and skip session.start, and on a Team or Enterprise plan the built-in guard
390// (cc-plugin-sec-default) keeps classic.SessionStart from user-tier mods: the new session's first
391// appended rows catch the change instead. No await between the check and the write, so a burst of
392// appends loads once.
393let loadedFor: string | undefined
394
395// Undefined where nothing answers for the session's id (a test's engine): then there is nothing to compare.
396const sessionId = ($: EngineInterface): Promise<string | undefined> => $.session.id().catch(() => undefined)
397
398async function reloadIfNewSession($: EngineInterface): Promise<void> {
399 const id = await sessionId($)
400
401 if (id === undefined || id === loadedFor) {
402 return
403 }
404
405 loadedFor = id
406
407 // A resumed scheduled-task session can start before Claude Code has bound it, and
408 // $.config.list rejects until then (#64). Not loaded yet, then: the next append tries again.
409 try {
410 await load($)
411 await followTheme($)
412 } catch (error) {
413 if (loadedFor === id) {
414 loadedFor = undefined
415 }
416 $.ui.log(`not loaded yet: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
417 }
418}
419
420// A pinned folder keeps its prefs to itself; every other folder shares the default.
421async function savePrefs($: EngineInterface, prefs: Prefs): Promise<void> {
422 if (!(await read($, pinnedAtom))) {
423 await $.store.set('prefs', prefs)
424
425 return
426 }
427
428 const names = skinNames(await read($, customAtom))
429 const folders = parseFolders(await $.store.get('folders'), names)
430 await $.store.set('folders', withFolder(folders, await $.session.cwd(), prefs))
431}
432
433// /skin pin, unpin and share: this folder's own look, or the default.
434async function runFolderCommand($: EngineInterface, word: string): Promise<string> {
435 const folder = await $.session.cwd()
436 const custom = await read($, customAtom)
437 const names = skinNames(custom)
438 const folders = parseFolders(await $.store.get('folders'), names)
439 const prefs = await read($, prefsAtom)
440
441 switch (word) {
442 case 'pin':
443 await $.store.set('folders', withFolder(folders, folder, prefs))
444 await update($, pinnedAtom, () => true)
445
446 return 'this folder keeps its own look'
447 case 'unpin': {
448 const fallback = parsePrefs(await $.store.get('prefs'), names)
449 await $.store.set('folders', withoutFolder(folders, folder))
450 await update($, pinnedAtom, () => false)
451 await update($, prefsAtom, () => fallback)
452
453 return `this folder follows the default: ${fallback.skin}`
454 }
455 default:
456 await $.store.set('prefs', prefs)
457
458 return `default look: ${prefs.skin}`
459 }
460}
461
462async function refreshUsage($: EngineInterface): Promise<void> {
463 const usage = await $.session.usage()
464 const snap: UsageSnap = {
465 context: usage.context.percent ?? null,
466 limits: usage.rateLimits.map(limit => {
467 const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
468
469 return { label: limitLabel(limit.kind), percent: limit.percentUsed, ...(Number.isNaN(resetsAt) ? {} : { resetsAt }) }
470 }),
471 }
472 // A window that resets moves its reset time even where its percent stays the same.
473 const drawn = (of: UsageSnap) => JSON.stringify([metersOf(of), of.limits.map(limit => limit.resetsAt ?? null)])
474
475 if (drawn(await read($, usageAtom)) === drawn(snap)) {
476 return
477 }
478
479 await settleEntrances($, true)
480 await update($, usageAtom, () => snap)
481}
482
483// Redraws the band when a countdown it shows would read differently, and not otherwise:
484// a redraw settles the cards still entering first, as a usage change does.
485async function tickCountdowns($: EngineInterface): Promise<void> {
486 const prefs = await read($, prefsAtom)
487
488 if (!prefs.countdown || !prefs.band) {
489 return
490 }
491
492 const next = countdownsOf(await read($, usageAtom), await $.clock.now())
493
494 if (JSON.stringify(next) === JSON.stringify(await read($, countdownsAtom))) {
495 return
496 }
497
498 await settleEntrances($, true)
499 await update($, countdownsAtom, () => next)
500}
501
502async function commit($: EngineInterface, state: DesignState): Promise<void> {
503 await update($, customAtom, () => state.custom)
504 await update($, prefsAtom, () => state.prefs)
505 await $.store.set('custom', state.custom)
506 await savePrefs($, state.prefs)
507}
508
509async function designState($: EngineInterface): Promise<DesignState> {
510 return { prefs: await read($, prefsAtom), custom: await read($, customAtom) }
511}
512
513const SURFACES: readonly RenderSurface[] = ['terminal', 'desktop', 'vscode', 'mobile']
514
515// `$.skins.markdown`: markdown drawn the way the skin draws a reply, for another mod's
516// drawing. Undefined when the skin is off or the text holds nothing it draws. No copy or
517// fold controls: a press cannot cross from one mod's tree to another's.
518async function drawMarkdown($: EngineInterface, args: SkinsMarkdownArgs): Promise<RenderElement | undefined> {
519 if (!SURFACES.includes(args.surface) || typeof args.text !== 'string' || !Number.isFinite(args.columns)) {
520 throw new Error('skins.markdown takes { surface, text, columns }: a surface name, a string and a number')
521 }
522
523 const active = await activeSkin($)
524 const segments = active === null || args.text.length > MAX_MARKDOWN * 4 ? null : replySegments(args.text, active.prefs, args.surface)
525
526 if (active === null || segments === null) {
527 return undefined
528 }
529
530 const ui = $.ui.resolve({ surface: args.surface, component: 'AssistantMessage' })
531 const svg = args.surface !== 'terminal' && active.prefs.tables === 'on' && 'Svg' in ui ? ui.Svg : undefined
532 const links = active.prefs.links && (args.surface !== 'terminal' || hyperlinks)
533
534 return replyRows({ ...lookOf(ui, active, args.surface), links, isFirstDraw: () => false }, segments, Math.max(20, Math.floor(args.columns)), svg)
535}
536
537export const register: Register = on => {
538 // Adds `$.skins` for other mods. A call is the `skins.markdown` event, which the hook
539 // below answers with this session's `$`; the method itself is the chain's floor.
540 on('engine.create', async ($, e, next) => {
541 const built = await next(e)
542
543 return { ...built, skins: { markdown: async () => undefined } }
544 })
545
546 on('skins.markdown', async ($, e) => ({ value: await drawMarkdown($, e) }))
547
548 // What the turn on the main loop has done so far, for its footer.
549 let stats: TurnStats = NO_STATS
550 let isWorking = false
551 let ticker: Timer | undefined
552 let countdownTicker: Timer | undefined
553 // The width the last reply was drawn at, shown by /skin list to tune table sizing.
554 let lastColumns: number | undefined
555
556 on('session.start', async ($, e, next) => {
557 await $.command.register({
558 name: 'skin',
559 description: 'Open the skin settings, or /skin <name | list | off>',
560 argumentHint: '[gallery | copy | copy code | name | list | off | calm | pin | unpin | share | rail | tables | shimmer | band | countdown | clip | markdown | quiet | charts | math | commands | shell | highlight | hints | links | fold | expand | icons | prompt | words]',
561 immediate: true,
562 })
563 const designTool = $.tool
564 .register({
565 name: DESIGN_TOOL.name,
566 description: DESIGN_TOOL.description,
567 inputSchema: DESIGN_TOOL.inputSchema,
568 })
569 .catch((error: unknown) => {
570 $.ui.log(`design tool: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
571 })
572 await new Promise<void>(resolve => {
573 const cap = $.clock.after(DESIGN_TOOL_WAIT_MS, resolve)
574 void designTool.then(() => {
575 cap.cancel()
576 resolve()
577 })
578 })
579 loadedFor = await sessionId($)
580 await load($)
581 await refreshUsage($)
582 await followTheme($)
583
584 // Only the spinner reads the frame, so a tick redraws the spinner and nothing else.
585 ticker?.cancel()
586 ticker = $.clock.every(FRAME_MS, () => {
587 if (isWorking) {
588 void update($, frameAtom, frame => frame + 1)
589 }
590
591 if (settling.size > 0) {
592 void settleEntrances($)
593 }
594 })
595 countdownTicker?.cancel()
596 countdownTicker = $.clock.every(COUNTDOWN_MS, () => {
597 void tickCountdowns($).catch((error: unknown) => {
598 $.ui.log(`countdown tick: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
599 })
600 })
601
602 return next(e)
603 })
604
605 // Where the guard does not hold it back, this comes first; session.append then finds the session loaded.
606 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
607 await reloadIfNewSession($)
608
609 return next(e)
610 })
611
612 on('ui.invalidate', async ($, e, next) => {
613 if (e.event === 'ui.render' && !(await settleEntrances($, true)) && refusals <= FAST_RETRIES) {
614 await $.clock.sleep(FRAME_MS / 3)
615 await settleEntrances($, true)
616 }
617
618 return next(e)
619 })
620
621 on('turn.start', async ($, e, next) => {
622 stats = NO_STATS
623 isWorking = true
624 // A worktree can move the session between turns.
625 sessionCwd = await $.session.cwd()
626 hasTouched = false
627 await update($, touchedAtom, () => ({}))
628 const now = await $.clock.now()
629 await update($, startedAtom, () => now)
630
631 return next(e)
632 })
633
634 on('config.set', async ($, e, next) => {
635 const result = await next(e)
636
637 // The written value: inside this hook $.config.list() still answers with the old one.
638 if (e.key === 'theme' && result.deny === undefined) {
639 await followTheme($, result.value)
640 }
641
642 return result
643 })
644
645 // Loads the prefs again for a new session (see loadedFor), and notes which prompts carried images,
646 // so their row keeps Claude Code's drawing of them.
647 on('session.append', async ($, e, next) => {
648 await reloadIfNewSession($)
649 const stored = await next(e)
650 const hasImage = e.door === 'prompt' && e.message.content.some(block => block.type === 'image')
651
652 if (hasImage && stored.uuid !== undefined) {
653 await update($, memberOf(imagesAtom, { requestId: stored.uuid }), () => true)
654 }
655
656 return stored
657 })
658
659 // A turn's end and a plan limit's move are when the band's numbers change.
660 on('session.measure', async ($, e, next) => {
661 await refreshUsage($)
662
663 return next(e)
664 })
665
666 on('turn.complete', async ($, e, next) => {
667 if (e.agentId === undefined) {
668 await refreshUsage($)
669 isWorking = false
670 const finished = stats
671 await update($, turnsAtom, turns =>
672 Object.fromEntries([...Object.entries(turns), [String(e.durationMs), finished]].slice(-KEPT_TURNS)),
673 )
674
675 if (e.answer.trim() !== '') {
676 await update($, lastReplyAtom, () => e.answer)
677 }
678 }
679
680 return next(e)
681 })
682
683 // Times every call and counts the main loop's calls and changed lines.
684 on('tool.call', async ($, e, next) => {
685 // Its output card's header names the command, which the result does not carry.
686 if (SHELLS.has(e.tool)) {
687 const command = commandOf(e)
688 await update($, memberOf(commandAtom, { requestId: e.tool_use_id }), () => command)
689 }
690
691 const startedAt = await $.clock.now()
692 const ran = await next(e)
693 const ms = (await $.clock.now()) - startedAt
694
695 await update($, memberOf(durationAtom, { requestId: e.tool_use_id }), () => ms)
696
697 // The call's arguments ride on the event itself (`e.command` for Bash).
698 if (isQuiet(e.tool, e)) {
699 await update($, memberOf(quietAtom, { requestId: e.tool_use_id }), () => true)
700 }
701
702 // A file the call made or changed takes its colour in this turn's rows and prose.
703 const file = (e as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path
704
705 if (WRITES.has(e.tool) && ran.deny === undefined && typeof file === 'string' && file !== '') {
706 const touch: Touch = (ran.result as { type?: unknown } | undefined)?.type === 'create' ? 'created' : 'edited'
707 hasTouched = true
708 await update($, touchedAtom, touched => (touched[file] === 'created' ? touched : { ...touched, [file]: touch }))
709 }
710
711 if (e.agentId === undefined && e.tool !== DESIGN && ran.deny === undefined) {
712 const diff = diffstat(ran.result)
713 stats = {
714 tools: stats.tools + 1,
715 added: stats.added + (diff?.added ?? 0),
716 removed: stats.removed + (diff?.removed ?? 0),
717 }
718 }
719
720 return ran
721 })
722
723 on('tool.call', { tool: DESIGN_MATCH }, async ($, e) => {
724 const outcome = runDesign(e, await designState($))
725
726 if (outcome.isError) {
727 return { deny: outcome.text }
728 }
729
730 await commit($, outcome.state)
731
732 return { result: outcome.text }
733 })
734
735 on('command.run', { command: 'skin' }, async ($, e) => {
736 if (e.args.trim() === '') {
737 await $.ui.open({ id: SETTINGS, title: 'Skins', focus: true, closeOnEscape: true })
738
739 return {}
740 }
741
742 if (e.args.trim().toLowerCase() === 'gallery') {
743 await $.ui.open({ id: GALLERY, title: 'Skin gallery', focus: true, closeOnEscape: true })
744
745 return {}
746 }
747
748 const word = e.args.trim().toLowerCase()
749
750 if (word === 'copy' || word === 'copy code') {
751 const copied = copyOf(await read($, lastReplyAtom), word === 'copy code')
752
753 if ('message' in copied) {
754 $.ui.toast(copied.message)
755 } else {
756 const result = await $.ui.copy({ text: copied.text })
757 $.ui.toast(result.isCopied ? (word === 'copy' ? 'Copied the reply' : 'Copied the code') : 'Could not copy here')
758 }
759
760 return {}
761 }
762
763 if (word === 'pin' || word === 'unpin' || word === 'share') {
764 $.ui.toast(await runFolderCommand($, word))
765
766 return {}
767 }
768
769 const current = await read($, prefsAtom)
770 const custom = await read($, customAtom)
771 const outcome = runSkinCommand(e.args, current, skinNames(custom))
772
773 if (outcome.prefs !== current) {
774 await commit($, { prefs: outcome.prefs, custom })
775 }
776
777 if (outcome.channel === 'row') {
778 const width = lastColumns === undefined ? '' : `
779reply width: ${lastColumns} columns`
780
781 const folder = (await read($, pinnedAtom)) ? 'this folder: pinned' : 'this folder: follows the default'
782
783 return { text: e.args.trim() === 'list' ? `${outcome.message}\n${folder}${width}` : outcome.message }
784 }
785
786 $.ui.toast(outcome.message)
787
788 return {}
789 })
790
791 on('ui.render', { component: 'Pane', requestId: SETTINGS }, async ($, e) => {
792 const prefs = await read($, prefsAtom)
793 const custom = await read($, customAtom)
794 const editing = await read($, editingAtom)
795 const skin = resolveSkin(prefs.skin, custom) ?? resolveSkin(DEFAULT_PREFS.skin, custom)
796 const ui = $.ui.resolve(e)
797
798 if (skin === undefined || e.surface === 'mobile' || !('Input' in ui)) {
799 return <ui.Text>Open the skin settings in the terminal or the desktop app.</ui.Text>
800 }
801
802 const look = lookOf(ui, { prefs, custom, skin }, e.surface)
803 const state = { prefs, custom }
804
805 return settingsPane(look, ui, { names: skinNames(custom), editing, width: e.props.bodyColumns }, {
806 pick: name => void commit($, { ...state, prefs: { ...prefs, skin: name } }),
807 toggle: word => void commit($, { ...state, prefs: { ...prefs, [TOGGLES[word]]: !prefs[TOGGLES[word]] } }),
808 calm: () => void commit($, { ...state, prefs: withCalm(prefs, prefs.calm === null) }),
809 tables: () => void commit($, { ...state, prefs: { ...prefs, tables: nextTables(prefs.tables) } }),
810 icons: () =>
811 void commit($, { ...state, prefs: { ...prefs, icons: prefs.icons === 'unicode' ? 'ascii' : 'unicode' } }),
812 prompt: () => void commit($, { ...state, prefs: { ...prefs, prompt: PROMPT_NEXT[prefs.prompt] } }),
813 words: () => void commit($, { ...state, prefs: { ...prefs, words: prefs.words === 'skin' ? 'claude' : 'skin' } }),
814 edit: slot => void update($, editingAtom, () => slot),
815 paint: hex => {
816 const made = withSlot(prefs.skin === 'off' ? DEFAULT_PREFS.skin : prefs.skin, custom, editing, hex)
817
818 if (typeof made === 'string') {
819 $.ui.toast(made)
820
821 return
822 }
823
824 void commit($, { prefs: { ...prefs, skin: made.name }, custom: { ...custom, [made.name]: made.skin } })
825 },
826 })
827 })
828
829 // Every element the skin draws, numbered, to point at when asking for a change.
830 on('ui.render', { component: 'Pane', requestId: GALLERY }, async ($, e) => {
831 const active = await activeSkin($)
832 const ui = $.ui.resolve(e)
833
834 if (active === null) {
835 return <ui.Text>The gallery shows a skin. Pick one with /skin first.</ui.Text>
836 }
837
838 const look = lookOf(ui, active, e.surface)
839
840 return withSettle($, e, galleryPane({ ...look, ...(look.svg === undefined ? {} : { isFirstDraw: await firstDraws($, e, active.prefs) }) }, e.props.bodyColumns))
841 })
842
843 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
844 const kind = kindOf(e.props.tool)
845 const active = await activeSkin($)
846
847 if (kind === null || active === null) {
848 return next(e)
849 }
850
851 const cwd = await cwdOf($)
852 const ms = e.props.isRunning ? -1 : await read($, memberOf(durationAtom, e))
853 const diff = diffstat(e.props.output)
854 const target = summarize(e.props.tool, e.props.input, cwd)
855
856 const meta = {
857 ...(ms >= 0 && !e.props.isRunning ? { ms } : {}),
858 ...(diff === null ? {} : diff),
859 }
860 const copy = (text: string) => {
861 void $.ui.copy({ text, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
862 }
863 const fields = (e.props.input ?? {}) as { file_path?: unknown; notebook_path?: unknown }
864 const path = fields.file_path ?? fields.notebook_path
865 // The chevron opens the row onto the call's input and its answer.
866 const disclosure = memberOf(disclosureAtom, e)
867 const open = isOpen(await read($, disclosure), e.props.isErrored)
868 const base = await sessionLook($, e, lookOf($.ui.resolve(e), active, e.surface, copy), {
869 touched: typeof path === 'string',
870 folds: open,
871 cwd,
872 })
873 const look = base.svg === undefined ? base : { ...base, isFirstDraw: await firstDraws($, e, active.prefs) }
874 // A file this turn made or changed keeps its colour in every row that names it.
875 const touch = typeof path === 'string' && look.touched !== undefined ? touchOf(look.touched.files, path, cwd) : undefined
876 const label = active.prefs.quiet && isQuiet(e.props.tool, e.props.input) ? quietLabel(e.props.tool) : toolLabel(e.props.tool)
877 const row = toolRow(look, e.props, kind, target, meta, label, touch)
878 const toggle = () => void update($, disclosure, () => (open ? 'closed' : 'open'))
879
880 if (!open) {
881 return withSettle($, e, disclosedRow(look, row, false, toggle))
882 }
883
884 const all = memberOf(allAtom, e)
885 const isAll = await read($, all)
886 const opened = await openedRows(look, {
887 fields: inputFields(e.props.tool, e.props.input, cwd),
888 body: bodyOf(e.props.tool, e.props.output, e.props.isErrored, e.props.isRunning, cwd),
889 command: commandOf(e.props.input),
890 isErrored: e.props.isErrored,
891 cwd,
892 columns: e.viewport?.columns ?? 100,
893 isAll,
894 toggleAll: () => void update($, all, () => !isAll),
895 stock: () => next(e),
896 })
897
898 return withSettle($, e, disclosedRow(look, row, true, toggle, opened))
899 })
900
901 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
902 const active = await activeSkin($)
903
904 if (active === null) {
905 return next(e)
906 }
907
908 const drawing = drawingOf(e)
909
910 if (active.prefs.expand) {
911 // expand opens every group for Claude Code to draw, as if the person had. A click on
912 // the group's line moves Claude Code's flag off the value it began at, and a second
913 // click puts it back: the first folds the group, the second opens it again.
914 const start = groupStarts.get(drawing) ?? e.props.isExpanded
915 groupStarts.set(drawing, start)
916
917 if (e.props.isExpanded === start) {
918 return next({ ...e, props: { ...e.props, isExpanded: true } })
919 }
920 } else {
921 groupStarts.delete(drawing)
922
923 if (e.props.isExpanded) {
924 return next(e)
925 }
926 }
927
928 return groupRow(lookOf($.ui.resolve(e), active, e.surface), e.props.calls)
929 })
930
931 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
932 const active = await activeSkin($)
933 const output = e.props.output as { stdout?: unknown } | null
934 const copy = (text: string) => {
935 void $.ui.copy({ text, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
936 }
937 // Only the terminal's shell card has a fold control here.
938 const needs = { folds: e.surface === 'terminal' && SHELLS.has(e.props.tool) }
939 const look = active === null ? undefined : await sessionLook($, e, lookOf($.ui.resolve(e), active, e.surface, copy), needs)
940 const columns = e.viewport?.columns ?? 100
941 // An opened row draws the answer itself; one it cannot draw keeps Claude Code's here.
942 const isOpened =
943 active !== null &&
944 kindOf(e.props.tool) !== null &&
945 isOpen(await read($, memberOf(disclosureAtom, e)), e.props.isErrored) &&
946 bodyOf(e.props.tool, e.props.output, e.props.isErrored, false, '').kind !== 'stock'
947
948 if (isOpened) {
949 const { Box } = $.ui.resolve(e)
950
951 return <Box />
952 }
953
954 // Calm never folds a failure away: it is drawn whole, past quiet and the clip.
955 const isCalmFailure = look !== undefined && look.prefs.calm !== null && e.props.isErrored
956
957 // A call that only looked folds away; a failure keeps the line that says why.
958 if (look !== undefined && look.prefs.quiet && !isCalmFailure && (await read($, memberOf(quietAtom, e)))) {
959 return quietResult(look, e.props.isErrored ? errorLine(e.props.output) : null)
960 }
961
962 // The desktop gets cards: a diff for an edit, a terminal for a shell command.
963 if (look?.svg !== undefined && EDITS.has(e.props.tool) && !e.props.isErrored) {
964 const diff = hunksOf(e.props.output)
965
966 if (diff !== null) {
967 const shown = shortenPath(diff.path, await $.session.cwd())
968
969 return withSettle($, e, diffCard({ ...look, isFirstDraw: await firstDraws($, e, look.prefs) }, look.svg, diff, shown, columns))
970 }
971 }
972
973 const isShellCard = look !== undefined && look.prefs.shell && SHELLS.has(e.props.tool) && !keepsOwnRow(e.props.output)
974
975 if (look?.svg !== undefined && isShellCard) {
976 const shell = shellOutputOf(e.props.output)
977
978 if (shell !== null) {
979 return withSettle($, e, terminalCard({ ...look, isFirstDraw: await firstDraws($, e, look.prefs) }, look.svg, shell, e.props.isErrored, columns))
980 }
981 }
982
983 // The terminal's card is text: the command and its exit status, stderr apart, folded.
984 if (look !== undefined && e.surface === 'terminal' && isShellCard) {
985 const shell = shellResultOf(e.props.output, e.props.isErrored)
986
987 if (shell !== null) {
988 // `/skin fold off`, or the person's `▾ N more`, shows the output whole.
989 const isWhole = !look.prefs.fold || look.folds?.open.has(OUTPUT_FOLD) === true
990 const fold = isWhole ? null : { head: CLIP_HEAD, tail: CLIP_TAIL }
991
992 return shellRows(look, await read($, memberOf(commandAtom, e)), shell, e.props.isErrored, {
993 stdout: fold,
994 stderr: isCalmFailure ? null : fold,
995 })
996 }
997 }
998
999 if (!active?.prefs.clipOutput || isCalmFailure || !SHELLS.has(e.props.tool) || typeof output?.stdout !== 'string') {
1000 return next(e)
1001 }
1002
1003 const stdout = clipLines(output.stdout, CLIP_HEAD, CLIP_TAIL)
1004
1005 return stdout === output.stdout
1006 ? next(e)
1007 : next({ ...e, props: { ...e.props, output: { ...output, stdout } } })
1008 })
1009
1010 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
1011 const active = await activeSkin($)
1012
1013 // Off the terminal your messages keep Claude Code's own component: it carries the copy
1014 // and rewind buttons, the context menu and selectable text, which an outline would lose.
1015 if (active === null || e.surface !== 'terminal' || !TYPED.has(e.props.origin.kind) || e.props.text.length > MAX_PROMPT) {
1016 return next(e)
1017 }
1018
1019 const look = lookOf($.ui.resolve(e), active, e.surface)
1020 const hasImages = await read($, memberOf(imagesAtom, e))
1021
1022 // Claude Code draws the images; the text is already in the outline above them.
1023 return promptRow(look, e.props.text, hasImages ? await next({ ...e, props: { ...e.props, text: '' } }) : undefined)
1024 })
1025
1026 // While charts are drawn, the model learns it can answer a "show me" with a Mermaid
1027 // fence. Nothing for a headless run, which draws nothing.
1028 on('prompt.compose', async ($, e, next) => {
1029 const result = await next(e)
1030 const active = await activeSkin($)
1031 const isHeadless = e.surfaces.length === 0 || e.traits.includes('bare') || e.traits.includes('print')
1032
1033 if (active === null || !active.prefs.charts || !active.prefs.hints || isHeadless) {
1034 return result
1035 }
1036
1037 return { sections: [...result.sections, { id: 'skins:charts', text: CHART_HINT, scope: 'session' as const }] }
1038 })
1039
1040 // A reply keeps Claude Code's own drawing unless it holds a card or markdown the pack draws.
1041 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
1042 const active = await activeSkin($)
1043
1044 const text = e.props.text
1045
1046 if (active === null) {
1047 return next(e)
1048 }
1049
1050 const { prefs } = active
1051 const segments = replySegments(text, prefs, e.surface)
1052
1053 // A reply skin-code draws is its: Claude Code may run either mod's hook first, and this
1054 // keeps the code in the person's VS Code theme whichever it is. skin-code draws the
1055 // prose around it through `$.skins.markdown`.
1056 if (segments === null || (await drawnBySkinCode($, text))) {
1057 return next(e)
1058 }
1059
1060 // Every surface's table names Svg, but the terminal draws it as nothing. As text, tables
1061 // are text grids and code the app's own selectable blocks.
1062 const ui = $.ui.resolve(e)
1063 const svg = e.surface !== 'terminal' && active.prefs.tables === 'on' && 'Svg' in ui ? ui.Svg : undefined
1064 lastColumns = e.viewport?.columns
1065 const copy = (copied: string) => {
1066 void $.ui.copy({ text: copied, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
1067 }
1068
1069 const base = await sessionLook($, e, lookOf(ui, active, e.surface, copy), replyNeeds(text, segments))
1070 const look = base.svg === undefined ? base : { ...base, isFirstDraw: await firstDraws($, e, prefs) }
1071
1072 // A reply of several blocks can be copied whole, as Claude wrote it.
1073 return withSettle($, e, replyRows(look, segments, e.viewport?.columns ?? 100, svg, segments.length > 1 ? text : undefined))
1074 })
1075
1076 // A slash command's output (`/cost`, `/context`, a plugin's) drawn the way a reply is: its
1077 // tables and code as cards, a run of `key: value` lines as a table. /skin's own rows are
1078 // already the skin's.
1079 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
1080 const active = await activeSkin($)
1081
1082 if (active === null || !active.prefs.commands || active.prefs.tables === 'off' || e.props.isErrored || e.props.command === 'skin') {
1083 return next(e)
1084 }
1085
1086 const segments = e.props.text.length > MAX_MARKDOWN ? null : commandSegments(e.props.text, e.props.command)
1087
1088 if (segments === null) {
1089 return next(e)
1090 }
1091
1092 const ui = $.ui.resolve(e)
1093 const svg = e.surface !== 'terminal' && active.prefs.tables === 'on' && 'Svg' in ui ? ui.Svg : undefined
1094 const copy = (copied: string) => {
1095 void $.ui.copy({ text: copied, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
1096 }
1097
1098 const base = await sessionLook($, e, lookOf(ui, active, e.surface, copy), replyNeeds(e.props.text, segments))
1099 const look = base.svg === undefined ? base : { ...base, isFirstDraw: await firstDraws($, e, active.prefs) }
1100
1101 return withSettle($, e, replyRows(look, segments, e.viewport?.columns ?? 100, svg))
1102 })
1103
1104 // The terminal's spinner gets the skin's word with a shimmer; the desktop's keeps its
1105 // word, which says what the step is doing, beside an animated icon.
1106 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
1107 const active = await activeSkin($)
1108
1109 if (active === null) {
1110 return next(e)
1111 }
1112
1113 if (e.surface !== 'terminal') {
1114 const look = lookOf($.ui.resolve(e), active, e.surface)
1115
1116 // Calm keeps the app's own, quieter spinner.
1117 return look.svg === undefined || active.prefs.calm !== null
1118 ? next(e)
1119 : desktopSpinnerRow(look, look.svg, e.props.mode, e.props.message ?? e.props.word)
1120 }
1121
1122 // words claude keeps Claude Code's own word, whatever the skin says.
1123 const word = active.prefs.words === 'claude' ? e.props.word : (pick(active.skin.spinner, e.props.word) ?? e.props.word)
1124
1125 if (!active.prefs.shimmer || e.props.message !== null) {
1126 return next({ ...e, props: { ...e.props, word } })
1127 }
1128
1129 const frame = await read($, frameAtom)
1130 const startedAt = await read($, startedAtom)
1131 const elapsed = startedAt === 0 ? 0 : (await $.clock.now()) - startedAt
1132
1133 return spinnerRow(lookOf($.ui.resolve(e), active, e.surface), word, frame, elapsed)
1134 })
1135
1136 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
1137 const active = await activeSkin($)
1138
1139 if (active === null) {
1140 return next(e)
1141 }
1142
1143 const turns = await read($, turnsAtom)
1144 const word = active.prefs.words === 'claude' ? e.props.word : (pick(active.skin.done, e.props.word) ?? e.props.word)
1145
1146 return footerRow(lookOf($.ui.resolve(e), active, e.surface), word, e.props.durationMs, turns[String(e.props.durationMs)])
1147 })
1148
1149 // The band above the prompt: context and plan limits. Another mod's drawing there,
1150 // and a survey, keep their place.
1151 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1152 const active = await activeSkin($)
1153 const usage = await read($, usageAtom)
1154 // Drawn from the clock so a countdown switched on shows at once; reading the atom
1155 // redraws the band when the minute tick changes one.
1156 const countdowns = active?.prefs.countdown === true ? (await read($, countdownsAtom), countdownsOf(usage, await $.clock.now())) : {}
1157 const meters = metersOf(usage, countdowns)
1158
1159 if (active === null || !active.prefs.band || e.props.hasSurvey || meters.length === 0) {
1160 return next(e)
1161 }
1162
1163 const plain = lookOf($.ui.resolve(e), active, e.surface)
1164 const { Box } = plain.ui
1165 const theirs = await next(e)
1166 // Compacting mid-turn would cut the turn's own context out from under it.
1167 // Runs Claude Code's own /compact, so the person sees its usual progress and result.
1168 // Work a press starts is abandoned when the press ends, which cancels a compaction
1169 // still running, so a timer starts it in a dispatch of its own, which returns the run
1170 // so that dispatch lasts until the compaction ends. The button hides until then, so
1171 // repeated presses do not queue one /compact each.
1172 const isCompacting = await read($, compactingAtom)
1173 const compact = () => {
1174 void update($, compactingAtom, () => true)
1175 $.clock.after(1, () =>
1176 $.command
1177 .run({ command: 'compact' })
1178 .catch((error: unknown) => $.ui.toast(`Compacting failed: ${error instanceof Error ? error.message : String(error)}`))
1179 .finally(() => update($, compactingAtom, () => false)),
1180 )
1181 }
1182 const look = { ...plain, ...(plain.svg === undefined ? {} : { isFirstDraw: await firstDraws($, e, active.prefs) }) }
1183
1184 return withSettle(
1185 $,
1186 e,
1187 <Box flexDirection="column">
1188 {usageBand(look, meters, !e.props.isWorking && !isCompacting, compact)}
1189 {theirs}
1190 </Box>,
1191 )
1192 })
1193
1194 // Claude Code's own dialog stays whole: the skin only adds a band above it.
1195 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
1196 const active = await activeSkin($)
1197 const theirs = await next(e)
1198
1199 if (active === null) {
1200 return theirshooks/command.ts 224 lines1import type { CalmSnapshot, Prefs } from '../types'
2
3export const DEFAULT_PREFS: Prefs = {
4 skin: 'noir',
5 icons: 'unicode',
6 prompt: 'left',
7 words: 'skin',
8 rail: true,
9 tables: 'on',
10 shimmer: true,
11 band: true,
12 countdown: false,
13 clipOutput: false,
14 markdown: true,
15 quiet: false,
16 charts: true,
17 math: true,
18 commands: true,
19 shell: true,
20 highlight: true,
21 hints: true,
22 links: true,
23 copy: true,
24 fold: true,
25 expand: false,
26 calm: null,
27}
28
29// The on/off settings, by the word /skin and the settings pane use for each. Tables have a
30// third state, `text`, so they are switched on their own.
31export const TOGGLES = {
32 rail: 'rail',
33 shimmer: 'shimmer',
34 band: 'band',
35 countdown: 'countdown',
36 clip: 'clipOutput',
37 markdown: 'markdown',
38 quiet: 'quiet',
39 charts: 'charts',
40 math: 'math',
41 commands: 'commands',
42 shell: 'shell',
43 highlight: 'highlight',
44 hints: 'hints',
45 links: 'links',
46 copy: 'copy',
47 fold: 'fold',
48 expand: 'expand',
49} as const satisfies Record<string, keyof Prefs>
50
51export type ToggleWord = keyof typeof TOGGLES
52
53export type Outcome = {
54 prefs: Prefs
55 message: string
56 // `row` prints a transcript line (listings, mistakes); `toast` confirms a change quietly.
57 channel: 'row' | 'toast'
58}
59
60const flagOr = (value: unknown, fallback: boolean): boolean =>
61 typeof value === 'boolean' ? value : fallback
62
63export const TABLE_MODES = ['on', 'text', 'off'] as const satisfies readonly Prefs['tables'][]
64
65// Tables were on/off before `text`, so a stored boolean still reads.
66export function tablesOr(value: unknown, fallback: Prefs['tables']): Prefs['tables'] {
67 if (typeof value === 'boolean') {
68 return value ? 'on' : 'off'
69 }
70
71 return TABLE_MODES.find(mode => mode === value) ?? fallback
72}
73
74// The settings pane's tables button steps through the modes in order.
75export const nextTables = (mode: Prefs['tables']): Prefs['tables'] =>
76 TABLE_MODES[(TABLE_MODES.indexOf(mode) + 1) % TABLE_MODES.length] ?? 'on'
77
78// A stored calm snapshot, or null when it is not one: then calm reads as off.
79function calmOf(value: unknown): CalmSnapshot | null {
80 const saved = typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
81
82 return saved !== null && typeof saved.shimmer === 'boolean' && typeof saved.rail === 'boolean' && typeof saved.quiet === 'boolean'
83 ? { shimmer: saved.shimmer, rail: saved.rail, quiet: saved.quiet }
84 : null
85}
86
87// What the store hands back may be old, hand-edited or from another version.
88export function parsePrefs(raw: unknown, names: readonly string[]): Prefs {
89 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
90 const skin = saved.skin
91
92 return {
93 skin:
94 typeof skin === 'string' && (skin === 'off' || names.includes(skin))
95 ? skin
96 : DEFAULT_PREFS.skin,
97 icons: saved.icons === 'ascii' ? 'ascii' : 'unicode',
98 prompt: saved.prompt === 'right' || saved.prompt === 'plain' ? saved.prompt : 'left',
99 words: saved.words === 'claude' ? 'claude' : 'skin',
100 rail: flagOr(saved.rail, DEFAULT_PREFS.rail),
101 tables: tablesOr(saved.tables, DEFAULT_PREFS.tables),
102 shimmer: flagOr(saved.shimmer, DEFAULT_PREFS.shimmer),
103 band: flagOr(saved.band, DEFAULT_PREFS.band),
104 countdown: flagOr(saved.countdown, DEFAULT_PREFS.countdown),
105 clipOutput: flagOr(saved.clipOutput, DEFAULT_PREFS.clipOutput),
106 markdown: flagOr(saved.markdown, DEFAULT_PREFS.markdown),
107 quiet: flagOr(saved.quiet, DEFAULT_PREFS.quiet),
108 charts: flagOr(saved.charts, DEFAULT_PREFS.charts),
109 math: flagOr(saved.math, DEFAULT_PREFS.math),
110 commands: flagOr(saved.commands, DEFAULT_PREFS.commands),
111 shell: flagOr(saved.shell, DEFAULT_PREFS.shell),
112 highlight: flagOr(saved.highlight, DEFAULT_PREFS.highlight),
113 hints: flagOr(saved.hints, DEFAULT_PREFS.hints),
114 links: flagOr(saved.links, DEFAULT_PREFS.links),
115 copy: flagOr(saved.copy, DEFAULT_PREFS.copy),
116 fold: flagOr(saved.fold, DEFAULT_PREFS.fold),
117 expand: flagOr(saved.expand, DEFAULT_PREFS.expand),
118 calm: calmOf(saved.calm),
119 }
120}
121
122// /skin calm: no motion, one line per tool row, read-only output folded. What it changes is
123// kept, so calm off puts back what was there before. Failures stay whole either way
124// (register.tsx draws them past quiet while calm is on).
125export function withCalm(current: Prefs, on: boolean): Prefs {
126 if (on) {
127 return current.calm !== null
128 ? current
129 : { ...current, calm: { shimmer: current.shimmer, rail: current.rail, quiet: current.quiet }, shimmer: false, rail: false, quiet: true }
130 }
131
132 return current.calm === null ? current : { ...current, ...current.calm, calm: null }
133}
134
135const changed = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'toast' })
136
137const refused = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'row' })
138
139const onOff = (on: boolean): string => (on ? 'on' : 'off')
140
141export const listing = (current: Prefs, names: readonly string[]): string =>
142 [
143 ...names.map(name => `${name === current.skin ? '●' : '○'} ${name}`),
144 `${current.skin === 'off' ? '●' : '○'} off`,
145 [
146 `icons ${current.icons}`,
147 `prompt ${current.prompt}`,
148 `words ${current.words}`,
149 `rail ${onOff(current.rail)}`,
150 `tables ${current.tables}`,
151 `shimmer ${onOff(current.shimmer)}`,
152 `band ${onOff(current.band)}`,
153 `clip ${onOff(current.clipOutput)}`,
154 `markdown ${onOff(current.markdown)}`,
155 `quiet ${onOff(current.quiet)}`,
156 `charts ${onOff(current.charts)}`,
157 `math ${onOff(current.math)}`,
158 `commands ${onOff(current.commands)}`,
159 `shell ${onOff(current.shell)}`,
160 `highlight ${onOff(current.highlight)}`,
161 `hints ${onOff(current.hints)}`,
162 `links ${onOff(current.links)}`,
163 `copy ${onOff(current.copy)}`,
164 `fold ${onOff(current.fold)}`,
165 `expand ${onOff(current.expand)}`,
166 `calm ${onOff(current.calm !== null)}`,
167 ].join(' · '),
168 ].join('\n')
169
170const isToggle = (word: string): word is ToggleWord => Object.hasOwn(TOGGLES, word)
171
172// Every argument but none: with none, /skin opens the settings pane instead.
173export function runSkinCommand(args: string, current: Prefs, names: readonly string[]): Outcome {
174 const [head = '', value] = args.trim().toLowerCase().split(/\s+/)
175
176 if (isToggle(head)) {
177 const field = TOGGLES[head]
178
179 return value === 'on' || value === 'off'
180 ? changed({ ...current, [field]: value === 'on' }, `${head} ${value}`)
181 : refused(current, `usage: /skin ${head} on|off`)
182 }
183
184 switch (head) {
185 case 'list':
186 return refused(current, listing(current, names))
187 // `default` and `stock` are what people reach for when they want Claude Code's own look.
188 case 'off':
189 case 'default':
190 case 'stock':
191 return changed({ ...current, skin: 'off' }, 'skins off')
192 case 'on':
193 return current.skin === 'off'
194 ? changed({ ...current, skin: DEFAULT_PREFS.skin }, `skin: ${DEFAULT_PREFS.skin}`)
195 : changed(current, `skin: ${current.skin}`)
196 case 'calm':
197 return value === 'on' || value === undefined
198 ? changed(withCalm(current, true), current.calm === null ? 'calm on' : 'calm is already on')
199 : value === 'off'
200 ? changed(withCalm(current, false), current.calm === null ? 'calm is already off' : 'calm off: your settings are back')
201 : refused(current, 'usage: /skin calm [on|off]')
202 case 'tables':
203 return value === 'on' || value === 'text' || value === 'off'
204 ? changed({ ...current, tables: value }, `tables ${value}`)
205 : refused(current, 'usage: /skin tables on|text|off')
206 case 'prompt':
207 return value === 'left' || value === 'right' || value === 'plain'
208 ? changed({ ...current, prompt: value }, `prompt ${value}`)
209 : refused(current, 'usage: /skin prompt left|right|plain')
210 case 'words':
211 return value === 'claude' || value === 'skin'
212 ? changed({ ...current, words: value }, `words: ${value}`)
213 : refused(current, 'usage: /skin words claude|skin')
214 case 'icons':
215 return value === 'unicode' || value === 'ascii'
216 ? changed({ ...current, icons: value }, `icons: ${value}`)
217 : refused(current, 'usage: /skin icons unicode|ascii')
218 default:
219 return names.includes(head)
220 ? changed({ ...current, skin: head }, `skin: ${head}`)
221 : refused(current, `unknown skin "${head}". /skin list shows them`)
222 }
223}
224hooks/custom.ts 185 lines1import type { CustomSkin, SkinSlot } from '../types'
2import type { Skin } from './skin'
3import { SKINS } from './themes'
4
5export const SLOTS: readonly SkinSlot[] = [
6 'user',
7 'fg',
8 'muted',
9 'surface',
10 'zebra',
11 'ok',
12 'err',
13 'warn',
14 'read',
15 'write',
16 'run',
17 'search',
18 'web',
19 'mcp',
20 'other',
21]
22
23// What each slot paints, for the settings pane and the agent's tool.
24export const SLOT_HELP: Readonly<Record<SkinSlot, string>> = {
25 user: 'accent: the rail, your prompt marker, the spinner',
26 fg: 'body text',
27 muted: 'secondary text: commands, paths, timings',
28 surface: 'band behind a table header',
29 zebra: 'every other table row; keep it a step off the background',
30 ok: 'a finished call',
31 err: 'a failed call',
32 warn: 'an interrupted call',
33 read: 'Read',
34 write: 'Edit and Write',
35 run: 'Bash and PowerShell',
36 search: 'Glob and Grep',
37 web: 'WebFetch and WebSearch',
38 mcp: 'MCP tools',
39 other: 'anything else in a group',
40}
41
42export type Customs = Readonly<Record<string, CustomSkin>>
43
44const HEX = /^#[0-9a-f]{6}$/i
45const NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
46const MAX_WORDS = 12
47const MAX_WORD = 24
48
49const builtIn = (name: string): Skin | undefined => SKINS.find(skin => skin.name === name)
50
51export const skinNames = (custom: Customs): string[] => [
52 ...SKINS.map(skin => skin.name),
53 ...Object.keys(custom).filter(name => builtIn(name) === undefined),
54]
55
56// A made skin is its base with the slots and words it changed laid over it.
57export function resolveSkin(name: string, custom: Customs): Skin | undefined {
58 const own = builtIn(name)
59
60 if (own !== undefined) {
61 return own
62 }
63
64 const made = custom[name]
65 const base = made === undefined ? undefined : (builtIn(made.base) ?? SKINS[0])
66
67 if (made === undefined || base === undefined) {
68 return undefined
69 }
70
71 return {
72 name: made.name,
73 label: made.label,
74 palette: { ...base.palette, ...made.palette },
75 spinner: made.spinner.length > 0 ? made.spinner : base.spinner,
76 done: made.done.length > 0 ? made.done : base.done,
77 }
78}
79
80export type Draft = {
81 name?: unknown
82 label?: unknown
83 base?: unknown
84 palette?: unknown
85 spinner?: unknown
86 done?: unknown
87}
88
89function words(value: unknown, field: string, fallback: string[]): string[] | string {
90 if (value === undefined) {
91 return fallback
92 }
93
94 const valid =
95 Array.isArray(value) &&
96 value.length <= MAX_WORDS &&
97 value.every(word => typeof word === 'string' && word.trim() !== '' && word.length <= MAX_WORD)
98
99 return valid
100 ? (value as string[]).map(word => word.trim())
101 : `${field} must be up to ${MAX_WORDS} words of 1-${MAX_WORD} characters`
102}
103
104function palette(value: unknown, fallback: CustomSkin['palette']): CustomSkin['palette'] | string {
105 if (value === undefined) {
106 return fallback
107 }
108
109 if (typeof value !== 'object' || value === null || Array.isArray(value)) {
110 return 'palette must be an object of slot: "#rrggbb"'
111 }
112
113 const entries = Object.entries(value as Record<string, unknown>)
114 const bad = entries.filter(
115 ([slot, hex]) => !SLOTS.includes(slot as SkinSlot) || typeof hex !== 'string' || !HEX.test(hex),
116 )
117
118 if (bad.length > 0) {
119 return `bad palette entries: ${bad.map(([slot]) => slot).join(', ')}. Slots: ${SLOTS.join(', ')}; values #rrggbb`
120 }
121
122 return { ...fallback, ...Object.fromEntries(entries.map(([slot, hex]) => [slot, (hex as string).toLowerCase()])) }
123}
124
125// Builds a made skin from what the agent or the pane sent, or says what is wrong with it.
126export function buildCustom(draft: Draft, existing: CustomSkin | undefined): CustomSkin | string {
127 const name = typeof draft.name === 'string' ? draft.name.trim().toLowerCase() : ''
128
129 if (!NAME.test(name)) {
130 return 'name must be 1-32 lowercase letters, digits or hyphens'
131 }
132
133 if (builtIn(name) !== undefined || name === 'off') {
134 return `"${name}" is taken by a built-in skin; pick another name`
135 }
136
137 const base = typeof draft.base === 'string' ? draft.base : (existing?.base ?? SKINS[0]?.name ?? '')
138
139 if (builtIn(base) === undefined) {
140 return `base must be one of ${SKINS.map(skin => skin.name).join(', ')}`
141 }
142
143 const label = typeof draft.label === 'string' && draft.label.trim() !== '' ? draft.label.trim().slice(0, 32) : (existing?.label ?? name)
144 const colours = palette(draft.palette, existing?.palette ?? {})
145 const spinner = words(draft.spinner, 'spinner', existing?.spinner ?? [])
146 const done = words(draft.done, 'done', existing?.done ?? [])
147
148 for (const result of [colours, spinner, done]) {
149 if (typeof result === 'string') {
150 return result
151 }
152 }
153
154 return {
155 name,
156 label,
157 base,
158 palette: colours as CustomSkin['palette'],
159 spinner: spinner as string[],
160 done: done as string[],
161 }
162}
163
164// One slot changed from the settings pane: a built-in skin forks into `my-<name>`.
165export function withSlot(
166 current: string,
167 custom: Customs,
168 slot: SkinSlot,
169 hex: string,
170): { name: string; skin: CustomSkin } | string {
171 if (!HEX.test(hex)) {
172 return 'use a colour like #7aa2f7'
173 }
174
175 const made = custom[current]
176 const name = made === undefined ? `my-${current}` : current
177 const base = made?.base ?? current
178 const skin = buildCustom(
179 { name, base, label: made?.label ?? `My ${current}`, palette: { [slot]: hex } },
180 custom[name],
181 )
182
183 return typeof skin === 'string' ? skin : { name, skin }
184}
185hooks/light.ts 100 lines1import type { ProcessRunResult } from 'claude-code'
2import type { Palette, Skin } from './skin'
3
4// Every skin works on a light background too. A skin may carry its own light palette;
5// the rest are derived: body text goes near-black, bands go near-white, and each colour
6// is deepened until it reads on white.
7
8const channels = (hex: string): [number, number, number] => [
9 parseInt(hex.slice(1, 3), 16),
10 parseInt(hex.slice(3, 5), 16),
11 parseInt(hex.slice(5, 7), 16),
12]
13
14const toHex = (rgb: readonly number[]): string =>
15 `#${rgb.map(value => Math.round(Math.max(0, Math.min(255, value))).toString(16).padStart(2, '0')).join('')}`
16
17// `amount` of the way from `hex` to black.
18export const deepen = (hex: string, amount: number): string =>
19 toHex(channels(hex).map(value => value * (1 - amount)))
20
21export function toLight(palette: Palette): Palette {
22 const deep = (hex: string) => deepen(hex, 0.45)
23
24 return {
25 read: deep(palette.read),
26 write: deep(palette.write),
27 run: deep(palette.run),
28 search: deep(palette.search),
29 web: deep(palette.web),
30 mcp: deep(palette.mcp),
31 other: deep(palette.other),
32 user: deep(palette.user),
33 fg: '#1f1f1f',
34 muted: '#6b6b6b',
35 surface: '#ffffff',
36 zebra: '#f2f2f2',
37 ok: deepen(palette.ok, 0.5),
38 err: deepen(palette.err, 0.25),
39 warn: deepen(palette.warn, 0.5),
40 }
41}
42
43export const forTheme = (skin: Skin, isLight: boolean): Skin =>
44 isLight ? { ...skin, palette: skin.light ?? toLight(skin.palette) } : skin
45
46// Claude Code's theme names say light or dark in them: `light`, `light-daltonized`, ...
47export const isLightTheme = (value: unknown): boolean => typeof value === 'string' && value.includes('light')
48
49// Light, dark, or undefined when the name says neither (`auto`).
50const named = (value: unknown): boolean | undefined => {
51 const name = typeof value === 'string' ? value.trim().toLowerCase() : ''
52
53 return name.includes('light') ? true : name.includes('dark') ? false : undefined
54}
55
56// `COLORFGBG` is `<fg>;<bg>` (sometimes `<fg>;<default>;<bg>`); background 7 or 15 is a light one.
57const terminalLight = (colorfgbg: string | undefined): boolean | undefined => {
58 const bg = Number(colorfgbg?.split(';').at(-1))
59
60 return colorfgbg === undefined || Number.isNaN(bg) ? undefined : bg === 7 || bg === 15
61}
62
63export type ThemeHints = {
64 // `SKINS_THEME`, for a session whose theme comes from somewhere skins cannot read (`--settings`).
65 override?: string
66 // Claude Code's `theme` setting.
67 theme?: unknown
68 colorfgbg?: string
69 // The system's appearance, when it could be read.
70 systemDark?: boolean
71}
72
73// `SKINS_THEME` wins, then a theme that names light or dark, then, for `auto`, the
74// terminal's background and the system's appearance. Dark when nothing says.
75export const resolveLight = (hints: ThemeHints): boolean =>
76 named(hints.override) ??
77 named(hints.theme) ??
78 terminalLight(hints.colorfgbg) ??
79 (hints.systemDark === undefined ? false : !hints.systemDark)
80
81// What a system-appearance probe printed: a reader answers dark, light, or undefined
82// when its output says neither (the setting is missing, the tool failed).
83type ProbeOutput = Pick<ProcessRunResult, 'exitCode' | 'stdout' | 'stderr'>
84
85// `defaults read -g AppleInterfaceStyle`: "Dark", or unset when light: "does not exist"
86// before macOS 27, "Could not find key" from 27 on.
87export const macDark = (out: ProbeOutput): boolean | undefined =>
88 out.exitCode === 0 ? out.stdout.trim() === 'Dark' : /does not exist|Could not find key/.test(out.stderr) ? false : undefined
89
90// `reg query ...\Personalize /v AppsUseLightTheme`: 0x0 is dark, 0x1 light.
91export const windowsDark = (out: ProbeOutput): boolean | undefined => {
92 const value = /AppsUseLightTheme\s+REG_DWORD\s+0x([0-9a-f]+)/i.exec(out.stdout)?.[1]
93
94 return out.exitCode !== 0 || value === undefined ? undefined : parseInt(value, 16) === 0
95}
96
97// `gsettings get org.gnome.desktop.interface color-scheme`: 'prefer-dark', 'default', ...
98export const gnomeDark = (out: ProbeOutput): boolean | undefined =>
99 out.exitCode === 0 ? out.stdout.includes('dark') : undefined
100hooks/folders.ts 21 lines1import type { Prefs } from '../types'
2import { parsePrefs } from './command'
3
4// Folders that keep their own look, by the session's directory. A folder not named
5// here follows the default prefs.
6export type Folders = Record<string, Prefs>
7
8export function parseFolders(raw: unknown, names: readonly string[]): Folders {
9 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
10
11 return Object.fromEntries(Object.entries(saved).map(([folder, prefs]) => [folder, parsePrefs(prefs, names)]))
12}
13
14export const prefsFor = (folder: string, folders: Folders, fallback: Prefs): Prefs =>
15 (Object.hasOwn(folders, folder) ? folders[folder] : undefined) ?? fallback
16
17export const withFolder = (folders: Folders, folder: string, prefs: Prefs): Folders => ({ ...folders, [folder]: prefs })
18
19export const withoutFolder = (folders: Folders, folder: string): Folders =>
20 Object.fromEntries(Object.entries(folders).filter(([name]) => name !== folder))
21hooks/designer.ts 141 lines1import type { CustomSkin, Prefs } from '../types'
2import { DEFAULT_PREFS, tablesOr } from './command'
3import { buildCustom, resolveSkin, SLOT_HELP, skinNames } from './custom'
4import { SKINS } from './themes'
5
6// The tool the person's own agent calls to design a skin with them.
7export const DESIGN_TOOL = {
8 name: 'design',
9 description: [
10 'Design, change and apply skins for the skins mod, which restyles this Claude Code transcript:',
11 'tool rows on a timeline rail, tables, the spinner, the turn footer. Changes show at once.',
12 'Call {"action":"show"} first: it returns the current skin, every slot with what it paints, and the names in use.',
13 '"save" makes or updates a skin: name (lowercase, hyphens), base (a built-in skin it starts from),',
14 'palette ({slot: "#rrggbb"}, only the slots to change), spinner (gerunds, like "Brewing"),',
15 'done (past tense, like "Brewed"), apply (default true).',
16 '"apply" switches to a skin by name, "delete" removes a made one,',
17 '"settings" switches rail, shimmer, band, clip, markdown, quiet, charts (booleans), tables ("on", "text" or "off") and icons ("unicode" or "ascii").',
18 'Good skins keep fg and muted readable on a dark background, and surface and zebra one small step off it.',
19 ].join(' '),
20 inputSchema: {
21 type: 'object',
22 properties: {
23 action: { type: 'string', enum: ['show', 'save', 'apply', 'delete', 'settings'] },
24 name: { type: 'string' },
25 label: { type: 'string' },
26 base: { type: 'string', enum: SKINS.map(skin => skin.name) },
27 palette: { type: 'object', additionalProperties: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' } },
28 spinner: { type: 'array', items: { type: 'string' }, maxItems: 12 },
29 done: { type: 'array', items: { type: 'string' }, maxItems: 12 },
30 apply: { type: 'boolean' },
31 settings: {
32 type: 'object',
33 properties: {
34 rail: { type: 'boolean' },
35 tables: { enum: [true, false, 'on', 'text', 'off'] },
36 shimmer: { type: 'boolean' },
37 band: { type: 'boolean' },
38 clip: { type: 'boolean' },
39 markdown: { type: 'boolean' },
40 quiet: { type: 'boolean' },
41 charts: { type: 'boolean' },
42 icons: { type: 'string', enum: ['unicode', 'ascii'] },
43 },
44 },
45 },
46 required: ['action'],
47 },
48} as const
49
50export type DesignState = { prefs: Prefs; custom: Record<string, CustomSkin> }
51
52export type DesignOutcome = { text: string; state: DesignState; isError: boolean }
53
54type Input = Record<string, unknown>
55
56const fail = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: true })
57
58const ok = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: false })
59
60function show(state: DesignState): DesignOutcome {
61 const skin = resolveSkin(state.prefs.skin, state.custom)
62
63 return ok(
64 state,
65 JSON.stringify({
66 current: state.prefs.skin,
67 palette: skin?.palette ?? null,
68 spinner: skin?.spinner ?? [],
69 done: skin?.done ?? [],
70 builtIn: SKINS.map(one => one.name),
71 made: Object.keys(state.custom),
72 slots: SLOT_HELP,
73 settings: state.prefs,
74 }),
75 )
76}
77
78function save(input: Input, state: DesignState): DesignOutcome {
79 const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
80 const skin = buildCustom(input, state.custom[name])
81
82 if (typeof skin === 'string') {
83 return fail(state, skin)
84 }
85
86 const custom = { ...state.custom, [skin.name]: skin }
87 const prefs = input.apply === false ? state.prefs : { ...state.prefs, skin: skin.name }
88
89 return ok({ prefs, custom }, `saved ${skin.name}${input.apply === false ? '' : ' and applied it'}`)
90}
91
92function settings(input: Input, state: DesignState): DesignOutcome {
93 const asked = (typeof input.settings === 'object' && input.settings !== null ? input.settings : {}) as Input
94 const flag = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
95 const icons = asked.icons === 'ascii' || asked.icons === 'unicode' ? asked.icons : state.prefs.icons
96 const prefs: Prefs = {
97 ...state.prefs,
98 rail: flag(asked.rail, state.prefs.rail),
99 tables: tablesOr(asked.tables, state.prefs.tables),
100 shimmer: flag(asked.shimmer, state.prefs.shimmer),
101 band: flag(asked.band, state.prefs.band),
102 clipOutput: flag(asked.clip, state.prefs.clipOutput),
103 markdown: flag(asked.markdown, state.prefs.markdown),
104 quiet: flag(asked.quiet, state.prefs.quiet),
105 charts: flag(asked.charts, state.prefs.charts),
106 icons,
107 }
108
109 return ok({ ...state, prefs }, `settings: ${JSON.stringify(prefs)}`)
110}
111
112export function runDesign(raw: unknown, state: DesignState): DesignOutcome {
113 const input = (typeof raw === 'object' && raw !== null ? raw : {}) as Input
114 const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
115
116 switch (input.action) {
117 case 'show':
118 return show(state)
119 case 'save':
120 return save(input, state)
121 case 'apply':
122 return skinNames(state.custom).includes(name)
123 ? ok({ ...state, prefs: { ...state.prefs, skin: name } }, `applied ${name}`)
124 : fail(state, `no skin named "${name}"; show lists them`)
125 case 'delete': {
126 if (state.custom[name] === undefined) {
127 return fail(state, `"${name}" is not a made skin; built-in skins cannot be deleted`)
128 }
129
130 const custom = Object.fromEntries(Object.entries(state.custom).filter(([key]) => key !== name))
131 const prefs = state.prefs.skin === name ? { ...state.prefs, skin: DEFAULT_PREFS.skin } : state.prefs
132
133 return ok({ prefs, custom }, `deleted ${name}`)
134 }
135 case 'settings':
136 return settings(input, state)
137 default:
138 return fail(state, 'action must be show, save, apply, delete or settings')
139 }
140}
141hooks/format.ts 85 lines1// Plain string helpers behind the row builders.
2
3export const oneLine = (text: string): string =>
4 (text.split('\n', 1)[0] ?? '').replace(/\s+/g, ' ').trim()
5
6const slashed = (path: string): string => path.replace(/\\/g, '/')
7
8// A path under the session's directory shows relative to it; any other stays as given.
9export function shortenPath(path: string, cwd: string): string {
10 const full = slashed(path)
11 const root = slashed(cwd).replace(/\/+$/, '')
12
13 if (root === '' || !full.toLowerCase().startsWith(`${root.toLowerCase()}/`)) {
14 return path
15 }
16
17 return full.slice(root.length + 1)
18}
19
20export function formatDuration(ms: number): string {
21 const seconds = Math.round(ms / 1000)
22
23 if (seconds < 1) {
24 return '<1s'
25 }
26
27 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
28}
29
30// A tool call's own time: `340ms`, `2.1s`, `1m 4s`.
31export function formatMs(ms: number): string {
32 if (ms < 1000) {
33 return `${Math.round(ms)}ms`
34 }
35
36 return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms)
37}
38
39// Lines added and removed, read from an Edit or Write record's patch.
40export function diffstat(output: unknown): { added: number; removed: number } | null {
41 const patch = (output as { structuredPatch?: unknown } | null)?.structuredPatch
42
43 if (!Array.isArray(patch)) {
44 return null
45 }
46
47 const lines = patch.flatMap(hunk => {
48 const hunkLines = (hunk as { lines?: unknown } | null)?.lines
49
50 return Array.isArray(hunkLines) ? hunkLines.filter(line => typeof line === 'string') : []
51 }) as string[]
52
53 return {
54 added: lines.filter(line => line.startsWith('+')).length,
55 removed: lines.filter(line => line.startsWith('-')).length,
56 }
57}
58
59// The same seed always picks the same item, so a row keeps its word when it redraws.
60export function pick<T>(items: readonly T[], seed: string): T | undefined {
61 let hash = 5381
62
63 for (let i = 0; i < seed.length; i++) {
64 hash = (hash * 33 + seed.charCodeAt(i)) >>> 0
65 }
66
67 return items[hash % items.length]
68}
69
70// Keeps the first `head` and last `tail` lines of a long text and names how many it hid.
71export function clipLines(text: string, head: number, tail: number): string {
72 const lines = text.split('\n')
73 const hidden = lines.length - head - tail
74
75 if (hidden <= 1) {
76 return text
77 }
78
79 return [
80 ...lines.slice(0, head),
81 `… ${hidden} lines hidden`,
82 ...lines.slice(lines.length - tail),
83 ].join('\n')
84}
85hooks/detail.ts 256 lines1import { shortenPath } from './format'
2import { SHELLS, shellResultOf } from './shell'
3import type { ShellResult } from './shell'
4import { hunksOf } from './svg-diff'
5import type { DiffInput } from './svg-diff'
6
7// What a tool row shows when it is opened: the call's input in full, then its result in
8// the form the call's kind reads best in. Anything this cannot read is `stock`, which the
9// row answers with Claude Code's own drawing, so an opened row is never empty.
10
11export type Field = { label: string; value: string }
12
13export type Body =
14 | { kind: 'file'; path: string; text: string; startLine: number; total: number }
15 | { kind: 'matches'; lines: string[]; note: string }
16 | { kind: 'shell'; result: ShellResult }
17 | { kind: 'diff'; diff: DiffInput }
18 | { kind: 'text'; text: string; isError: boolean }
19 | { kind: 'none' }
20 | { kind: 'stock' }
21
22// Lines of a read or a search shown before the rest folds behind a `show all` control.
23export const DETAIL_LINES = 20
24
25// The engine's `Code` and `Markdown` take at most 10000 characters a block.
26export const MAX_BLOCK = 9000
27
28export const isOpen = (disclosure: string, isErrored: boolean): boolean =>
29 disclosure === 'open' || (disclosure !== 'closed' && isErrored)
30
31type Fields = Readonly<Record<string, unknown>>
32
33// Tab and newline are the only control characters a drawn string may hold.
34export const clean = (text: string): string => text.replace(/\r\n?/g, '\n').replace(/\u001b\[[0-9;?]*[A-Za-z]|[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
35
36const fieldsOf = (value: unknown): Fields => (typeof value === 'object' && value !== null ? (value as Fields) : {})
37
38// Text the edit's diff already shows, which would only repeat it above.
39const SHOWN_BY_DIFF = new Set(['old_string', 'new_string', 'content', 'edits'])
40
41const LABELS: Readonly<Record<string, string>> = {
42 file_path: 'path',
43 notebook_path: 'path',
44 run_in_background: 'background',
45 output_mode: 'mode',
46 '-i': 'ignore case',
47 '-n': 'line numbers',
48 '-A': 'after',
49 '-B': 'before',
50 '-C': 'context',
51 head_limit: 'limit',
52}
53
54const valueText = (value: unknown): string =>
55 typeof value === 'string' ? value : typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value, null, 2)
56
57// A read's `offset` and `limit` as the line range they ask for.
58function rangeOf(fields: Fields): Field[] {
59 const offset = typeof fields.offset === 'number' ? fields.offset : undefined
60 const limit = typeof fields.limit === 'number' ? fields.limit : undefined
61
62 if (offset === undefined && limit === undefined) {
63 return []
64 }
65
66 const from = offset ?? 1
67
68 return [{ label: 'lines', value: limit === undefined ? `${from}–end` : `${from}–${from + limit - 1}` }]
69}
70
71// The call's input, each field as given; paths under the session's folder made relative.
72export function inputFields(tool: string, input: unknown, cwd: string): Field[] {
73 const fields = fieldsOf(input)
74 const isRead = tool === 'Read'
75 const isEdit = tool === 'Edit' || tool === 'MultiEdit' || tool === 'Write' || tool === 'NotebookEdit'
76 const listed = Object.entries(fields)
77 .filter(([, value]) => value !== undefined && value !== null && value !== '')
78 .filter(([key]) => !(isRead && (key === 'offset' || key === 'limit')) && !(isEdit && SHOWN_BY_DIFF.has(key)))
79 .map(([key, value]) => ({
80 label: LABELS[key] ?? key,
81 value: /path$/.test(key) && typeof value === 'string' ? shortenPath(value, cwd) : valueText(value),
82 }))
83
84 return isRead ? [...listed, ...rangeOf(fields)] : listed
85}
86
87// A failed call's output is the text the model read, its error tags taken off.
88const errorText = (output: unknown): string =>
89 (typeof output === 'string' ? output : valueText(output)).replace(/<\/?tool_use_error>/g, '').trim()
90
91function readBody(output: unknown): Body {
92 const record = fieldsOf(output)
93 const file = fieldsOf(record.file)
94
95 if (record.type !== 'text' || typeof file.content !== 'string') {
96 // Images, PDFs and notebooks are Claude Code's to draw.
97 return { kind: 'stock' }
98 }
99
100 return {
101 kind: 'file',
102 path: typeof file.filePath === 'string' ? file.filePath : '',
103 text: clean(file.content).replace(/\n$/, ''),
104 startLine: typeof file.startLine === 'number' ? file.startLine : 1,
105 total: typeof file.totalLines === 'number' ? file.totalLines : 0,
106 }
107}
108
109const count = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
110
111// Grep answers with matching lines, matching files or counts; Glob with files.
112function searchBody(output: unknown, cwd: string): Body {
113 const record = fieldsOf(output)
114 const files = Array.isArray(record.filenames) ? record.filenames.filter((name): name is string => typeof name === 'string') : []
115 const truncated = record.truncated === true ? ', truncated' : ''
116
117 if (typeof record.content === 'string' && record.content.trim() !== '') {
118 const lines = clean(record.content).replace(/\n$/, '').split('\n')
119 const numLines = typeof record.numLines === 'number' ? record.numLines : lines.length
120
121 return { kind: 'matches', lines: lines.map(line => shortenPath(line, cwd)), note: `${count(numLines, 'line')}${truncated}` }
122 }
123
124 if (files.length === 0 && typeof record.numFiles !== 'number') {
125 return typeof output === 'string' ? { kind: 'matches', lines: output.trim().split('\n'), note: '' } : { kind: 'stock' }
126 }
127
128 return files.length === 0
129 ? { kind: 'none' }
130 : { kind: 'matches', lines: files.map(file => shortenPath(file, cwd)), note: `${count(files.length, 'file')}${truncated}` }
131}
132
133function webBody(tool: string, output: unknown): Body {
134 const record = fieldsOf(output)
135
136 if (tool === 'WebFetch' && typeof record.result === 'string') {
137 return { kind: 'text', text: clean(record.result), isError: false }
138 }
139
140 if (tool === 'WebSearch' && Array.isArray(record.results)) {
141 const lines = record.results.flatMap(result =>
142 typeof result === 'string'
143 ? [result]
144 : (Array.isArray(fieldsOf(result).content) ? (fieldsOf(result).content as unknown[]) : []).map(hit => {
145 const link = fieldsOf(hit)
146
147 return `${valueText(link.title)} ${valueText(link.url)}`
148 }),
149 )
150
151 return { kind: 'matches', lines, note: count(lines.length, 'result') }
152 }
153
154 return { kind: 'stock' }
155}
156
157// An MCP tool's result: its text blocks, else the record as JSON; an image is stock.
158function genericBody(output: unknown): Body {
159 if (typeof output === 'string') {
160 return output.trim() === '' ? { kind: 'none' } : { kind: 'text', text: clean(output), isError: false }
161 }
162
163 const blocks = Array.isArray(output) ? output : Array.isArray(fieldsOf(output).content) ? (fieldsOf(output).content as unknown[]) : null
164
165 if (blocks !== null) {
166 if (blocks.some(block => fieldsOf(block).type === 'image')) {
167 return { kind: 'stock' }
168 }
169
170 const text = blocks.map(block => valueText(fieldsOf(block).text ?? block)).join('\n')
171
172 return text.trim() === '' ? { kind: 'none' } : { kind: 'text', text: clean(text), isError: false }
173 }
174
175 return output === undefined || output === null ? { kind: 'none' } : { kind: 'text', text: clean(valueText(output)), isError: false }
176}
177
178// The result of a finished call, as the opened row draws it; `none` while it runs.
179export function bodyOf(tool: string, output: unknown, isErrored: boolean, isRunning: boolean, cwd: string): Body {
180 if (isRunning || output === undefined) {
181 return { kind: 'none' }
182 }
183
184 if (SHELLS.has(tool)) {
185 const result = shellResultOf(output, isErrored)
186
187 // A command sent to the background, or one whose output is an image, is stock.
188 return result === null || fieldsOf(output).backgroundTaskId !== undefined || fieldsOf(output).isImage === true
189 ? { kind: 'stock' }
190 : { kind: 'shell', result }
191 }
192
193 if (isErrored) {
194 return { kind: 'text', text: clean(errorText(output)), isError: true }
195 }
196
197 switch (tool) {
198 case 'Read':
199 return readBody(output)
200 case 'Grep':
201 case 'Glob':
202 return searchBody(output, cwd)
203 case 'Edit':
204 case 'MultiEdit':
205 case 'Write': {
206 const diff = hunksOf(output)
207
208 return diff === null ? { kind: 'stock' } : { kind: 'diff', diff }
209 }
210 case 'WebFetch':
211 case 'WebSearch':
212 return webBody(tool, output)
213 case 'NotebookEdit':
214 return { kind: 'stock' }
215 default:
216 return genericBody(output)
217 }
218}
219
220// A diff's hunks as unified-diff text, for the engine's `Code` in its `diff` format.
221export function unifiedDiff(diff: DiffInput): string {
222 return diff.hunks
223 .map(hunk => {
224 const lines = hunk.lines.filter(line => !line.startsWith('\\'))
225 const oldCount = lines.filter(line => !line.startsWith('+')).length
226 const newCount = lines.filter(line => !line.startsWith('-')).length
227
228 return [`@@ -${hunk.oldStart},${oldCount} +${hunk.newStart},${newCount} @@`, ...lines].join('\n')
229 })
230 .join('\n')
231}
232
233// Text cut into blocks the engine takes, each with the line number it starts at.
234export function chunksOf(lines: readonly string[], startLine: number, max = MAX_BLOCK): { text: string; startLine: number }[] {
235 const chunks: { text: string; startLine: number }[] = []
236 let current: string[] = []
237 let size = 0
238 let at = startLine
239
240 for (const [i, line] of lines.entries()) {
241 const cut = line.length > max ? `${line.slice(0, max - 1)}…` : line
242
243 if (current.length > 0 && size + cut.length + 1 > max) {
244 chunks.push({ text: current.join('\n'), startLine: at })
245 at = startLine + i
246 current = []
247 size = 0
248 }
249
250 current.push(cut)
251 size += cut.length + 1
252 }
253
254 return current.length === 0 ? chunks : [...chunks, { text: current.join('\n'), startLine: at }]
255}
256hooks/detail-rows.tsx 158 lines1import type { RenderElement } from 'claude-code'
2
3import { chunksOf, DETAIL_LINES, MAX_BLOCK, unifiedDiff } from './detail'
4import type { Body, Field } from './detail'
5import { shortenPath } from './format'
6import { diffCard, terminalCard } from './rows'
7import type { Look } from './rows'
8import { OUTPUT_FOLD, shellRows } from './shell-rows'
9
10// An opened tool row: what the call was asked, then what it answered, under the row the
11// chevron sits on. Long answers fold behind a `show all` control of their own.
12
13export type Opened = {
14 fields: readonly Field[]
15 body: Body
16 // The shell command, for its card's header.
17 command: string
18 isErrored: boolean
19 cwd: string
20 columns: number
21 isAll: boolean
22 toggleAll: () => void
23 // Claude Code's own drawing of the call, for a body this cannot draw.
24 stock: () => Promise<RenderElement>
25}
26
27const SHELL_FOLD = { head: 8, tail: 4 }
28
29// Shell output shows whole when folding is off or the person opened it.
30const isWhole = (look: Look, opened: Opened): boolean => opened.isAll || !look.prefs.fold || look.folds?.open.has(OUTPUT_FOLD) === true
31
32// The row with its chevron at the right; `▸` closed, `▾` open.
33export function disclosedRow(look: Look, row: RenderElement, isOpen: boolean, toggle: () => void, opened?: RenderElement) {
34 const { Box, Button } = look.ui
35 const ascii = look.prefs.icons === 'ascii'
36 const chevron = isOpen ? (ascii ? 'v' : '▾') : ascii ? '>' : '▸'
37
38 return (
39 <Box flexDirection="column">
40 <Box flexDirection="row" alignItems="flex-end">
41 <Box flexGrow={1} flexShrink={1} flexDirection="column">
42 {row}
43 </Box>
44 <Box flexShrink={0} paddingLeft={1}>
45 <Button key="disclose" label={chevron} plain dimColor onPress={toggle} />
46 </Box>
47 </Box>
48 {opened ?? ''}
49 </Box>
50 )
51}
52
53function fieldRows(look: Look, fields: readonly Field[]) {
54 const { Text } = look.ui
55 const { palette } = look.skin
56
57 return fields.map(field => (
58 <Text>
59 <Text color={palette.muted}>{`${field.label} `}</Text>
60 <Text color={palette.fg}>{field.value.length > MAX_BLOCK ? `${field.value.slice(0, MAX_BLOCK)}…` : field.value}</Text>
61 </Text>
62 ))
63}
64
65// `show all 120 lines` under a folded answer, `show less` once it is open.
66function moreButton(look: Look, total: number, opened: Opened) {
67 const { Button } = look.ui
68
69 return total <= DETAIL_LINES ? (
70 ''
71 ) : (
72 <Button key="show-all" label={opened.isAll ? 'show less' : `show all ${total} lines`} plain dimColor onPress={opened.toggleAll} />
73 )
74}
75
76function textLines(look: Look, lines: readonly string[], color: string, opened: Opened) {
77 const { Box, Text } = look.ui
78 const shown = opened.isAll ? lines : lines.slice(0, DETAIL_LINES)
79
80 return (
81 <Box flexDirection="column">
82 {shown.map(line => (
83 <Text color={color} wrap="truncate-end">
84 {line === '' ? ' ' : line}
85 </Text>
86 ))}
87 {moreButton(look, lines.length, opened)}
88 </Box>
89 )
90}
91
92async function bodyRows(look: Look, opened: Opened): Promise<RenderElement | string> {
93 const { Box, Code, Text } = look.ui
94 const { palette } = look.skin
95 const { body } = opened
96
97 switch (body.kind) {
98 case 'file': {
99 const lines = body.text.split('\n')
100 const shown = opened.isAll ? lines : lines.slice(0, DETAIL_LINES)
101
102 return (
103 <Box flexDirection="column">
104 {chunksOf(shown, body.startLine).map(chunk => (
105 <Code source={chunk.text} path={body.path} startLine={chunk.startLine} wrap="truncate-end" />
106 ))}
107 {moreButton(look, lines.length, opened)}
108 </Box>
109 )
110 }
111 case 'matches':
112 return (
113 <Box flexDirection="column">
114 {body.note === '' ? '' : <Text color={palette.muted}>{body.note}</Text>}
115 {textLines(look, body.lines, palette.fg, opened)}
116 </Box>
117 )
118 case 'shell':
119 return look.svg !== undefined
120 ? terminalCard(look, look.svg, body.result, opened.isErrored, opened.columns)
121 : shellRows(look, opened.command, body.result, opened.isErrored, {
122 stdout: isWhole(look, opened) ? null : SHELL_FOLD,
123 stderr: isWhole(look, opened) || opened.isErrored ? null : SHELL_FOLD,
124 })
125 case 'diff': {
126 const path = shortenPath(body.diff.path, opened.cwd)
127
128 if (look.svg !== undefined) {
129 return diffCard(look, look.svg, body.diff, path, opened.columns)
130 }
131
132 const source = unifiedDiff(body.diff)
133
134 // A diff cut mid-hunk no longer parses: one too long for a block is Claude Code's.
135 return source.length > MAX_BLOCK ? opened.stock() : <Code source={source} format="diff" path={body.diff.path} wrap="truncate-end" />
136 }
137 case 'text':
138 return textLines(look, body.text.split('\n'), body.isError ? palette.err : palette.fg, opened)
139 case 'none':
140 return ''
141 case 'stock':
142 return opened.stock()
143 }
144}
145
146// What an opened row holds: the input, a line's breath, the answer.
147export async function openedRows(look: Look, opened: Opened) {
148 const { Box } = look.ui
149 const body = await bodyRows(look, opened)
150
151 return (
152 <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
153 {fieldRows(look, opened.fields)}
154 {body === '' ? '' : <Box marginTop={opened.fields.length === 0 ? 0 : 1} flexDirection="column">{body}</Box>}
155 </Box>
156 )
157}
158hooks/markdown.ts 381 lines1// Finds the tables and fenced code in a reply so they can be drawn as cards; the rest
2// stays markdown.
3
4export type Align = 'left' | 'right' | 'center'
5
6export type Table = { kind: 'table'; header: string[]; align: Align[]; rows: string[][] }
7
8// `raw` is the fence as written, for the surfaces that keep Claude Code's own drawing.
9export type Code = { kind: 'code'; lang: string; code: string; raw: string }
10
11// `tex` is the formula alone; `raw` the block as written, kept for the copy button.
12export type Math = { kind: 'math'; tex: string; raw: string }
13
14export type Segment = { kind: 'text'; text: string } | Table | Code | Math
15
16// Fences the desktop marks runnable with a Run button of its own. A card is an image and
17// cannot carry it, so these keep Claude Code's drawing there.
18const SHELLS = new Set(['bash', 'sh', 'shell', 'zsh', 'fish', 'console', 'powershell', 'pwsh', 'ps1', 'cmd', 'bat'])
19
20export const isShell = (segment: Segment): boolean => segment.kind === 'code' && SHELLS.has(segment.lang)
21
22const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
23const FENCE = /^\s*(```|~~~)\s*([\w+#.-]*)/
24
25// Inline emphasis and code ticks read as noise in a padded cell.
26const clean = (cell: string): string =>
27 cell.replace(/\*\*|__|`/g, '').replace(/\\\|/g, '|').trim()
28
29export function cellsOf(line: string): string[] {
30 const inner = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
31
32 return inner.split(/(?<!\\)\|/).map(clean)
33}
34
35const alignOf = (cell: string): Align => {
36 const spec = cell.trim()
37
38 if (spec.startsWith(':') && spec.endsWith(':')) {
39 return 'center'
40 }
41
42 return spec.endsWith(':') ? 'right' : 'left'
43}
44
45const fit = (cells: string[], width: number): string[] =>
46 Array.from({ length: width }, (_, i) => cells[i] ?? '')
47
48// `tables` off leaves tables as text; a fence `fence` turns down stays text too, so a
49// reply can be split for its charts alone. `math` takes display formulas out as their own
50// segments: ````math`` fences, `$$…$$` and `\[…\]`.
51export type SplitOptions = { tables: boolean; fence: (lang: string, code: string) => boolean; math?: boolean }
52
53const SPLIT_ALL: SplitOptions = { tables: true, fence: () => true }
54
55export function splitReply(markdown: string, options: SplitOptions = SPLIT_ALL): Segment[] {
56 const lines = markdown.split('\n')
57 const segments: Segment[] = []
58 let text: string[] = []
59 let inFence = false
60
61 const flush = () => {
62 if (text.join('\n').trim() !== '') {
63 segments.push({ kind: 'text', text: text.join('\n') })
64 }
65
66 text = []
67 }
68
69 for (let i = 0; i < lines.length; i++) {
70 const line = lines[i] ?? ''
71 const next = lines[i + 1] ?? ''
72
73 const fence = FENCE.exec(line)
74
75 if (options.math === true && !inFence) {
76 const formula = displayMath(lines, i, fence)
77
78 if (formula !== null) {
79 flush()
80 segments.push({ kind: 'math', tex: formula.tex, raw: lines.slice(i, formula.end + 1).join('\n') })
81 i = formula.end
82 continue
83 }
84 }
85
86 // A closed fence becomes a code segment; one still streaming stays text.
87 if (fence !== null && !inFence) {
88 const close = lines.findIndex((other, j) => j > i && FENCE.test(other) && other.trim().replace(/[`~]/g, '') === '')
89
90 if (close !== -1) {
91 const lang = (fence[2] ?? '').toLowerCase()
92 const code = lines.slice(i + 1, close).join('\n')
93
94 if (!options.fence(lang, code)) {
95 text.push(...lines.slice(i, close + 1))
96 i = close
97 continue
98 }
99
100 flush()
101 segments.push({ kind: 'code', lang, code, raw: lines.slice(i, close + 1).join('\n') })
102 i = close
103 continue
104 }
105
106 inFence = true
107 } else if (fence !== null) {
108 inFence = false
109 }
110
111 const header = cellsOf(line)
112 const isTable =
113 options.tables &&
114 !inFence &&
115 line.includes('|') &&
116 SEPARATOR.test(next) &&
117 header.length > 1 &&
118 cellsOf(next).length === header.length
119
120 if (!isTable) {
121 text.push(line)
122 continue
123 }
124
125 flush()
126
127 const rows: string[][] = []
128 i += 2
129
130 while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim() !== '') {
131 rows.push(fit(cellsOf(lines[i] ?? ''), header.length))
132 i++
133 }
134
135 i--
136 segments.push({ kind: 'table', header, align: cellsOf(next).map(alignOf), rows })
137 }
138
139 flush()
140
141 return segments
142}
143
144const DISPLAY_OPEN = /^\s*(\$\$|\\\[)(.*)$/
145
146// A display formula starting on line `i`: where it ends and the TeX inside. One still
147// streaming (no closing delimiter yet) is not one, so it stays text until it closes.
148function displayMath(lines: readonly string[], i: number, fence: RegExpExecArray | null): { tex: string; end: number } | null {
149 const line = lines[i] ?? ''
150
151 if (fence !== null) {
152 if ((fence[2] ?? '').toLowerCase() !== 'math') {
153 return null
154 }
155
156 const close = lines.findIndex((other, j) => j > i && FENCE.test(other) && other.trim().replace(/[`~]/g, '') === '')
157
158 return close === -1 ? null : { tex: lines.slice(i + 1, close).join('\n'), end: close }
159 }
160
161 const open = DISPLAY_OPEN.exec(line)
162
163 if (open === null) {
164 return null
165 }
166
167 const closer = open[1] === '$$' ? '$$' : '\\]'
168 const rest = open[2] ?? ''
169 const sameLine = rest.indexOf(closer)
170
171 // Whole on one line, ` x `, with nothing but space after it.
172 if (sameLine !== -1) {
173 const tex = rest.slice(0, sameLine)
174
175 return rest.slice(sameLine + closer.length).trim() === '' && tex.trim() !== '' ? { tex, end: i } : null
176 }
177
178 for (let j = i + 1; j < lines.length; j++) {
179 const other = lines[j] ?? ''
180 const at = other.indexOf(closer)
181
182 if (at !== -1) {
183 if (other.slice(at + closer.length).trim() !== '') {
184 return null
185 }
186
187 const tex = [rest, ...lines.slice(i + 1, j), other.slice(0, at)].join('\n')
188
189 return tex.trim() === '' ? null : { tex, end: j }
190 }
191
192 // A blank line ends a paragraph, and a formula with it.
193 if (other.trim() === '') {
194 return null
195 }
196 }
197
198 return null
199}
200
201// Terminal cells a character takes: wide East Asian characters and emoji take two,
202// combining marks, zero-width joiners and skin-tone modifiers none.
203const WIDE: readonly (readonly [number, number])[] = [
204 [0x1100, 0x115f],
205 [0x2e80, 0x303e],
206 [0x3041, 0x33ff],
207 [0x3400, 0x4dbf],
208 [0x4e00, 0x9fff],
209 [0xa000, 0xa4cf],
210 [0xa960, 0xa97f],
211 [0xac00, 0xd7a3],
212 [0xf900, 0xfaff],
213 [0xfe10, 0xfe19],
214 [0xfe30, 0xfe6f],
215 [0xff00, 0xff60],
216 [0xffe0, 0xffe6],
217 [0x1f300, 0x1f64f],
218 [0x1f680, 0x1f6ff],
219 [0x1f900, 0x1f9ff],
220 [0x1fa70, 0x1faff],
221 [0x20000, 0x3fffd],
222]
223
224export const charWidth = (char: string): number => {
225 const code = char.codePointAt(0) ?? 0
226
227 if (/\p{Mn}|\p{Me}|\u200d|[\ufe00-\ufe0f]|\p{Emoji_Modifier}/u.test(char)) {
228 return 0
229 }
230
231 return WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
232}
233
234export const widthOf = (text: string): number => [...text].reduce((sum, char) => sum + charWidth(char), 0)
235
236// `text` cut to `width` cells, marked with an ellipsis where it lost text.
237const cutTo = (text: string, width: number): string => {
238 if (widthOf(text) <= width) {
239 return text
240 }
241
242 let kept = ''
243 let used = 0
244
245 for (const char of text) {
246 const next = charWidth(char)
247
248 if (used + next > width - 1) {
249 break
250 }
251
252 kept += char
253 used += next
254 }
255
256 return `${kept}…`
257}
258
259// Natural column widths, narrowed from the widest down until the table fits.
260export function columnWidths(table: Table, maxWidth: number, gap: number): number[] {
261 const widths = table.header.map((cell, col) =>
262 Math.max(widthOf(cell), ...table.rows.map(row => widthOf(row[col] ?? ''))),
263 )
264 const budget = maxWidth - gap * (widths.length - 1)
265
266 while (widths.reduce((sum, width) => sum + width, 0) > budget) {
267 const widest = widths.indexOf(Math.max(...widths))
268
269 if ((widths[widest] ?? 0) <= 3) {
270 break
271 }
272
273 widths[widest] = (widths[widest] ?? 0) - 1
274 }
275
276 return widths
277}
278
279// A cell cut to its column, marked with an ellipsis where it lost text.
280export function cutCell(text: string, width: number): string {
281 return cutTo(text, width)
282}
283
284// Splits a word too long for its column into pieces that each fit.
285function breakWord(word: string, width: number, measure: (text: string) => number): string[] {
286 const pieces: string[] = []
287 let piece = ''
288
289 for (const char of word) {
290 if (piece !== '' && measure(piece + char) > width) {
291 pieces.push(piece)
292 piece = char
293 } else {
294 piece += char
295 }
296 }
297
298 return piece === '' ? pieces : [...pieces, piece]
299}
300
301// `text` as lines that fit `width`; past `maxLines`, the last line ends in `…`. `measure`
302// is terminal cells by default; the desktop card passes its pixel measure.
303export function wrapLines(text: string, width: number, maxLines: number, measure: (text: string) => number = widthOf): string[] {
304 const lines: string[] = []
305 let line = ''
306
307 for (const word of text.split(/\s+/).filter(Boolean)) {
308 const candidate = line === '' ? word : `${line} ${word}`
309
310 if (measure(candidate) <= width) {
311 line = candidate
312 continue
313 }
314
315 if (line !== '') {
316 lines.push(line)
317 }
318
319 const pieces = measure(word) <= width ? [word] : breakWord(word, width, measure)
320 lines.push(...pieces.slice(0, -1))
321 line = pieces.at(-1) ?? ''
322 }
323
324 const all = line === '' ? lines : [...lines, line]
325
326 if (all.length <= maxLines) {
327 return all.length === 0 ? [''] : all
328 }
329
330 const kept = all.slice(0, maxLines)
331 const chars = [...(kept[maxLines - 1] ?? '')]
332
333 while (chars.length > 0 && measure(`${chars.join('')}…`) > width) {
334 chars.pop()
335 }
336
337 return [...kept.slice(0, -1), `${chars.join('')}…`]
338}
339
340export function padCell(text: string, width: number, align: Align): string {
341 const cut = cutTo(text, width)
342 const room = width - widthOf(cut)
343
344 if (align === 'right') {
345 return ' '.repeat(room) + cut
346 }
347
348 if (align === 'center') {
349 const left = Math.floor(room / 2)
350
351 return ' '.repeat(left) + cut + ' '.repeat(room - left)
352 }
353
354 return cut + ' '.repeat(room)
355}
356
357// What `/skin copy` puts on the clipboard: the whole reply, or with `code` its last code
358// block; a message instead when there is nothing to copy.
359export function copyOf(reply: string, code: boolean): { text: string } | { message: string } {
360 if (reply.trim() === '') {
361 return { message: 'Nothing to copy yet' }
362 }
363
364 if (!code) {
365 return { text: reply }
366 }
367
368 const last = splitReply(reply).findLast((segment): segment is Code => segment.kind === 'code')
369
370 return last === undefined ? { message: 'No code block in the last reply' } : { text: last.code }
371}
372
373// A table as it is drawn, boxed in monospace, for pasting where markdown does not render.
374export function tableArt(table: Table): string {
375 const widths = table.header.map((cell, col) => Math.max(widthOf(cell), ...table.rows.map(row => widthOf(row[col] ?? ''))))
376 const rule = (left: string, mid: string, right: string) => `${left}${widths.map(width => '─'.repeat(width + 2)).join(mid)}${right}`
377 const row = (cells: readonly string[]) => `│${widths.map((width, col) => ` ${padCell(cells[col] ?? '', width, table.align[col] ?? 'left')} `).join('│')}│`
378
379 return [rule('╭', '┬', '╮'), row(table.header), rule('├', '┼', '┤'), ...table.rows.map(row), rule('╰', '┴', '╯')].join('\n')
380}
381hooks/mermaid.ts 403 lines1// The Mermaid people actually write in a reply, read well enough to draw: flowcharts,
2// xy charts and pies here, the planning and shape kinds in their own files. Anything past
3// that (subgraphs, styling, other diagram kinds) reads as null, and the fence keeps its
4// code-block drawing.
5
6import { frontmatterOf, listOf, numbersOf, plural, statementsOf, unquote } from './mermaid-lex'
7import { parseGantt, parseJourney, parseKanban, parseTimeline } from './mermaid-plan'
8import type { Gantt, Journey, Kanban, Timeline } from './mermaid-plan'
9import { parseGitGraph, parseMindmap, parseQuadrant, parseRadar, parseSankey } from './mermaid-shape'
10import type { GitGraph, Mindmap, Quadrant, Radar, Sankey } from './mermaid-shape'
11import { parsePacket, parseTreemap } from './mermaid-data'
12import type { Packet, Treemap } from './mermaid-data'
13import { parseArchitecture, parseBlock, parseC4 } from './mermaid-system'
14import type { Architecture, Block, C4 } from './mermaid-system'
15
16export type * from './mermaid-plan'
17export type * from './mermaid-shape'
18export type * from './mermaid-data'
19export type * from './mermaid-system'
20
21export type Shape = 'box' | 'round' | 'diamond'
22
23export type FlowNode = { id: string; label: string; shape: Shape }
24
25export type FlowEdge = { from: string; to: string; label: string; line: 'solid' | 'dotted' | 'thick'; isArrow: boolean }
26
27// `ranks` are the nodes by depth, each rank in the order the source named its nodes;
28// `back` marks the edges that point up the ranks, as in a retry loop.
29export type Flow = {
30 kind: 'flow'
31 title: string
32 direction: 'down' | 'right'
33 nodes: FlowNode[]
34 edges: FlowEdge[]
35 ranks: string[][]
36 back: FlowEdge[]
37}
38
39export type Series = { kind: 'bar' | 'line'; name: string; values: number[] }
40
41export type XyChart = { kind: 'xy'; title: string; labels: string[]; yLabel: string; min: number; max: number; series: Series[] }
42
43export type Slice = { label: string; value: number }
44
45export type Pie = { kind: 'pie'; title: string; slices: Slice[] }
46
47export type Chart = Flow | XyChart | Pie | Gantt | Timeline | Journey | Kanban | Mindmap | Quadrant | Radar | Sankey | GitGraph | Treemap | Packet | Block | Architecture | C4
48
49// Past these a picture stops being easier to read than the source.
50export const MAX_NODES = 40
51const MAX_POINTS = 60
52const MAX_SLICES = 16
53
54export function parseMermaid(source: string): Chart | null {
55 const { title, lines } = frontmatterOf(source)
56 const chart = parseKind(lines)
57
58 // A frontmatter title names any chart that did not name itself.
59 return chart === null || chart.title !== '' || title === '' ? chart : { ...chart, title }
60}
61
62// The header word and the parser that reads the lines after it.
63const KINDS: readonly (readonly [RegExp, (body: readonly string[]) => Chart | null])[] = [
64 [/^xychart(-beta)?\b/, parseXy],
65 [/^gantt\b/, parseGantt],
66 [/^timeline\b/, parseTimeline],
67 [/^journey\b/, parseJourney],
68 [/^kanban\b/, parseKanban],
69 [/^mindmap\b/, parseMindmap],
70 [/^quadrantChart\b/, parseQuadrant],
71 [/^radar(-beta)?\b/, parseRadar],
72 [/^sankey(-beta)?\b/, parseSankey],
73 [/^gitGraph\b/, parseGitGraph],
74 [/^treemap(-beta)?\b/, parseTreemap],
75 [/^packet(-beta)?\b/, parsePacket],
76 [/^block(-beta)?\s*$/, parseBlock],
77 [/^architecture(-beta)?\s*$/, parseArchitecture],
78]
79
80function parseKind(lines: readonly string[]): Chart | null {
81 const start = lines.findIndex(line => line.trim() !== '' && !line.trim().startsWith('%%'))
82 const head = (lines[start] ?? '').trim()
83 const body = lines.slice(start + 1)
84
85 // `graph LR; a-->b` puts its first statements on the header line.
86 if (/^(flowchart|graph)\b/.test(head)) {
87 const [direction = '', ...inline] = head.split(';')
88
89 return parseFlow(direction.trim(), [...inline, ...body])
90 }
91
92 if (/^pie\b/.test(head)) {
93 return parsePie(head, body)
94 }
95
96 if (/^C4(Context|Container|Component|Dynamic|Deployment)\s*$/.test(head)) {
97 return parseC4(head, body)
98 }
99
100 const parse = KINDS.find(([pattern]) => pattern.test(head))?.[1]
101
102 return parse === undefined ? null : parse(body)
103}
104
105// A node reference with its optional label: `A`, `A[Box]`, `A(Round)`, `A{Choice}`, and
106// the doubled forms Mermaid draws as circles, stadiums, subroutines and hexagons.
107const NODE =
108 /^([A-Za-z0-9_]+)\s*(?:\(\((.*?)\)\)|\(\[(.*?)\]\)|\[\[(.*?)\]\]|\{\{(.*?)\}\}|\[(.*?)\]|\((.*?)\)|\{(.*?)\})?/
109
110// An edge: `-->`, `---`, `-.->`, `==>`, with a label as `-->|yes|` or `-- yes -->`.
111const EDGE =
112 /^(?:(-->|---|-\.->|-\.-|==>|===)\s*(?:\|([^|]*)\|)?|(--|-\.|==)\s*([^|>-][^>]*?)\s*(-->|---|\.->|\.-|==>|===))\s*/
113
114function readNode(text: string): { node: FlowNode; label: boolean; rest: string } | null {
115 const match = NODE.exec(text)
116
117 if (match === null) {
118 return null
119 }
120
121 const [whole, id = ''] = match
122 const groups = match.slice(2)
123 const at = groups.findIndex(group => group !== undefined)
124 const shape: Shape = at === 3 || at === 6 ? 'diamond' : at === 0 || at === 1 || at === 5 ? 'round' : 'box'
125
126 return {
127 node: { id, label: at === -1 ? id : unquote(groups[at] ?? ''), shape },
128 label: at !== -1,
129 rest: text.slice(whole.length).trim(),
130 }
131}
132
133function parseFlow(head: string, body: readonly string[]): Flow | null {
134 const direction = /\b(LR|RL)\b/.test(head) ? 'right' : 'down'
135 const nodes = new Map<string, FlowNode>()
136 const edges: FlowEdge[] = []
137
138 const meet = (read: { node: FlowNode; label: boolean }) => {
139 const known = nodes.get(read.node.id)
140
141 // A later bare mention keeps the label an earlier one gave.
142 if (known === undefined || read.label) {
143 nodes.set(read.node.id, read.node)
144 }
145 }
146
147 for (const statement of statementsOf(body)) {
148 if (/^(subgraph\b|end$|direction\s)/.test(statement)) {
149 return null
150 }
151
152 let read = readNode(statement)
153
154 if (read === null) {
155 return null
156 }
157
158 meet(read)
159
160 while (read.rest !== '') {
161 const edge = EDGE.exec(read.rest)
162
163 if (edge === null) {
164 return null
165 }
166
167 const arrow = edge[1] ?? edge[5] ?? ''
168 const from: string = read.node.id
169 const next = readNode(read.rest.slice(edge[0].length))
170
171 if (next === null) {
172 return null
173 }
174
175 meet(next)
176 edges.push({
177 from,
178 to: next.node.id,
179 label: unquote(edge[2] ?? edge[4] ?? ''),
180 line: arrow.includes('.') ? 'dotted' : arrow.includes('=') ? 'thick' : 'solid',
181 isArrow: arrow.endsWith('>'),
182 })
183 read = next
184 }
185 }
186
187 if (nodes.size === 0 || nodes.size > MAX_NODES) {
188 return null
189 }
190
191 const order = [...nodes.keys()]
192 const back = backEdges(order, edges)
193 const forward = edges.filter(edge => !back.includes(edge))
194 const depth = new Map(order.map(id => [id, 0]))
195
196 // Longest path from a source: each node one rank below the deepest node feeding it.
197 for (let pass = 0; pass < order.length; pass++) {
198 for (const edge of forward) {
199 if (edge.from !== edge.to) {
200 depth.set(edge.to, Math.max(depth.get(edge.to) ?? 0, (depth.get(edge.from) ?? 0) + 1))
201 }
202 }
203 }
204
205 const ranks: string[][] = []
206
207 for (const id of order) {
208 const rank = depth.get(id) ?? 0
209 ranks[rank] = [...(ranks[rank] ?? []), id]
210 }
211
212 return { kind: 'flow', title: '', direction, nodes: order.map(id => nodes.get(id) as FlowNode), edges, ranks: ranks.filter(rank => rank !== undefined), back }
213}
214
215// The edges that close a cycle, found walking from each node in source order.
216function backEdges(order: readonly string[], edges: readonly FlowEdge[]): FlowEdge[] {
217 const state = new Map<string, 'open' | 'done'>()
218 const back: FlowEdge[] = []
219
220 const walk = (id: string) => {
221 state.set(id, 'open')
222
223 for (const edge of edges.filter(candidate => candidate.from === id)) {
224 const seen = state.get(edge.to)
225
226 if (seen === 'open') {
227 back.push(edge)
228 } else if (seen === undefined) {
229 walk(edge.to)
230 }
231 }
232
233 state.set(id, 'done')
234 }
235
236 for (const id of order) {
237 if (!state.has(id)) {
238 walk(id)
239 }
240 }
241
242 return back
243}
244
245// A nice round step for about `count` gridlines across `span`.
246export function niceStep(span: number, count = 4): number {
247 const raw = span / count
248 const power = 10 ** Math.floor(Math.log10(raw))
249 const unit = raw / power
250
251 return (unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 2.5 ? 2.5 : unit <= 5 ? 5 : 10) * power
252}
253
254function parseXy(body: readonly string[]): XyChart | null {
255 let title = ''
256 let labels: string[] = []
257 let yLabel = ''
258 let range: [number, number] | null = null
259 const series: Series[] = []
260
261 for (const statement of statementsOf(body)) {
262 const titled = /^title\s+(.*)$/.exec(statement)
263 const xAxis = /^x-axis\s*(?:("[^"]*"|[^[\d\s-][^[]*?))?\s*(?:\[(.*)\]|(-?[\d.]+)\s*-->\s*(-?[\d.]+))?$/.exec(statement)
264 const yAxis = /^y-axis\s*(?:("[^"]*"|[^\d\s-][^\d]*?))?\s*(?:(-?[\d.]+)\s*-->\s*(-?[\d.]+))?$/.exec(statement)
265 const plotted = /^(bar|line)\s*(?:("[^"]*"|\S+)\s*)?\[(.*)\]$/.exec(statement)
266
267 if (titled !== null) {
268 title = unquote(titled[1] ?? '')
269 } else if (xAxis !== null) {
270 labels = xAxis[2] === undefined ? [] : listOf(xAxis[2])
271 } else if (yAxis !== null) {
272 yLabel = unquote(yAxis[1] ?? '')
273 range = yAxis[2] === undefined ? null : [Number(yAxis[2]), Number(yAxis[3])]
274 } else if (plotted !== null) {
275 const values = numbersOf(plotted[3] ?? '')
276
277 if (values === null || values.length === 0 || values.length > MAX_POINTS) {
278 return null
279 }
280
281 series.push({ kind: plotted[1] === 'line' ? 'line' : 'bar', name: unquote(plotted[2] ?? ''), values })
282 } else {
283 return null
284 }
285 }
286
287 if (series.length === 0) {
288 return null
289 }
290
291 const points = Math.max(...series.map(one => one.values.length))
292 const all = series.flatMap(one => one.values)
293 const low = Math.min(0, ...all)
294 const high = Math.max(...all)
295 const [min, max] = range ?? [low, high === low ? low + 1 : low + Math.ceil((high - low) / niceStep(high - low)) * niceStep(high - low)]
296
297 if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
298 return null
299 }
300
301 return {
302 kind: 'xy',
303 title,
304 labels: Array.from({ length: points }, (_, i) => labels[i] ?? String(i + 1)),
305 yLabel,
306 min,
307 max,
308 series,
309 }
310}
311
312function parsePie(head: string, body: readonly string[]): Pie | null {
313 let title = unquote(/\btitle\s+(.*)$/.exec(head)?.[1] ?? '')
314 const slices: Slice[] = []
315
316 for (const statement of statementsOf(body)) {
317 const titled = /^title\s+(.*)$/.exec(statement)
318 const slice = /^"([^"]*)"\s*:\s*(\d+(?:\.\d+)?)$/.exec(statement)
319
320 if (titled !== null) {
321 title = unquote(titled[1] ?? '')
322 } else if (slice !== null) {
323 slices.push({ label: unquote(slice[1] ?? ''), value: Number(slice[2]) })
324 } else {
325 return null
326 }
327 }
328
329 const total = slices.reduce((sum, slice) => sum + slice.value, 0)
330
331 return slices.length === 0 || slices.length > MAX_SLICES || total <= 0 ? null : { kind: 'pie', title, slices }
332}
333
334// What a chart is called in a card header and how much it holds.
335export function chartHeading(chart: Chart): { kind: string; count: string } {
336 switch (chart.kind) {
337 case 'flow':
338 return { kind: 'Flowchart', count: plural(chart.nodes.length, 'step') }
339 case 'xy':
340 return { kind: 'Chart', count: chart.series.length > 1 ? `${chart.series.length} series` : plural(chart.labels.length, 'point') }
341 case 'pie':
342 return { kind: 'Pie', count: plural(chart.slices.length, 'slice') }
343 case 'gantt':
344 return { kind: 'Gantt', count: plural(chart.tasks.length, 'task') }
345 case 'timeline':
346 return { kind: 'Timeline', count: plural(chart.periods.length, 'period') }
347 case 'journey':
348 return { kind: 'Journey', count: plural(chart.steps.length, 'step') }
349 case 'kanban':
350 return { kind: 'Kanban', count: plural(chart.columns.reduce((sum, column) => sum + column.cards.length, 0), 'card') }
351 case 'mindmap':
352 return { kind: 'Mindmap', count: plural(chart.count, 'node') }
353 case 'quadrant':
354 return { kind: 'Quadrant', count: plural(chart.points.length, 'point') }
355 case 'radar':
356 return { kind: 'Radar', count: plural(chart.curves.length, 'curve') }
357 case 'sankey':
358 return { kind: 'Sankey', count: plural(chart.links.length, 'flow') }
359 case 'git':
360 return { kind: 'Git graph', count: plural(chart.commits.length, 'commit') }
361 case 'treemap':
362 return { kind: 'Treemap', count: plural(chart.leaves, 'item') }
363 case 'packet':
364 return { kind: 'Packet', count: plural(chart.bits, 'bit') }
365 case 'block':
366 return { kind: 'Block diagram', count: plural(chart.boxes, 'block') }
367 case 'architecture':
368 return { kind: 'Architecture', count: plural(chart.services.filter(service => !service.isJunction).length, 'service') }
369 case 'c4':
370 return { kind: `C4 ${chart.level.toLowerCase()}`, count: plural(chart.boxes, 'element') }
371 }
372}
373
374// A number as a chart labels it: whole where it is, else to two places, with thousands
375// grouped.
376export const formatValue = (value: number): string =>
377 value.toLocaleString('en-US', { maximumFractionDigits: Number.isInteger(value) ? 0 : 2 })
378
379// A share as a percent, one decimal under 10% and none where it would read `9.0%`.
380export const formatPercent = (share: number): string => `${Number((share * 100).toFixed(share < 0.1 ? 1 : 0))}%`
381
382// A series' legend name: its own, else its kind, numbered among the unnamed ones of that kind.
383export function seriesName(chart: XyChart, i: number): string {
384 const series = chart.series[i]
385
386 if (series === undefined || series.name !== '') {
387 return series?.name ?? ''
388 }
389
390 const same = chart.series.filter(other => other.kind === series.kind && other.name === '')
391
392 return same.length === 1 ? series.kind : `${series.kind} ${same.indexOf(series) + 1}`
393}
394
395// What reaches the model: one short paragraph, sent only while charts are on.
396export const CHART_HINT = [
397 '# Charts',
398 'Fenced ```mermaid draws as a picture here: flowchart TD|LR, xychart-beta (bar/line), pie, gantt, timeline, journey,',
399 'kanban, mindmap, quadrantChart, radar-beta, sankey-beta, gitGraph, treemap-beta, packet-beta, block-beta,',
400 'architecture-beta, C4Context; on the terminal also sequence, state, class and ER diagrams.',
401 'No subgraphs or styling, at most 40 nodes. Draw one when a process, schedule, numeric comparison or breakdown reads better as a picture.',
402].join(' ')
403hooks/command-output.ts 75 lines1import { splitReply } from './markdown'
2import type { Segment, Table } from './markdown'
3
4// What a slash command printed, as the segments a reply is drawn from: its own tables and
5// fences, or a run of `key: value` lines (`/cost`, `/status`) as a two-column table. Null
6// when there is nothing to draw, so the row keeps Claude Code's drawing.
7
8const PAIR = /^\s*([^:|`]{1,40}?):\s+(\S.*?)\s*$/
9
10// Colour codes some commands still print; a card cannot show them.
11const ANSI = /\u001b\[[0-9;]*m/g
12
13const pairOf = (line: string): [string, string] | null => {
14 const match = PAIR.exec(line)
15
16 return match === null ? null : [(match[1] ?? '').trim(), match[2] ?? '']
17}
18
19function pairTables(text: string, command: string): Segment[] {
20 const segments: Segment[] = []
21 let prose: string[] = []
22 let pairs: [string, string][] = []
23
24 const flushProse = () => {
25 if (prose.join('\n').trim() !== '') {
26 segments.push({ kind: 'text', text: prose.join('\n') })
27 }
28
29 prose = []
30 }
31
32 // One pair alone reads better as the line it was.
33 const flushPairs = () => {
34 if (pairs.length >= 2) {
35 flushProse()
36 const table: Table = { kind: 'table', header: [command, ''], align: ['left', 'left'], rows: pairs }
37 segments.push(table)
38 } else {
39 prose.push(...pairs.map(([key, value]) => `${key}: ${value}`))
40 }
41
42 pairs = []
43 }
44
45 for (const line of text.split('\n')) {
46 const pair = pairOf(line)
47
48 if (pair !== null) {
49 pairs.push(pair)
50 continue
51 }
52
53 flushPairs()
54 prose.push(line)
55 }
56
57 flushPairs()
58 flushProse()
59
60 return segments
61}
62
63export function commandSegments(raw: string, command: string): Segment[] | null {
64 const text = raw.replace(ANSI, '')
65 const split = splitReply(text)
66
67 if (split.some(segment => segment.kind !== 'text')) {
68 return split
69 }
70
71 const paired = pairTables(text, command === '' ? 'output' : command)
72
73 return paired.some(segment => segment.kind === 'table') ? paired : null
74}
75