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">
Fork of hellosverre/claude-skins (MIT). Needs Claude Code 2.1.287 or later and GitHub access to this repo (SSH key or gh auth login). Run both inside Claude Code:
/plugin marketplace add devwriet/claude-skins
/plugin install skins@srit-mods
Restart Claude Code. Type /skin to change the skin; mocha is the colorful one. /skin label <name> sets the name above your messages (default User).
If upstream skins is installed, uninstall it first: both use the plugin name skins.
This fork has no update check. To update:
claude plugin marketplace update srit-mods
claude plugin update skins@srit-mods
Then restart Claude Code.
claude plugin uninstall skins@srit-mods
To keep it installed but go back to Claude Code's own drawing, run /skin off.
| Command | What it does | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
/skin | Settings: pick a skin with a live preview, toggle parts, repaint any colour | ||||||||||||||||
/skin <name> | Switch skin: noir, tokyo-night, dracula, catppuccin, rose-pine, nord, gruvbox, kanagawa, everforest, one-dark, solarized, night-owl, ayu, github, mono | ||||||||||||||||
/skin list | Every skin, yours included | ||||||||||||||||
/skin off | Back to Claude Code's own drawing | ||||||||||||||||
| `/skin rail\ | shimmer\ | band\ | countdown\ | clip\ | markdown\ | quiet\ | charts\ | math\ | commands\ | shell\ | highlight\ | hints\ | links\ | copy\ | fold on\ | off` | Toggle one part |
/skin calm · calm off | No motion, one line per tool row, read-only output folded, failures always whole. calm off puts back what you had | ||||||||||||||||
/skin copy · copy code | Copy Claude's last reply, or just its last code block | ||||||||||||||||
| `/skin tables on\ | text\ | off` | text draws tables and code as selectable text on the desktop | ||||||||||||||
| `/skin prompt left\ | right\ | plain` | In the terminal, which side your prompt's outline sits on, or plain for a leading marker instead. 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 keeps Claude Code's own, whatever the skin says; skin (the default) draws the skin's | |||||||||||||||
/skin gallery | Every element the skin draws, numbered, to point at | ||||||||||||||||
/skin pin · unpin · share | Give this folder its own look, drop it, or make it the default |
Design one with Claude. Ask "make me a skin that feels like a sunset". skins gives Claude a design tool, so it builds the skin and applies it while you watch. Repainting a built-in skin saves it as your own my-<skin>. Choices are remembered across sessions.
Drawn by the mod's own card code (scripts/previews.ts), not screenshots.
<img alt="A Claude Code turn with the noir skin: tool rows with icons, an edit as a diff card, and a table card" src="docs/previews/hero-dark.svg">
<img alt="The four spinner animations: thinking, running a tool, writing, waiting" src="docs/previews/spinners-dark.svg">
<img alt="Shell output in a terminal card" src="docs/previews/terminal-dark.svg">
<img alt="A code block as a card with line numbers" src="docs/previews/code-dark.svg">
<img alt="Context and plan limit rings" src="docs/previews/usage-dark.svg">
| Part | Desktop app | Terminal |
|---|---|---|
| Tool calls | Icon per kind, spinner while running, lines changed, time taken. ▸ opens the row: the call's input in full and its answer (a read's text with line numbers, a search's matches, the shell card, the diff); a failed call opens by itself | A node on the turn's rail, with the same ▸ |
| Edits | Diff card with +N −M and numbered lines | Claude Code's own diff |
| Shell commands | Terminal card with status, stderr apart, long output folded; paths, numbers and error, warning and pass words coloured | Card with the command, the output coloured the same way, ✓ or ✗ exit N, stderr in red under its own label, each stream folded to 8 + 4 lines (/skin shell off for Claude Code's own) |
| Quiet output (off by default) | Reads, searches and read-only commands fold to one row; a failure keeps its error line | The same |
| Tables | Card with header rule, zebra rows, colour swatches | Cell grid with a header band |
| Charts | `mermaid as a card: flowchart, xy, pie, gantt, timeline, journey, kanban, mindmap, quadrant, radar, sankey, gitGraph, treemap, packet, block, architecture, C4 | The same seventeen drawn in cells, ASCII with /skin icons ascii |
| Math | $$…$$, \[…\] and `math as a typeset card: fractions, roots, sums and integrals with limits, matrices, aligned lines; $…$ inline turns into Unicode | Fractions and limits stacked in cells, inline $…$ as Unicode |
| Command output | /cost, /context and plugin output: tables, code and key: value runs as cards | The same as cell grids |
| Alerts and task lists | > [!NOTE] as a titled box, - [ ] with ticks and a done count | The same |
| Code blocks | Card with language, line numbers, highlighting; shell blocks keep the Run button | Framed, the language on the frame, highlighted in the skin's colours: TS/JS, JSON, shells, PowerShell, Python, Rust, Go, C/C++, C#, 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 1235 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderSurface, Timer } from 'claude-code'
3
4import type { CustomSkin, Disclosure, Prefs, SkinSlot, SkinsMarkdownArgs, Touch, TurnStats, UsageSnap } from '../types'
5import { DEFAULT_PREFS, nextTables, parsePrefs, runSkinCommand, TOGGLES, withCalm } from './command'
6import { buildCustom, resolveSkin, skinNames, withSlot } from './custom'
7import { forTheme, gnomeDark, macDark, resolveLight, windowsDark } from './light'
8import { parseFolders, prefsFor, withFolder, withoutFolder } from './folders'
9import { DESIGN_TOOL, runDesign } from './designer'
10import type { DesignState } from './designer'
11import { clipLines, diffstat, pick } from './format'
12import { bodyOf, inputFields, isOpen } from './detail'
13import { disclosedRow, openedRows } from './detail-rows'
14import { copyOf } from './markdown'
15import { CHART_HINT } from './mermaid'
16import { commandSegments } from './command-output'
17import { askBand, desktopSpinnerRow, diffCard, footerRow, terminalCard, usageBand, groupRow, promptRow, quietResult, REPLY_FRAME_COLUMNS, replyFrame, replyRows, toolRow, KIND_GLYPH } 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 { diffRows } from './diff-fence'
23import { settingsPane } from './settings'
24import { sightings } from './sightings'
25import { ICONS } from './skin'
26import type { Skin } from './skin'
27import { hunksOf } from './svg-diff'
28import { shellOutputOf } from './svg-terminal'
29import { countdownsOf, limitLabel, metersOf } from './svg-usage'
30import { shortenPath } from './format'
31import { kindOf, summarize, toolLabel } from './tools'
32import { commandOf, errorLine, isQuiet, quietLabel } from './quiet'
33import { MAX_MARKDOWN, replySegments } from './reply'
34import { isDue, isNewer, LATEST_URL, STAR_NOTICE, starStep, updateNotice, versionOf } from './updates'
35import { hyperlinksFrom, touchOf } from './links'
36import type { Checked, Star } from './updates'
37
38const SETTINGS = 'skins-settings'
39const GALLERY = 'skins-gallery'
40const DESIGN = `mcp__skins__${DESIGN_TOOL.name}`
41const DESIGN_MATCH = /^mcp__skins__design$/
42const DESIGN_TOOL_WAIT_MS = 1000
43
44// A longer prompt keeps Claude Code's own drawing, which folds a big paste.
45const MAX_PROMPT = 4000
46
47// The settings pane's prompt button cycles through these.
48const PROMPT_NEXT: Readonly<Record<Prefs['prompt'], Prefs['prompt']>> = { left: 'right', right: 'plain', plain: 'left' }
49
50const CLIP_HEAD = 8
51const CLIP_TAIL = 4
52const FRAME_MS = 90
53// Countdowns read to the minute, so they are looked at once a minute.
54const COUNTDOWN_MS = 60_000
55const SETTLE_LEAD_MS = FRAME_MS
56const FAST_RETRIES = 4
57const RETRY_MAX_MS = 1000
58const STALE_AHEAD_MS = RETRY_MAX_MS + 1000
59const KEPT_TURNS = 40
60// How often an `auto` theme asks the system again: nothing tells a plugin it changed.
61const APPEARANCE_MS = 5000
62
63const prefsAtom = atom({ plugin: 'skins', key: 'prefs' } as const, DEFAULT_PREFS)
64const customAtom = atom({ plugin: 'skins', key: 'custom' } as const, {})
65const startedAtom = atom({ plugin: 'skins', key: 'startedAt' } as const, 0)
66const mainTurnAtom = atom({ plugin: 'skins', key: 'mainTurnActive' } as const, false)
67const turnsAtom = atom({ plugin: 'skins', key: 'turns' } as const, {})
68const durationAtom = atom({ plugin: 'skins', key: 'duration' } as const, -1)
69const editingAtom = atom({ plugin: 'skins', key: 'editing' } as const, 'user' as SkinSlot)
70const lightAtom = atom({ plugin: 'skins', key: 'isLight' } as const, false)
71const imagesAtom = atom({ plugin: 'skins', key: 'images' } as const, false)
72// Whether a call only looked, so quiet output folds its result away.
73const quietAtom = atom({ plugin: 'skins', key: 'quiet' } as const, false)
74const commandAtom = atom({ plugin: 'skins', key: 'command' } as const, '')
75const usageAtom = atom({ plugin: 'skins', key: 'usage' } as const, { context: null, limits: [] } as UsageSnap)
76// The band's countdowns as last drawn; it changes only when one of them reads differently.
77const countdownsAtom = atom({ plugin: 'skins', key: 'countdowns' } as const, {} as Record<string, string>)
78const compactingAtom = atom({ plugin: 'skins', key: 'compacting' } as const, false)
79const pinnedAtom = atom({ plugin: 'skins', key: 'pinned' } as const, false)
80const settleAtom = atom({ plugin: 'skins', key: 'settle' } as const, 0)
81// Whether each tool row is open, as the person left it; `auto` opens a failed call.
82const disclosureAtom = atom({ plugin: 'skins', key: 'disclosure' } as const, 'auto' as Disclosure)
83// Whether an opened row shows its answer whole, past the fold.
84const allAtom = atom({ plugin: 'skins', key: 'showAll' } as const, false)
85// Files this turn created or edited, by absolute path.
86const touchedAtom = atom({ plugin: 'skins', key: 'touched' } as const, {} as Record<string, Touch>)
87// Per drawing, the blocks the person unfolded.
88const foldsAtom = atom({ plugin: 'skins', key: 'folds' } as const, [] as string[])
89// The main loop's last answer, for `/skin copy`.
90const lastReplyAtom = atom({ plugin: 'skins', key: 'lastReply' } as const, '')
91
92const EDITS = new Set(['Edit', 'MultiEdit', 'Write'])
93const WRITES = new Set([...EDITS, 'NotebookEdit'])
94
95// Only a person's own typing becomes a prompt row: a task notification or a peer's
96// message is not theirs to dress as theirs.
97const TYPED = new Set(['composer', 'bridge', 'sdk'])
98
99type Active = { prefs: Prefs; skin: Skin; custom: Record<string, CustomSkin> }
100
101const NO_STATS: TurnStats = { tools: 0, added: 0, removed: 0 }
102
103async function activeSkin($: EngineInterface): Promise<Active | null> {
104 const prefs = await read($, prefsAtom)
105 const custom = await read($, customAtom)
106 const skin = resolveSkin(prefs.skin, custom)
107
108 return skin === undefined ? null : { prefs, skin: forTheme(skin, await read($, lightAtom)), custom }
109}
110
111// Where each system keeps its appearance, and how to read the answer.
112const PROBES = [
113 { argv: ['defaults', 'read', '-g', 'AppleInterfaceStyle'], dark: macDark },
114 {
115 argv: ['reg', 'query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize', '/v', 'AppsUseLightTheme'],
116 dark: windowsDark,
117 },
118 { argv: ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], dark: gnomeDark },
119] as const
120
121// Read once a session or a turn and kept here: each read through `$` is a dispatch, and
122// every row draws often. Whether the terminal draws OSC 8 links, the session's folder, and
123// whether this turn has touched a file at all (the list itself is `touchedAtom`).
124let hyperlinks = false
125let sessionCwd: string | undefined
126let hasTouched = false
127
128const cwdOf = async ($: EngineInterface): Promise<string> => sessionCwd ?? (await $.session.cwd())
129
130// The probe that answered last, so the timer asks one system rather than all three.
131let answering: (typeof PROBES)[number] | undefined
132
133// The system's appearance, for an `auto` theme. Undefined when no probe answers.
134async function systemDark($: EngineInterface): Promise<boolean | undefined> {
135 for (const probe of answering === undefined ? PROBES : [answering, ...PROBES]) {
136 try {
137 const dark = probe.dark(await $.process.run([...probe.argv], { timeoutMs: 2000 }))
138
139 if (dark !== undefined) {
140 answering = probe
141 return dark
142 }
143 } catch {
144 // The other systems' tools are missing here; that is expected, not a failure.
145 }
146 }
147
148 answering = undefined
149 return undefined
150}
151
152// Claude Code's own theme decides whether skins draw for a light or a dark background:
153// `SKINS_THEME` first, then the theme setting, and for `auto` the terminal and the system.
154// `theme` is a value just written, which `$.config.list()` does not answer with yet.
155// Resolves true when the answer came from the system's appearance, which can change under it.
156async function refreshTheme($: EngineInterface, theme?: unknown): Promise<boolean> {
157 const hints = {
158 override: await $.env.get('SKINS_THEME'),
159 theme: theme ?? (await $.config.list()).find(row => row.key === 'theme')?.value,
160 colorfgbg: await $.env.get('COLORFGBG'),
161 }
162 // Asks the system only when nothing before it decided.
163 const needsSystem = resolveLight(hints) !== resolveLight({ ...hints, systemDark: false })
164 const dark = needsSystem ? await systemDark($) : undefined
165 const isLight = resolveLight({ ...hints, systemDark: dark })
166
167 // A write redraws every row, and the system is asked again every few seconds.
168 if ((await read($, lightAtom)) !== isLight) {
169 await update($, lightAtom, () => isLight)
170 }
171
172 return dark !== undefined
173}
174
175// Asks the system again on a timer while the theme follows it; any other theme stops it.
176let appearance: Timer | undefined
177
178async function followTheme($: EngineInterface, theme?: unknown): Promise<void> {
179 const followsSystem = await refreshTheme($, theme)
180
181 appearance?.cancel()
182 appearance = followsSystem
183 ? $.clock.every(APPEARANCE_MS, () => {
184 void refreshTheme($).catch((error: unknown) => {
185 $.ui.log(error instanceof Error ? error.message : String(error), { to: 'debug' })
186 })
187 })
188 : undefined
189}
190
191// Every surface's element table names Svg, but the terminal draws it as nothing, so
192// vector icons are for the other surfaces only.
193const lookOf = (
194 ui: Ui & { Svg?: SvgElement },
195 active: Active,
196 surface: RenderSurface,
197 copy?: (text: string) => void,
198): Look => ({
199 ui,
200 skin: active.skin,
201 icons: ICONS[active.prefs.icons],
202 prefs: active.prefs,
203 surface,
204 ...(surface !== 'terminal' && ui.Svg !== undefined ? { svg: ui.Svg } : {}),
205 // `/skin copy off` takes every copy button away.
206 ...(copy === undefined || !active.prefs.copy ? {} : { copy }),
207})
208
209// What a drawing adds to its look from the session: links where they can be drawn, this
210// turn's files, and the blocks the person unfolded in it. Each is a read on every draw, so
211// a drawing asks only for what it can show.
212type Needs = { links?: boolean; touched?: boolean; folds?: boolean; cwd?: string }
213
214async function sessionLook($: EngineInterface, e: { requestId: string; surface: RenderSurface }, look: Look, needs: Needs): Promise<Look> {
215 const folds = memberOf(foldsAtom, e)
216 const links = needs.links === true && look.prefs.links && (e.surface !== 'terminal' || hyperlinks)
217 const touched = needs.touched === true && hasTouched ? { files: await read($, touchedAtom), cwd: needs.cwd ?? (await cwdOf($)) } : undefined
218 const open = needs.folds === true && look.prefs.fold ? new Set(await read($, folds)) : undefined
219
220 return {
221 ...look,
222 links,
223 ...(touched === undefined ? {} : { touched }),
224 ...(open === undefined
225 ? {}
226 : {
227 folds: {
228 open,
229 toggle: (key: string) =>
230 void update($, folds, keys => (keys.includes(key) ? keys.filter(kept => kept !== key) : [...keys, key])),
231 },
232 }),
233 }
234}
235
236// What a reply's blocks can show: links where it has a URL, file colours where it names a
237// path, fold controls where it has code or a table.
238const replyNeeds = (text: string, segments: readonly { kind: string }[]): Needs => ({
239 links: /https?:\/\//.test(text),
240 touched: /[\w.-]\/[\w.-]|\w\.[A-Za-z]{1,5}\b/.test(text),
241 folds: segments.some(segment => segment.kind === 'code' || segment.kind === 'table'),
242})
243
244// Whether the terminal draws OSC 8 links, from the environment it was started in.
245async function readHyperlinks($: EngineInterface): Promise<void> {
246 // Each name spelled out: the engine lists the variables a module reads from its source.
247 const supported = hyperlinksFrom({
248 FORCE_HYPERLINK: await $.env.get('FORCE_HYPERLINK'),
249 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
250 TERM_PROGRAM_VERSION: await $.env.get('TERM_PROGRAM_VERSION'),
251 TERM: await $.env.get('TERM'),
252 WT_SESSION: await $.env.get('WT_SESSION'),
253 VTE_VERSION: await $.env.get('VTE_VERSION'),
254 KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
255 TMUX: await $.env.get('TMUX'),
256 CI: await $.env.get('CI'),
257 })
258
259 hyperlinks = supported
260}
261
262// A drawing's cards animate on their first draw only, keyed by the drawing's instance
263// (the message id, the tool_use_id) so two replies never share a card.
264const seenCards = sightings()
265const settling = new Map<string, { at: number; cards: string[] }>()
266let settlingNow = new Set<string>()
267let refusals = 0
268
269const drawingOf = (e: { surface: RenderSurface; requestId: string }): string => `${e.surface}:${e.requestId}`
270
271const drawnBy = (drawing: string, drawnAt: number) => (key: string, entranceMs: number) => {
272 const card = `${drawing}:${key}`
273
274 if (settlingNow.delete(card)) {
275 seenCards(card)
276
277 return false
278 }
279
280 const isFirst = seenCards(card)
281
282 if (isFirst) {
283 const pending = settling.get(drawing)
284
285 settling.set(drawing, {
286 at: Math.max(pending?.at ?? 0, drawnAt + entranceMs - SETTLE_LEAD_MS),
287 cards: [...(pending?.cards ?? []), card],
288 })
289 }
290
291 return isFirst
292}
293
294// Calm draws every card settled, its first draw included.
295async function firstDraws($: EngineInterface, e: { surface: RenderSurface; requestId: string }, prefs: Prefs): Promise<(key: string, entranceMs: number) => boolean> {
296 const drawing = drawingOf(e)
297 settling.delete(drawing)
298
299 return prefs.calm === null ? drawnBy(drawing, await $.clock.now()) : () => false
300}
301
302async function withSettle<T>($: EngineInterface, e: { surface: RenderSurface; requestId: string }, drawn: T): Promise<T> {
303 if (settling.has(drawingOf(e))) {
304 await read($, settleAtom)
305 }
306
307 return drawn
308}
309
310async function settleEntrances($: EngineInterface, isForced = false): Promise<boolean> {
311 const now = await $.clock.now()
312 const isDue = (at: number): boolean => at <= now || at > now + STALE_AHEAD_MS
313
314 if (settling.size === 0 || (!isForced && ![...settling.values()].some(pending => isDue(pending.at)))) {
315 return true
316 }
317
318 const moved = [...settling]
319 settlingNow = new Set(moved.flatMap(([, pending]) => pending.cards))
320 settling.clear()
321
322 return update($, settleAtom, round => round + 1).then(
323 () => {
324 refusals = 0
325
326 return true
327 },
328 (error: unknown) => {
329 refusals += 1
330 const retryAt = now + Math.min(RETRY_MAX_MS, refusals <= FAST_RETRIES ? 0 : FRAME_MS * 2 ** (refusals - FAST_RETRIES))
331
332 for (const [drawing, pending] of moved) {
333 if (!settling.has(drawing)) {
334 settling.set(drawing, { ...pending, at: retryAt })
335 }
336 }
337
338 $.ui.log(`settle: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
339
340 return false
341 },
342 )
343}
344
345// Made skins from the store, each checked again: the store may hold an older shape.
346function parseCustom(raw: unknown): Record<string, CustomSkin> {
347 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
348
349 return Object.fromEntries(
350 Object.values(saved)
351 .map(draft => buildCustom(draft as Record<string, unknown>, undefined))
352 .filter((skin): skin is CustomSkin => typeof skin !== 'string')
353 .map(skin => [skin.name, skin]),
354 )
355}
356
357async function load($: EngineInterface): Promise<void> {
358 const custom = parseCustom(await $.store.get('custom'))
359 const names = skinNames(custom)
360 const folders = parseFolders(await $.store.get('folders'), names)
361 const folder = await $.session.cwd()
362 sessionCwd = folder
363 const prefs = prefsFor(folder, folders, parsePrefs(await $.store.get('prefs'), names))
364
365 await update($, customAtom, () => custom)
366 await update($, prefsAtom, () => prefs)
367 await update($, pinnedAtom, () => Object.hasOwn(folders, folder))
368 // Links stay text if the environment cannot be read; the look loads either way.
369 await readHyperlinks($).catch((error: unknown) => {
370 $.ui.log(`hyperlink check: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
371 })
372}
373
374// The session the prefs were last loaded for. /clear, /resume and /branch reset $.state to its
375// defaults and skip session.start, and on a Team or Enterprise plan the built-in guard
376// (cc-plugin-sec-default) keeps classic.SessionStart from user-tier mods: the new session's first
377// appended rows catch the change instead. No await between the check and the write, so a burst of
378// appends loads once.
379let loadedFor: string | undefined
380
381// Undefined where nothing answers for the session's id (a test's engine): then there is nothing to compare.
382const sessionId = ($: EngineInterface): Promise<string | undefined> => $.session.id().catch(() => undefined)
383
384async function reloadIfNewSession($: EngineInterface): Promise<void> {
385 const id = await sessionId($)
386
387 if (id === undefined || id === loadedFor) {
388 return
389 }
390
391 loadedFor = id
392 await load($)
393 await followTheme($)
394}
395
396// A pinned folder keeps its prefs to itself; every other folder shares the default.
397async function savePrefs($: EngineInterface, prefs: Prefs): Promise<void> {
398 if (!(await read($, pinnedAtom))) {
399 await $.store.set('prefs', prefs)
400
401 return
402 }
403
404 const names = skinNames(await read($, customAtom))
405 const folders = parseFolders(await $.store.get('folders'), names)
406 await $.store.set('folders', withFolder(folders, await $.session.cwd(), prefs))
407}
408
409// /skin pin, unpin and share: this folder's own look, or the default.
410async function runFolderCommand($: EngineInterface, word: string): Promise<string> {
411 const folder = await $.session.cwd()
412 const custom = await read($, customAtom)
413 const names = skinNames(custom)
414 const folders = parseFolders(await $.store.get('folders'), names)
415 const prefs = await read($, prefsAtom)
416
417 switch (word) {
418 case 'pin':
419 await $.store.set('folders', withFolder(folders, folder, prefs))
420 await update($, pinnedAtom, () => true)
421
422 return 'this folder keeps its own look'
423 case 'unpin': {
424 const fallback = parsePrefs(await $.store.get('prefs'), names)
425 await $.store.set('folders', withoutFolder(folders, folder))
426 await update($, pinnedAtom, () => false)
427 await update($, prefsAtom, () => fallback)
428
429 return `this folder follows the default: ${fallback.skin}`
430 }
431 default:
432 await $.store.set('prefs', prefs)
433
434 return `default look: ${prefs.skin}`
435 }
436}
437
438async function refreshUsage($: EngineInterface): Promise<void> {
439 const usage = await $.session.usage()
440 const snap: UsageSnap = {
441 context: usage.context.percent ?? null,
442 limits: usage.rateLimits.map(limit => {
443 const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
444
445 return { label: limitLabel(limit.kind), percent: limit.percentUsed, ...(Number.isNaN(resetsAt) ? {} : { resetsAt }) }
446 }),
447 }
448 // A window that resets moves its reset time even where its percent stays the same.
449 const drawn = (of: UsageSnap) => JSON.stringify([metersOf(of), of.limits.map(limit => limit.resetsAt ?? null)])
450
451 if (drawn(await read($, usageAtom)) === drawn(snap)) {
452 return
453 }
454
455 await settleEntrances($, true)
456 await update($, usageAtom, () => snap)
457}
458
459// Redraws the band when a countdown it shows would read differently, and not otherwise:
460// a redraw settles the cards still entering first, as a usage change does.
461async function tickCountdowns($: EngineInterface): Promise<void> {
462 const prefs = await read($, prefsAtom)
463
464 if (!prefs.countdown || !prefs.band) {
465 return
466 }
467
468 const next = countdownsOf(await read($, usageAtom), await $.clock.now())
469
470 if (JSON.stringify(next) === JSON.stringify(await read($, countdownsAtom))) {
471 return
472 }
473
474 await settleEntrances($, true)
475 await update($, countdownsAtom, () => next)
476}
477
478// True when it showed the update notice, so the star ask can keep out of its way.
479async function checkForUpdate($: EngineInterface): Promise<boolean> {
480 const now = await $.clock.now()
481
482 if (!isDue(await $.store.get('updateCheck'), now)) {
483 return false
484 }
485
486 // Stamped before the fetch, so an offline machine is not asked again every session.
487 await $.store.set('updateCheck', { at: now } satisfies Checked)
488
489 const current = versionOf(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
490 const response = await $.http.fetch(LATEST_URL)
491
492 if (!response.ok) {
493 throw new Error(`update check: ${LATEST_URL} answered ${response.status}`)
494 }
495
496 const latest = versionOf(response.text)
497
498 if (current !== undefined && latest !== undefined && isNewer(latest, current)) {
499 $.ui.toast(updateNotice(latest, current), { timeoutMs: 12_000 })
500 return true
501 }
502
503 return false
504}
505
506async function askForStar($: EngineInterface): Promise<void> {
507 const step = starStep(await $.store.get('star'), await $.clock.now())
508
509 if (step.save !== undefined) {
510 await $.store.set('star', step.save satisfies Star)
511 }
512
513 if (step.ask) {
514 $.ui.toast(STAR_NOTICE, { timeoutMs: 12_000 })
515 }
516}
517
518async function commit($: EngineInterface, state: DesignState): Promise<void> {
519 await update($, customAtom, () => state.custom)
520 await update($, prefsAtom, () => state.prefs)
521 await $.store.set('custom', state.custom)
522 await savePrefs($, state.prefs)
523}
524
525async function designState($: EngineInterface): Promise<DesignState> {
526 return { prefs: await read($, prefsAtom), custom: await read($, customAtom) }
527}
528
529const SURFACES: readonly RenderSurface[] = ['terminal', 'desktop', 'vscode', 'mobile']
530
531// `$.skins.markdown`: markdown drawn the way the skin draws a reply, for another mod's
532// drawing. Undefined when the skin is off or the text holds nothing it draws. No copy or
533// fold controls: a press cannot cross from one mod's tree to another's.
534async function drawMarkdown($: EngineInterface, args: SkinsMarkdownArgs): Promise<RenderElement | undefined> {
535 if (!SURFACES.includes(args.surface) || typeof args.text !== 'string' || !Number.isFinite(args.columns)) {
536 throw new Error('skins.markdown takes { surface, text, columns }: a surface name, a string and a number')
537 }
538
539 const active = await activeSkin($)
540 const segments = active === null || args.text.length > MAX_MARKDOWN * 4 ? null : replySegments(args.text, active.prefs, args.surface)
541
542 if (active === null || segments === null) {
543 return undefined
544 }
545
546 const ui = $.ui.resolve({ surface: args.surface, component: 'AssistantMessage' })
547 const svg = args.surface !== 'terminal' && active.prefs.tables === 'on' && 'Svg' in ui ? ui.Svg : undefined
548 const links = active.prefs.links && (args.surface !== 'terminal' || hyperlinks)
549
550 return replyRows({ ...lookOf(ui, active, args.surface), links, isFirstDraw: () => false }, segments, Math.max(20, Math.floor(args.columns)), svg)
551}
552
553// Fork: the model that labels a reply. Each assistant row carries the model that wrote it
554// (session.append's origin), found again by the row's uuid, which is the reply's render id: a
555// subagent's reply keeps its own model. Anything unknown falls back to the session's current
556// model, so a reply drawn again after `/model` carries the new name.
557const messageModels = new Map<string, string>()
558const KEPT = 1000
559
560async function modelName($: EngineInterface, messageId = ''): Promise<string> {
561 const own = messageModels.get(messageId)
562
563 if (own !== undefined) {
564 return own
565 }
566
567 try {
568 return await $.session.model()
569 } catch {
570 return ''
571 }
572}
573
574// What reaches the model about tables, sent beside the chart hint.
575const TABLE_HINT = [
576 '# Tables',
577 'When a table fits the answer, keep it compact: every cell is a short summary of that line, a few words, never a sentence.',
578 'Use at most 5 columns. Put any needed detail in one line of prose under the table, not inside a cell.',
579].join(' ')
580
581export const register: Register = on => {
582 // Adds `$.skins` for other mods. A call is the `skins.markdown` event, which the hook
583 // below answers with this session's `$`; the method itself is the chain's floor.
584 on('engine.create', async ($, e, next) => {
585 const built = await next(e)
586
587 return { ...built, skins: { markdown: async () => undefined } }
588 })
589
590 on('skins.markdown', async ($, e) => ({ value: await drawMarkdown($, e) }))
591
592 // What the turn on the main loop has done so far, for its footer.
593 let stats: TurnStats = NO_STATS
594 let ticker: Timer | undefined
595 let countdownTicker: Timer | undefined
596 // The width the last reply was drawn at, shown by /skin list to tune table sizing.
597 let lastColumns: number | undefined
598
599 on('session.start', async ($, e, next) => {
600 await $.command.register({
601 name: 'skin',
602 description: 'Open the skin settings, or /skin <name | list | off>',
603 argumentHint: '[gallery | copy | copy code | name | list | off | calm | pin | unpin | share | rail | tables | shimmer | band | countdown | clip | markdown | quiet | charts | math | commands | shell | highlight | hints | links | fold | icons | prompt | words | label]',
604 immediate: true,
605 })
606 const designTool = $.tool
607 .register({
608 name: DESIGN_TOOL.name,
609 description: DESIGN_TOOL.description,
610 inputSchema: DESIGN_TOOL.inputSchema,
611 })
612 .catch((error: unknown) => {
613 $.ui.log(`design tool: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
614 })
615 await new Promise<void>(resolve => {
616 const cap = $.clock.after(DESIGN_TOOL_WAIT_MS, resolve)
617 void designTool.then(() => {
618 cap.cancel()
619 resolve()
620 })
621 })
622 loadedFor = await sessionId($)
623 await load($)
624 await refreshUsage($)
625 await followTheme($)
626
627 // Fork: upstream's update check and star ask removed.
628
629 // Settles first-draw cards; spinner frames run on their visible Client.
630 ticker?.cancel()
631 ticker = $.clock.every(FRAME_MS, () => {
632 if (settling.size > 0) {
633 void settleEntrances($)
634 }
635 })
636 countdownTicker?.cancel()
637 countdownTicker = $.clock.every(COUNTDOWN_MS, () => {
638 void tickCountdowns($).catch((error: unknown) => {
639 $.ui.log(`countdown tick: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
640 })
641 })
642
643 return next(e)
644 })
645
646 // Where the guard does not hold it back, this comes first; session.append then finds the session loaded.
647 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
648 await reloadIfNewSession($)
649
650 return next(e)
651 })
652
653 on('ui.invalidate', async ($, e, next) => {
654 if (e.event === 'ui.render' && !(await settleEntrances($, true)) && refusals <= FAST_RETRIES) {
655 await $.clock.sleep(FRAME_MS / 3)
656 await settleEntrances($, true)
657 }
658
659 return next(e)
660 })
661
662 on('turn.start', async ($, e, next) => {
663 stats = NO_STATS
664 // A worktree can move the session between turns.
665 sessionCwd = await $.session.cwd()
666 hasTouched = false
667 await update($, touchedAtom, () => ({}))
668 if (e.agentId === undefined) {
669 const now = await $.clock.now()
670 await update($, startedAtom, () => now)
671 await update($, mainTurnAtom, () => true)
672 }
673
674 return next(e)
675 })
676
677 on('config.set', async ($, e, next) => {
678 const result = await next(e)
679
680 // The written value: inside this hook $.config.list() still answers with the old one.
681 if (e.key === 'theme' && result.deny === undefined) {
682 await followTheme($, result.value)
683 }
684
685 return result
686 })
687
688 // Loads the prefs again for a new session (see loadedFor), and notes which prompts carried images,
689 // so their row keeps Claude Code's drawing of them.
690 on('session.append', async ($, e, next) => {
691 await reloadIfNewSession($)
692 const stored = await next(e)
693
694 if (e.origin.kind === 'model') {
695 messageModels.set(stored.uuid ?? e.uuid, e.origin.model)
696
697 if (messageModels.size > KEPT) {
698 messageModels.delete(messageModels.keys().next().value as string)
699 }
700 }
701
702 const hasImage = e.door === 'prompt' && e.message.content.some(block => block.type === 'image')
703
704 if (hasImage && stored.uuid !== undefined) {
705 await update($, memberOf(imagesAtom, { requestId: stored.uuid }), () => true)
706 }
707
708 return stored
709 })
710
711 // A turn's end and a plan limit's move are when the band's numbers change.
712 on('session.measure', async ($, e, next) => {
713 await refreshUsage($)
714
715 return next(e)
716 })
717
718 on('turn.complete', async ($, e, next) => {
719 if (e.agentId === undefined) {
720 await refreshUsage($)
721 await update($, startedAtom, () => 0)
722 await update($, mainTurnAtom, () => false)
723 const finished = stats
724 await update($, turnsAtom, turns =>
725 Object.fromEntries([...Object.entries(turns), [String(e.durationMs), finished]].slice(-KEPT_TURNS)),
726 )
727
728 if (e.answer.trim() !== '') {
729 await update($, lastReplyAtom, () => e.answer)
730 }
731 }
732
733 return next(e)
734 })
735
736 // Times every call and counts the main loop's calls and changed lines.
737 on('tool.call', async ($, e, next) => {
738 // Its output card's header names the command, which the result does not carry.
739 if (SHELLS.has(e.tool)) {
740 const command = commandOf(e)
741 await update($, memberOf(commandAtom, { requestId: e.tool_use_id }), () => command)
742 }
743
744 const startedAt = await $.clock.now()
745 const ran = await next(e)
746 const ms = (await $.clock.now()) - startedAt
747
748 await update($, memberOf(durationAtom, { requestId: e.tool_use_id }), () => ms)
749
750 // The call's arguments ride on the event itself (`e.command` for Bash).
751 if (isQuiet(e.tool, e)) {
752 await update($, memberOf(quietAtom, { requestId: e.tool_use_id }), () => true)
753 }
754
755 // A file the call made or changed takes its colour in this turn's rows and prose.
756 const file = (e as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path
757
758 if (WRITES.has(e.tool) && ran.deny === undefined && typeof file === 'string' && file !== '') {
759 const touch: Touch = (ran.result as { type?: unknown } | undefined)?.type === 'create' ? 'created' : 'edited'
760 hasTouched = true
761 await update($, touchedAtom, touched => (touched[file] === 'created' ? touched : { ...touched, [file]: touch }))
762 }
763
764 if (e.agentId === undefined && e.tool !== DESIGN && ran.deny === undefined) {
765 const diff = diffstat(ran.result)
766 stats = {
767 tools: stats.tools + 1,
768 added: stats.added + (diff?.added ?? 0),
769 removed: stats.removed + (diff?.removed ?? 0),
770 }
771 }
772
773 return ran
774 })
775
776 on('tool.call', { tool: DESIGN_MATCH }, async ($, e) => {
777 const outcome = runDesign(e, await designState($))
778
779 if (outcome.isError) {
780 return { deny: outcome.text }
781 }
782
783 await commit($, outcome.state)
784
785 return { result: outcome.text }
786 })
787
788 on('command.run', { command: 'skin' }, async ($, e) => {
789 if (e.args.trim() === '') {
790 await $.ui.open({ id: SETTINGS, title: 'Skins', focus: true, closeOnEscape: true })
791
792 return {}
793 }
794
795 if (e.args.trim().toLowerCase() === 'gallery') {
796 await $.ui.open({ id: GALLERY, title: 'Skin gallery', focus: true, closeOnEscape: true })
797
798 return {}
799 }
800
801 const word = e.args.trim().toLowerCase()
802
803 if (word === 'copy' || word === 'copy code') {
804 const copied = copyOf(await read($, lastReplyAtom), word === 'copy code')
805
806 if ('message' in copied) {
807 $.ui.toast(copied.message)
808 } else {
809 const result = await $.ui.copy({ text: copied.text })
810 $.ui.toast(result.isCopied ? (word === 'copy' ? 'Copied the reply' : 'Copied the code') : 'Could not copy here')
811 }
812
813 return {}
814 }
815
816 if (word === 'pin' || word === 'unpin' || word === 'share') {
817 $.ui.toast(await runFolderCommand($, word))
818
819 return {}
820 }
821
822 const current = await read($, prefsAtom)
823 const custom = await read($, customAtom)
824 const outcome = runSkinCommand(e.args, current, skinNames(custom))
825
826 if (outcome.prefs !== current) {
827 await commit($, { prefs: outcome.prefs, custom })
828 }
829
830 if (outcome.channel === 'row') {
831 const width = lastColumns === undefined ? '' : `
832reply width: ${lastColumns} columns`
833
834 const folder = (await read($, pinnedAtom)) ? 'this folder: pinned' : 'this folder: follows the default'
835
836 return { text: e.args.trim() === 'list' ? `${outcome.message}\n${folder}${width}` : outcome.message }
837 }
838
839 $.ui.toast(outcome.message)
840
841 return {}
842 })
843
844 on('ui.render', { component: 'Pane', requestId: SETTINGS }, async ($, e) => {
845 const prefs = await read($, prefsAtom)
846 const custom = await read($, customAtom)
847 const editing = await read($, editingAtom)
848 const skin = resolveSkin(prefs.skin, custom) ?? resolveSkin(DEFAULT_PREFS.skin, custom)
849 const ui = $.ui.resolve(e)
850
851 if (skin === undefined || e.surface === 'mobile' || !('Input' in ui)) {
852 return <ui.Text>Open the skin settings in the terminal or the desktop app.</ui.Text>
853 }
854
855 const look = lookOf(ui, { prefs, custom, skin }, e.surface)
856 const state = { prefs, custom }
857
858 return settingsPane(look, ui, { names: skinNames(custom), editing, width: e.props.bodyColumns }, {
859 pick: name => void commit($, { ...state, prefs: { ...prefs, skin: name } }),
860 toggle: word => void commit($, { ...state, prefs: { ...prefs, [TOGGLES[word]]: !prefs[TOGGLES[word]] } }),
861 calm: () => void commit($, { ...state, prefs: withCalm(prefs, prefs.calm === null) }),
862 tables: () => void commit($, { ...state, prefs: { ...prefs, tables: nextTables(prefs.tables) } }),
863 icons: () =>
864 void commit($, { ...state, prefs: { ...prefs, icons: prefs.icons === 'unicode' ? 'ascii' : 'unicode' } }),
865 prompt: () => void commit($, { ...state, prefs: { ...prefs, prompt: PROMPT_NEXT[prefs.prompt] } }),
866 words: () => void commit($, { ...state, prefs: { ...prefs, words: prefs.words === 'skin' ? 'claude' : 'skin' } }),
867 edit: slot => void update($, editingAtom, () => slot),
868 paint: hex => {
869 const made = withSlot(prefs.skin === 'off' ? DEFAULT_PREFS.skin : prefs.skin, custom, editing, hex)
870
871 if (typeof made === 'string') {
872 $.ui.toast(made)
873
874 return
875 }
876
877 void commit($, { prefs: { ...prefs, skin: made.name }, custom: { ...custom, [made.name]: made.skin } })
878 },
879 })
880 })
881
882 // Every element the skin draws, numbered, to point at when asking for a change.
883 on('ui.render', { component: 'Pane', requestId: GALLERY }, async ($, e) => {
884 const active = await activeSkin($)
885 const ui = $.ui.resolve(e)
886
887 if (active === null) {
888 return <ui.Text>The gallery shows a skin. Pick one with /skin first.</ui.Text>
889 }
890
891 const look = lookOf(ui, active, e.surface)
892
893 return withSettle($, e, galleryPane({ ...look, ...(look.svg === undefined ? {} : { isFirstDraw: await firstDraws($, e, active.prefs) }) }, e.props.bodyColumns))
894 })
895
896 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
897 const kind = kindOf(e.props.tool)
898 const active = await activeSkin($)
899
900 if (kind === null || active === null) {
901 return next(e)
902 }
903
904 const cwd = await cwdOf($)
905 const ms = e.props.isRunning ? -1 : await read($, memberOf(durationAtom, e))
906 const diff = diffstat(e.props.output)
907 const target = summarize(e.props.tool, e.props.input, cwd)
908
909 const meta = {
910 ...(ms >= 0 && !e.props.isRunning ? { ms } : {}),
911 ...(diff === null ? {} : diff),
912 }
913 const copy = (text: string) => {
914 void $.ui.copy({ text, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
915 }
916 const fields = (e.props.input ?? {}) as { file_path?: unknown; notebook_path?: unknown }
917 const path = fields.file_path ?? fields.notebook_path
918 // The chevron opens the row onto the call's input and its answer.
919 const disclosure = memberOf(disclosureAtom, e)
920 const open = isOpen(await read($, disclosure), e.props.isErrored)
921 const base = await sessionLook($, e, lookOf($.ui.resolve(e), active, e.surface, copy), {
922 touched: typeof path === 'string',
923 folds: open,
924 cwd,
925 })
926 const look = base.svg === undefined ? base : { ...base, isFirstDraw: await firstDraws($, e, active.prefs) }
927 // A file this turn made or changed keeps its colour in every row that names it.
928 const touch = typeof path === 'string' && look.touched !== undefined ? touchOf(look.touched.files, path, cwd) : undefined
929 const label = active.prefs.quiet && isQuiet(e.props.tool, e.props.input) ? quietLabel(e.props.tool) : toolLabel(e.props.tool)
930 const row = toolRow(look, e.props, kind, target, meta, label, touch)
931 const toggle = () => void update($, disclosure, () => (open ? 'closed' : 'open'))
932
933 if (!open) {
934 return withSettle($, e, disclosedRow(look, row, false, toggle))
935 }
936
937 const all = memberOf(allAtom, e)
938 const isAll = await read($, all)
939 const opened = await openedRows(look, {
940 fields: inputFields(e.props.tool, e.props.input, cwd),
941 body: bodyOf(e.props.tool, e.props.output, e.props.isErrored, e.props.isRunning, cwd),
942 command: commandOf(e.props.input),
943 isErrored: e.props.isErrored,
944 cwd,
945 columns: e.viewport?.columns ?? 100,
946 isAll,
947 toggleAll: () => void update($, all, () => !isAll),
948 stock: () => next(e),
949 })
950
951 return withSettle($, e, disclosedRow(look, row, true, toggle, opened))
952 })
953
954 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
955 const active = await activeSkin($)
956
957 if (active === null || e.props.isExpanded) {
958 return next(e)
959 }
960
961 return groupRow(lookOf($.ui.resolve(e), active, e.surface), e.props.calls)
962 })
963
964 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
965 const active = await activeSkin($)
966 const output = e.props.output as { stdout?: unknown } | null
967 const copy = (text: string) => {
968 void $.ui.copy({ text, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
969 }
970 // Only the terminal's shell and edit cards have a fold control here.
971 const needs = { folds: e.surface === 'terminal' && (SHELLS.has(e.props.tool) || EDITS.has(e.props.tool)) }
972 const look = active === null ? undefined : await sessionLook($, e, lookOf($.ui.resolve(e), active, e.surface, copy), needs)
973 const columns = e.viewport?.columns ?? 100
974 // An opened row draws the answer itself; one it cannot draw keeps Claude Code's here.
975 const isOpened =
976 active !== null &&
977 kindOf(e.props.tool) !== null &&
978 isOpen(await read($, memberOf(disclosureAtom, e)), e.props.isErrored) &&
979 bodyOf(e.props.tool, e.props.output, e.props.isErrored, false, '').kind !== 'stock'
980
981 if (isOpened) {
982 const { Box } = $.ui.resolve(e)
983
984 return <Box />
985 }
986
987 // Calm never folds a failure away: it is drawn whole, past quiet and the clip.
988 const isCalmFailure = look !== undefined && look.prefs.calm !== null && e.props.isErrored
989
990 // A call that only looked folds away; a failure keeps the line that says why.
991 if (look !== undefined && look.prefs.quiet && !isCalmFailure && (await read($, memberOf(quietAtom, e)))) {
992 return quietResult(look, e.props.isErrored ? errorLine(e.props.output) : null)
993 }
994
995 // The desktop gets cards: a diff for an edit, a terminal for a shell command.
996 if (look?.svg !== undefined && EDITS.has(e.props.tool) && !e.props.isErrored) {
997 const diff = hunksOf(e.props.output)
998
999 if (diff !== null) {
1000 const shown = shortenPath(diff.path, await $.session.cwd())
1001
1002 return withSettle($, e, diffCard({ ...look, isFirstDraw: await firstDraws($, e, look.prefs) }, look.svg, diff, shown, columns))
1003 }
1004 }
1005
1006 // The terminal's edit card: the path and its counts over numbered rows. `/skin shell off`
1007 // gives it back to Claude Code with the shell card.
1008 if (look !== undefined && e.surface === 'terminal' && look.prefs.shell && EDITS.has(e.props.tool) && !e.props.isErrored) {
1009 const diff = hunksOf(e.props.output)
1010
1011 if (diff !== null) {
1012 const glyph = look.prefs.icons === 'ascii' ? '' : `${KIND_GLYPH.write} `
1013
1014 return diffRows(look, diff, undefined, OUTPUT_FOLD, undefined, { glyph, path: shortenPath(diff.path, await $.session.cwd()) })
1015 }
1016 }
1017
1018 const isShellCard = look !== undefined && look.prefs.shell && SHELLS.has(e.props.tool) && !keepsOwnRow(e.props.output)
1019
1020 if (look?.svg !== undefined && isShellCard) {
1021 const shell = shellOutputOf(e.props.output)
1022
1023 if (shell !== null) {
1024 return withSettle($, e, terminalCard({ ...look, isFirstDraw: await firstDraws($, e, look.prefs) }, look.svg, shell, e.props.isErrored, columns))
1025 }
1026 }
1027
1028 // The terminal's card is text: the command and its exit status, stderr apart, folded.
1029 if (look !== undefined && e.surface === 'terminal' && isShellCard) {
1030 const shell = shellResultOf(e.props.output, e.props.isErrored)
1031
1032 if (shell !== null) {
1033 // `/skin fold off`, or the person's `▾ N more`, shows the output whole.
1034 const isWhole = !look.prefs.fold || look.folds?.open.has(OUTPUT_FOLD) === true
1035 const fold = isWhole ? null : { head: CLIP_HEAD, tail: CLIP_TAIL }
1036
1037 return shellRows(look, await read($, memberOf(commandAtom, e)), shell, e.props.isErrored, {
1038 stdout: fold,
1039 stderr: isCalmFailure ? null : fold,
1040 })
1041 }
1042 }
1043
1044 if (!active?.prefs.clipOutput || isCalmFailure || !SHELLS.has(e.props.tool) || typeof output?.stdout !== 'string') {
1045 return next(e)
1046 }
1047
1048 const stdout = clipLines(output.stdout, CLIP_HEAD, CLIP_TAIL)
1049
1050 return stdout === output.stdout
1051 ? next(e)
1052 : next({ ...e, props: { ...e.props, output: { ...output, stdout } } })
1053 })
1054
1055 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
1056 const active = await activeSkin($)
1057
1058 // Off the terminal your messages keep Claude Code's own component: it carries the copy
1059 // and rewind buttons, the context menu and selectable text, which an outline would lose.
1060 if (active === null || e.surface !== 'terminal' || !TYPED.has(e.props.origin.kind) || e.props.text.length > MAX_PROMPT) {
1061 return next(e)
1062 }
1063
1064 const look = lookOf($.ui.resolve(e), active, e.surface)
1065 const hasImages = await read($, memberOf(imagesAtom, e))
1066
1067 // Claude Code draws the images; the text is already in the outline above them.
1068 return promptRow(look, e.props.text, hasImages ? await next({ ...e, props: { ...e.props, text: '' } }) : undefined)
1069 })
1070
1071 // While charts are drawn, the model learns it can answer a "show me" with a Mermaid
1072 // fence, and to keep tables compact. Nothing for a headless run, which draws nothing.
1073 on('prompt.compose', async ($, e, next) => {
1074 const result = await next(e)
1075 const active = await activeSkin($)
1076 const isHeadless = e.surfaces.length === 0 || e.traits.includes('bare') || e.traits.includes('print')
1077
1078 if (active === null || !active.prefs.charts || !active.prefs.hints || isHeadless) {
1079 return result
1080 }
1081
1082 return { sections: [...result.sections, { id: 'skins:charts', text: CHART_HINT, scope: 'session' as const }, { id: 'skins:tables', text: TABLE_HINT, scope: 'session' as const }] }
1083 })
1084
1085 // A reply keeps Claude Code's own drawing unless it holds a card or markdown the pack draws.
1086 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
1087 const active = await activeSkin($)
1088
1089 const text = e.props.text
1090
1091 if (active === null) {
1092 return next(e)
1093 }
1094
1095 const { prefs } = active
1096 const segments = replySegments(text, prefs, e.surface)
1097 const isFramed = e.surface === 'terminal' && text.trim() !== ''
1098
1099 if (segments === null) {
1100 return isFramed ? replyFrame(lookOf($.ui.resolve(e), active, e.surface), await next(e), await modelName($, e.requestId)) : next(e)
1101 }
1102
1103 // Every surface's table names Svg, but the terminal draws it as nothing. As text, tables
1104 // are text grids and code the app's own selectable blocks.
1105 const ui = $.ui.resolve(e)
1106 const svg = e.surface !== 'terminal' && active.prefs.tables === 'on' && 'Svg' in ui ? ui.Svg : undefined
1107 lastColumns = e.viewport?.columns
1108 const copy = (copied: string) => {
1109 void $.ui.copy({ text: copied, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
1110 }
1111
1112 const base = await sessionLook($, e, lookOf(ui, active, e.surface, copy), replyNeeds(text, segments))
1113 const look = base.svg === undefined ? base : { ...base, isFirstDraw: await firstDraws($, e, prefs) }
1114
1115 // A reply of several blocks can be copied whole, as Claude wrote it.
1116 const columns = (e.viewport?.columns ?? 100) - (isFramed ? REPLY_FRAME_COLUMNS : 0)
1117 const rows = replyRows(look, segments, columns, svg, segments.length > 1 ? text : undefined)
1118
1119 return withSettle($, e, isFramed ? replyFrame(look, rows, await modelName($, e.requestId)) : rows)
1120 })
1121
1122 // A slash command's output (`/cost`, `/context`, a plugin's) drawn the way a reply is: its
1123 // tables and code as cards, a run of `key: value` lines as a table. /skin's own rows are
1124 // already the skin's.
1125 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
1126 const active = await activeSkin($)
1127
1128 if (active === null || !active.prefs.commands || active.prefs.tables === 'off' || e.props.isErrored || e.props.command === 'skin') {
1129 return next(e)
1130 }
1131
1132 const segments = e.props.text.length > MAX_MARKDOWN ? null : commandSegments(e.props.text, e.props.command)
1133
1134 if (segments === null) {
1135 return next(e)
1136 }
1137
1138 const ui = $.ui.resolve(e)
1139 const svg = e.surface !== 'terminal' && active.prefs.tables === 'on' && 'Svg' in ui ? ui.Svg : undefined
1140 const copy = (copied: string) => {
1141 void $.ui.copy({ text: copied, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
1142 }
1143
1144 const base = await sessionLook($, e, lookOf(ui, active, e.surface, copy), replyNeeds(e.props.text, segments))
1145 const look = base.svg === undefined ? base : { ...base, isFirstDraw: await firstDraws($, e, active.prefs) }
1146
1147 return withSettle($, e, replyRows(look, segments, e.viewport?.columns ?? 100, svg))
1148 })
1149
1150 // The terminal's spinner gets the skin's word with a shimmer; the desktop's keeps its
1151 // word, which says what the step is doing, beside an animated icon.
1152 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
1153 const active = await activeSkin($)
1154
1155 if (active === null) {
1156 return next(e)
1157 }
1158
1159 if (e.surface !== 'terminal') {
1160 const look = lookOf($.ui.resolve(e), active, e.surface)
1161
1162 // Calm keeps the app's own, quieter spinner.
1163 return look.svg === undefined || active.prefs.calm !== null
1164 ? next(e)
1165 : desktopSpinnerRow(look, look.svg, e.props.mode, e.props.message ?? e.props.word)
1166 }
1167
1168 // words claude keeps Claude Code's own word, whatever the skin says.
1169 const word = active.prefs.words === 'claude' ? e.props.word : (pick(active.skin.spinner, e.props.word) ?? e.props.word)
1170
1171 if (!active.prefs.shimmer || e.props.message !== null) {
1172 return next({ ...e, props: { ...e.props, word } })
1173 }
1174
1175 const startedAt = (await read($, mainTurnAtom)) ? await read($, startedAtom) : 0
1176 const elapsed = startedAt === 0 ? 0 : (await $.clock.now()) - startedAt
1177 const look = lookOf($.ui.resolve(e), active, e.surface)
1178 const { Client } = $.ui.resolve(e)
1179
1180 return (
1181 <Client
1182 key={`skin-spinner-${e.requestId}`}
1183 module="./spinner-client.tsx"
1184 props={{
1185 word,
1186 frames: [...look.icons.frames],
1187 palette: { fg: look.skin.palette.fg, user: look.skin.palette.user, muted: look.skin.palette.muted },
1188 startedAt,
1189 elapsedMs: elapsed,
1190 }}
1191 />
1192 )
1193 })
1194
1195 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
1196 const active = await activeSkin($)
1197
1198 if (active === null) {
1199 return next(e)
1200 }hooks/command.ts 228 lines1import type { CalmSnapshot, Prefs } from '../types'
2
3export const DEFAULT_PREFS: Prefs = {
4 skin: 'noir',
5 icons: 'unicode',
6 prompt: 'left',
7 words: 'skin',
8 label: 'User',
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 calm: null,
27}
28
29// The on/off settings, by the word /skin and the settings pane use for each. Tables have a
30// third state, `text`, so they are switched on their own.
31export const TOGGLES = {
32 rail: 'rail',
33 shimmer: 'shimmer',
34 band: 'band',
35 countdown: 'countdown',
36 clip: 'clipOutput',
37 markdown: 'markdown',
38 quiet: 'quiet',
39 charts: 'charts',
40 math: 'math',
41 commands: 'commands',
42 shell: 'shell',
43 highlight: 'highlight',
44 hints: 'hints',
45 links: 'links',
46 copy: 'copy',
47 fold: 'fold',
48} as const satisfies Record<string, keyof Prefs>
49
50export type ToggleWord = keyof typeof TOGGLES
51
52export type Outcome = {
53 prefs: Prefs
54 message: string
55 // `row` prints a transcript line (listings, mistakes); `toast` confirms a change quietly.
56 channel: 'row' | 'toast'
57}
58
59const flagOr = (value: unknown, fallback: boolean): boolean =>
60 typeof value === 'boolean' ? value : fallback
61
62export const TABLE_MODES = ['on', 'text', 'off'] as const satisfies readonly Prefs['tables'][]
63
64// Tables were on/off before `text`, so a stored boolean still reads.
65export function tablesOr(value: unknown, fallback: Prefs['tables']): Prefs['tables'] {
66 if (typeof value === 'boolean') {
67 return value ? 'on' : 'off'
68 }
69
70 return TABLE_MODES.find(mode => mode === value) ?? fallback
71}
72
73// The settings pane's tables button steps through the modes in order.
74export const nextTables = (mode: Prefs['tables']): Prefs['tables'] =>
75 TABLE_MODES[(TABLE_MODES.indexOf(mode) + 1) % TABLE_MODES.length] ?? 'on'
76
77// A stored calm snapshot, or null when it is not one: then calm reads as off.
78function calmOf(value: unknown): CalmSnapshot | null {
79 const saved = typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
80
81 return saved !== null && typeof saved.shimmer === 'boolean' && typeof saved.rail === 'boolean' && typeof saved.quiet === 'boolean'
82 ? { shimmer: saved.shimmer, rail: saved.rail, quiet: saved.quiet }
83 : null
84}
85
86// What the store hands back may be old, hand-edited or from another version.
87export function parsePrefs(raw: unknown, names: readonly string[]): Prefs {
88 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
89 const skin = saved.skin
90
91 return {
92 skin:
93 typeof skin === 'string' && (skin === 'off' || names.includes(skin))
94 ? skin
95 : DEFAULT_PREFS.skin,
96 icons: saved.icons === 'ascii' ? 'ascii' : 'unicode',
97 prompt: saved.prompt === 'right' || saved.prompt === 'plain' ? saved.prompt : 'left',
98 words: saved.words === 'claude' ? 'claude' : 'skin',
99 label: typeof saved.label === 'string' && saved.label.trim() !== '' ? saved.label.trim().slice(0, 20) : DEFAULT_PREFS.label,
100 rail: flagOr(saved.rail, DEFAULT_PREFS.rail),
101 tables: tablesOr(saved.tables, DEFAULT_PREFS.tables),
102 shimmer: flagOr(saved.shimmer, DEFAULT_PREFS.shimmer),
103 band: flagOr(saved.band, DEFAULT_PREFS.band),
104 countdown: flagOr(saved.countdown, DEFAULT_PREFS.countdown),
105 clipOutput: flagOr(saved.clipOutput, DEFAULT_PREFS.clipOutput),
106 markdown: flagOr(saved.markdown, DEFAULT_PREFS.markdown),
107 quiet: flagOr(saved.quiet, DEFAULT_PREFS.quiet),
108 charts: flagOr(saved.charts, DEFAULT_PREFS.charts),
109 math: flagOr(saved.math, DEFAULT_PREFS.math),
110 commands: flagOr(saved.commands, DEFAULT_PREFS.commands),
111 shell: flagOr(saved.shell, DEFAULT_PREFS.shell),
112 highlight: flagOr(saved.highlight, DEFAULT_PREFS.highlight),
113 hints: flagOr(saved.hints, DEFAULT_PREFS.hints),
114 links: flagOr(saved.links, DEFAULT_PREFS.links),
115 copy: flagOr(saved.copy, DEFAULT_PREFS.copy),
116 fold: flagOr(saved.fold, DEFAULT_PREFS.fold),
117 calm: calmOf(saved.calm),
118 }
119}
120
121// /skin calm: no motion, one line per tool row, read-only output folded. What it changes is
122// kept, so calm off puts back what was there before. Failures stay whole either way
123// (register.tsx draws them past quiet while calm is on).
124export function withCalm(current: Prefs, on: boolean): Prefs {
125 if (on) {
126 return current.calm !== null
127 ? current
128 : { ...current, calm: { shimmer: current.shimmer, rail: current.rail, quiet: current.quiet }, shimmer: false, rail: false, quiet: true }
129 }
130
131 return current.calm === null ? current : { ...current, ...current.calm, calm: null }
132}
133
134const changed = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'toast' })
135
136const refused = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'row' })
137
138const onOff = (on: boolean): string => (on ? 'on' : 'off')
139
140export const listing = (current: Prefs, names: readonly string[]): string =>
141 [
142 ...names.map(name => `${name === current.skin ? '●' : '○'} ${name}`),
143 `${current.skin === 'off' ? '●' : '○'} off`,
144 [
145 `icons ${current.icons}`,
146 `prompt ${current.prompt}`,
147 `words ${current.words}`,
148 `label ${current.label}`,
149 `rail ${onOff(current.rail)}`,
150 `tables ${current.tables}`,
151 `shimmer ${onOff(current.shimmer)}`,
152 `band ${onOff(current.band)}`,
153 `clip ${onOff(current.clipOutput)}`,
154 `markdown ${onOff(current.markdown)}`,
155 `quiet ${onOff(current.quiet)}`,
156 `charts ${onOff(current.charts)}`,
157 `math ${onOff(current.math)}`,
158 `commands ${onOff(current.commands)}`,
159 `shell ${onOff(current.shell)}`,
160 `highlight ${onOff(current.highlight)}`,
161 `hints ${onOff(current.hints)}`,
162 `links ${onOff(current.links)}`,
163 `copy ${onOff(current.copy)}`,
164 `fold ${onOff(current.fold)}`,
165 `calm ${onOff(current.calm !== null)}`,
166 ].join(' · '),
167 ].join('\n')
168
169const isToggle = (word: string): word is ToggleWord => Object.hasOwn(TOGGLES, word)
170
171// Every argument but none: with none, /skin opens the settings pane instead.
172export function runSkinCommand(args: string, current: Prefs, names: readonly string[]): Outcome {
173 const [head = '', value] = args.trim().toLowerCase().split(/\s+/)
174 const label = args.trim().replace(/^\S+\s*/, '').slice(0, 20)
175
176 if (isToggle(head)) {
177 const field = TOGGLES[head]
178
179 return value === 'on' || value === 'off'
180 ? changed({ ...current, [field]: value === 'on' }, `${head} ${value}`)
181 : refused(current, `usage: /skin ${head} on|off`)
182 }
183
184 switch (head) {
185 case 'list':
186 return refused(current, listing(current, names))
187 // `default` and `stock` are what people reach for when they want Claude Code's own look.
188 case 'off':
189 case 'default':
190 case 'stock':
191 return changed({ ...current, skin: 'off' }, 'skins off')
192 case 'on':
193 return current.skin === 'off'
194 ? changed({ ...current, skin: DEFAULT_PREFS.skin }, `skin: ${DEFAULT_PREFS.skin}`)
195 : changed(current, `skin: ${current.skin}`)
196 case 'calm':
197 return value === 'on' || value === undefined
198 ? changed(withCalm(current, true), current.calm === null ? 'calm on' : 'calm is already on')
199 : value === 'off'
200 ? changed(withCalm(current, false), current.calm === null ? 'calm is already off' : 'calm off: your settings are back')
201 : refused(current, 'usage: /skin calm [on|off]')
202 case 'tables':
203 return value === 'on' || value === 'text' || value === 'off'
204 ? changed({ ...current, tables: value }, `tables ${value}`)
205 : refused(current, 'usage: /skin tables on|text|off')
206 case 'prompt':
207 return value === 'left' || value === 'right' || value === 'plain'
208 ? changed({ ...current, prompt: value }, `prompt ${value}`)
209 : refused(current, 'usage: /skin prompt left|right|plain')
210 case 'label':
211 return label === ''
212 ? refused(current, 'usage: /skin label <name>')
213 : changed({ ...current, label }, `label: ${label}`)
214 case 'words':
215 return value === 'claude' || value === 'skin'
216 ? changed({ ...current, words: value }, `words: ${value}`)
217 : refused(current, 'usage: /skin words claude|skin')
218 case 'icons':
219 return value === 'unicode' || value === 'ascii'
220 ? changed({ ...current, icons: value }, `icons: ${value}`)
221 : refused(current, 'usage: /skin icons unicode|ascii')
222 default:
223 return names.includes(head)
224 ? changed({ ...current, skin: head }, `skin: ${head}`)
225 : refused(current, `unknown skin "${head}". /skin list shows them`)
226 }
227}
228hooks/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 114 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
21// `amount` of the way from `over` to `hex`: a faint wash of a colour over a band. Undefined
22// unless both are #rrggbb, so a skin naming colours another way draws no wash.
23export const tint = (hex: string, over: string, amount: number): string | undefined => {
24 const isHex = (value: string) => /^#[0-9a-f]{6}$/i.test(value)
25
26 if (!isHex(hex) || !isHex(over)) {
27 return undefined
28 }
29
30 const top = channels(hex)
31
32 return toHex(channels(over).map((value, i) => value + ((top[i] ?? value) - value) * amount))
33}
34
35export function toLight(palette: Palette): Palette {
36 const deep = (hex: string) => deepen(hex, 0.45)
37
38 return {
39 read: deep(palette.read),
40 write: deep(palette.write),
41 run: deep(palette.run),
42 search: deep(palette.search),
43 web: deep(palette.web),
44 mcp: deep(palette.mcp),
45 other: deep(palette.other),
46 user: deep(palette.user),
47 fg: '#1f1f1f',
48 muted: '#6b6b6b',
49 surface: '#ffffff',
50 zebra: '#f2f2f2',
51 ok: deepen(palette.ok, 0.5),
52 err: deepen(palette.err, 0.25),
53 warn: deepen(palette.warn, 0.5),
54 }
55}
56
57export const forTheme = (skin: Skin, isLight: boolean): Skin =>
58 isLight ? { ...skin, palette: skin.light ?? toLight(skin.palette) } : skin
59
60// Claude Code's theme names say light or dark in them: `light`, `light-daltonized`, ...
61export const isLightTheme = (value: unknown): boolean => typeof value === 'string' && value.includes('light')
62
63// Light, dark, or undefined when the name says neither (`auto`).
64const named = (value: unknown): boolean | undefined => {
65 const name = typeof value === 'string' ? value.trim().toLowerCase() : ''
66
67 return name.includes('light') ? true : name.includes('dark') ? false : undefined
68}
69
70// `COLORFGBG` is `<fg>;<bg>` (sometimes `<fg>;<default>;<bg>`); background 7 or 15 is a light one.
71const terminalLight = (colorfgbg: string | undefined): boolean | undefined => {
72 const bg = Number(colorfgbg?.split(';').at(-1))
73
74 return colorfgbg === undefined || Number.isNaN(bg) ? undefined : bg === 7 || bg === 15
75}
76
77export type ThemeHints = {
78 // `SKINS_THEME`, for a session whose theme comes from somewhere skins cannot read (`--settings`).
79 override?: string
80 // Claude Code's `theme` setting.
81 theme?: unknown
82 colorfgbg?: string
83 // The system's appearance, when it could be read.
84 systemDark?: boolean
85}
86
87// `SKINS_THEME` wins, then a theme that names light or dark, then, for `auto`, the
88// terminal's background and the system's appearance. Dark when nothing says.
89export const resolveLight = (hints: ThemeHints): boolean =>
90 named(hints.override) ??
91 named(hints.theme) ??
92 terminalLight(hints.colorfgbg) ??
93 (hints.systemDark === undefined ? false : !hints.systemDark)
94
95// What a system-appearance probe printed: a reader answers dark, light, or undefined
96// when its output says neither (the setting is missing, the tool failed).
97type ProbeOutput = Pick<ProcessRunResult, 'exitCode' | 'stdout' | 'stderr'>
98
99// `defaults read -g AppleInterfaceStyle`: "Dark", or unset when light: "does not exist"
100// before macOS 27, "Could not find key" from 27 on.
101export const macDark = (out: ProbeOutput): boolean | undefined =>
102 out.exitCode === 0 ? out.stdout.trim() === 'Dark' : /does not exist|Could not find key/.test(out.stderr) ? false : undefined
103
104// `reg query ...\Personalize /v AppsUseLightTheme`: 0x0 is dark, 0x1 light.
105export const windowsDark = (out: ProbeOutput): boolean | undefined => {
106 const value = /AppsUseLightTheme\s+REG_DWORD\s+0x([0-9a-f]+)/i.exec(out.stdout)?.[1]
107
108 return out.exitCode !== 0 || value === undefined ? undefined : parseInt(value, 16) === 0
109}
110
111// `gsettings get org.gnome.desktop.interface color-scheme`: 'prefer-dark', 'default', ...
112export const gnomeDark = (out: ProbeOutput): boolean | undefined =>
113 out.exitCode === 0 ? out.stdout.includes('dark') : undefined
114hooks/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