SLOPSHOPPER

skins

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…

newpanebandspinnerrowsguard
★ 1v0.5.9MITupdated 2026-10-21samuelbfernandes/moddeck/mods/skins
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skins
│ ┃ Skins ✕ │ ┃ Skin 1: noir ● 2: tokyo-night 3: dracula ╭─────────────────────────────────────────────────────────────────╮ │ ┃ │ fix the failing auth test and add an audit log call │ │ ┃ ╭─────────────────────────────────────────── ╰─────────────────────────────────────────────────────────────────╯ │ ┃ │ │ ┃ │ ╭──────────────────────────────────────╮ │ ┃ │ │ add rate limiting to the hub api │ │ ┃ │ ╰──────────────────────────────────────╯ ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ ┃ │ ┃ │ ┃ ●─ Edit src/auth.ts 20ms │ ┃ │ ●─ Bash pnpm test --filter hub 2.1s │ ┃ │ ┃ ⏺ Bash(bun test) │ ┃ │ ●─ Edit apps/hub/src/server.ts +18 −3 3 ⎿ 3 pass, 1 fail │ ┃ │ ┃ │ ┃ │ ●─ Read 2 · Search 1 Done. I made refresh reject expired claims and added an audit call. │ ┃ │ │ ┃ │ ╭──────────────────────────────────────╮ │ ┃ │ │ Route Limit Window │ ◆ Done in 42s · 9 tools │ ┃ │ │ /chat 60 1 min │ │ ┃ │ │ /upload 10 1 min │ › /skin │ ┃ │ │ /search 120 1 min │ │ ┃ │ ╰──────────────────────────────────────╯ │ ┃ │ │ ┃ │ │ ┃ │ ⠋ Working… <1s ▰▰▰▰▱▱▱▱ 49% context ▰▰▱▱▱▱▱▱ 31% 5h 0: Compact ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▰▰▰▰▱▱▱▱ 49% context ▰▰▱▱▱▱▱▱ 31% 5h 0: Compact ⟨Claude Code's own drawing⟩
Pane · Skins
Skin 1: noir ● 2: tokyo-night 3: dracula 4: nord 5: gru ╭──────────────────────────────────────────────────────────╮ │ │ │ ╭──────────────────────────────────────────────────────╮ │ │ │ add rate limiting to the hub api │ │ │ ╰──────────────────────────────────────────────────────╯ │ │ │ │ │ │ ┃ │ │ ●─ Bash pnpm test --filter hub 2.1s │ │ ┃ │ │ ●─ Edit apps/hub/src/server.ts +18 −3 380ms │ │ ┃ │ │ ●─ Read 2 · Search 1 │ │ │ │ ╭──────────────────────────────────────────────────────╮ │ │ │ Route Limit Window │ │ │ │ /chat 60 1 min │ │ │ │ /upload 10 1 min │ │ │ │ /search 120 1 min │ │ │ ╰──────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ │ ◆ Done in 41s · 6 tools · +18 −3 │ ╰──────────────────────────────────────────────────────────╯ Look r: rail ● t: tables on s: shimmer ● b: band ● w: c user fg muted surface zebra ok er Colour: user ▾ #f5f5f5: #rrggbb ⏎ paint Or ask Claude: “make me a skin that feels like a sunset”. Esc closes.
Pane · skins-gallery
1 Your prompt ╭──────────────────────────────────────────────────────────╮ │ add rate limiting to the hub api │ ╰──────────────────────────────────────────────────────────╯ 2 Tool rows: finished, running, failed ┃ ●─ Bash pnpm test --filter hub 2.1s ┃ ○─ Read docs/protocols.md ┃ ✕─ Edit apps/hub/src/server.ts 3 Folded group of reads and searches ┃ ●─ Read 2 · Search 1 4 Edit diff card Desktop only; the terminal keeps Claude Code's diff. 5 Shell output card: ok, failed with stderr, folded ╭──────────────────────────────────────────────────────────╮ │ $ pnpm test --filter hub ✓ │ │ │ │ Test Files 12 passed (12) │ │ Tests 148 passed (148) │ │ │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ $ pnpm tsc ✗ exit 2 │ │ │ │ src/server.ts │ │ stderr │ │ error TS2322: Type string is not assignable to number │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ $ seq 40 ✓ │ │ │ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │ 5 │ │ 6 │ │ 7 │ │ 8 │ │ … 28 lines hidden │ │ 37 │ │ 38 │ │ 39 │ │ 40 │ │ │ │ │ ╰──────────────────────────────────────────────────────────╯ 6 Code block, highlighted ╭──────────────────────────────────────────────────────────╮ │ // Rate limit per route, keyed by user │ │ export function limit(route: string, perMinute = 60) { │ │ return (used: number) => used < perMinute │ │ } │ ╰──────────────────────────────────────────────────────────╯ ts 7 Table ╭──────────────────────────────────────────────────────────╮ │ … … … │ │ … … … │ │ … … … │ │ … … … │ ╰──────────────────────────────────────────────────────────╯ 8 Spinner ⠸ Thinking… 12s 9 Band above the prompt: normal, then nudging to compact ▰▰▰▱▱▱▱▱ 42% context ▰▱▱▱▱▱▱▱ 18% 5h 0: Compact ▰▰▰▰▰▰▰▱ 85% context ▰▰▰▰▰▱▱▱ 61% 5h Context is 85% ful 10 Band above a question ▍ Approach · Store 11 Turn footer (terminal) ◆ Done in 41s · 6 tools · +18 −3
Spinner
⠋ Working… <1s
Turn line
◆ Done in 42s · 9 tools
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
┃ ●─ Edit src/auth.ts 20ms
README

skins

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.

Credit

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:

  • The daily version check (a request to GitHub) and the one-time "star us" toast were removed: moddeck mods make no network calls. Update by updating moddeck.
  • plugin.json says where it came from. The skin-designer skill, the tests and the drawing code are as published.

Use

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.

Turn off

/skin off, or /plugin disable skins.

Costs and limits

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.

Source 75 files
hooks/register.tsx 1219 lines
1import { 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 theirs
hooks/command.ts 224 lines
1import 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}
224
hooks/custom.ts 185 lines
1import 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}
185
hooks/light.ts 100 lines
1import 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
100
hooks/folders.ts 21 lines
1import 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))
21
hooks/designer.ts 141 lines
1import 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}
141
hooks/format.ts 85 lines
1// 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}
85
hooks/detail.ts 256 lines
1import { 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}
256
hooks/detail-rows.tsx 158 lines
1import 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}
158
hooks/markdown.ts 381 lines
1// 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}
381
hooks/mermaid.ts 403 lines
1// 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(' ')
403
hooks/command-output.ts 75 lines
1import { 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