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