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

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