SLOPSHOPPER

skins

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…

newpanespinnerrowsguardcommand
v0.5.7MITupdated 2026-10-15devwriet/claude-skins
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skins
│ ┃ Skins ✕ │ ┃ Skin 1: noir ● 2: tokyo-night 3: dracula User │ ┃ ╭─────────────────────────────────────────────────────────────────╮ │ ┃ ╭─────────────────────────────────────────── │ fix the failing auth test and add an audit log call │ │ ┃ │ ╰─────────────────────────────────────────────────────────────────╯ │ ┃ │ User │ ┃ │ ╭──────────────────────────────────────╮ │ ┃ │ │ add rate limiting to the hub api │ │ ┃ │ ╰──────────────────────────────────────╯ ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ ┃ │ ┃ │ ┃ ●─ ✎ Edit src/auth.ts 20ms │ ┃ │ ●─ ❯ Bash pnpm test --filter hub 2.1s │ ┃ │ ┃ ⏺ Bash(bun test) │ ┃ │ ●─ ✎ Edit apps/hub/src/server.ts +18 −3 ⎿ 3 pass, 1 fail │ ┃ │ ┃ │ ┃ │ ●─ Read 2 · Search 1 │ ┃ │ Claude(Opus 5.5) │ ┃ │ ╭──────────────────────────────────────╮ ╭───────────────────────────────────────────────────────────────────╮ │ ┃ │ │ ROUTE LIMIT WINDOW │ │ Done. I made refresh reject expired claims and added an audit │ │ ┃ │ │ ────────────────────── │ │ call. │ │ ┃ │ │ /chat 60 1 min │ │ │ │ ┃ │ │ /upload 10 1 min │ ╰───────────────────────────────────────────────────────────────────╯ │ ┃ │ │ /search 120 1 min │ │ ┃ │ ╰──────────────────────────────────────╯ │ ┃ │ ▣ client module ./spinner-client.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

<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">

Install

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.

Update

This fork has no update check. To update:

claude plugin marketplace update srit-mods
claude plugin update skins@srit-mods

Then restart Claude Code.

Uninstall

claude plugin uninstall skins@srit-mods

To keep it installed but go back to Claude Code's own drawing, run /skin off.

Use

CommandWhat it does
/skinSettings: 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 listEvery skin, yours included
/skin offBack 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 offNo motion, one line per tool row, read-only output folded, failures always whole. calm off puts back what you had
/skin copy · copy codeCopy 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 galleryEvery element the skin draws, numbered, to point at
/skin pin · unpin · shareGive 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.

Preview

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">

What it redraws

PartDesktop appTerminal
Tool callsIcon 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 itselfA node on the turn's rail, with the same ▸
EditsDiff card with +N −M and numbered linesClaude Code's own diff
Shell commandsTerminal card with status, stderr apart, long output folded; paths, numbers and error, warning and pass words colouredCard 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 lineThe same
TablesCard with header rule, zebra rows, colour swatchesCell 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, C4The 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 UnicodeFractions and limits stacked in cells, inline $…$ as Unicode
Command output/cost, /context and plugin output: tables, code and key: value runs as cardsThe same as cell grids
Alerts and task lists> [!NOTE] as a titled box, - [ ] with ticks and a done countThe same
Code blocksCard with language, line numbers, highlighting; shell blocks keep the Run buttonFramed, 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)
SpinnerAnimated icon per phaseThe skin's word with a band of light
Above the promptContext and plan-limit rings, Compact button, nudge at 70%Block meters, same button
Your promptsClaude Code's own, so copy, rewind and selection stayRounded outline, or a plain marker
Links and filesURLs 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 sideTwo or more tables or charts in a row share it when each gets 75 columns; stack otherwiseTables and diagrams in a row sit side by side while their natural widths fit; stack otherwise
Long blocksCode cards past 24 lines cut there, with ▾ N moreCode 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.

For other mods

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} />

Compatibility

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

Known limitations

WhatWhat you get insteadWhere
An opened row for an image, PDF or notebook read, a background command, or an answer it cannot readClaude Code's own row, drawn in the opened rowbodyOf in hooks/detail.ts; test "an opened image Read is Claude Code's own drawing…"
Agents, plan mode, todos, the permission promptClaude Code's own rows and dialogskindOf in hooks/tools.ts; test "tools a skin cannot redraw faithfully keep their own row"
Edits on the terminal, failed edits everywhereClaude Code's own diffhooks/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 knowClaude Code's own markdownlanguageOf in hooks/highlight.ts; test "a fence in a language it does not know keeps Claude Code's markdown"
Shell fences ( `bash ) on the desktopThe app's own block, so its Run button staystest "on the desktop a shell fence keeps the app's own block"
Sequence, state, class and ER diagrams on the desktopA code card: only the terminal's vendored layout draws themartKind 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 artdrawsWide 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 codetest "a diagram too wide for the terminal falls back to rows…"
A reply block over 9,000 characters, a prompt over 4,000Claude Code's own drawing of the whole messageMAX_MARKDOWN, MAX_PROMPT in hooks/register.tsx
Code cards past 80 linesThe first 80 and a N more lines note; Copy takes all of itMAX_LINES in hooks/svg-code.ts
TeX commands the parser does not knowDrawn as written, \namehooks/math.ts
Shell output on the terminal vs the desktopThe 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 + 6hooks/shell-rows.tsx, hooks/svg-terminal.ts
A failed shell call that hands back error text, not its recordstdout and stderr arrive merged, so the whole text is drawn as stderrshellResultOf in hooks/shell.ts
A command sent to the background, or output that is an imageClaude Code's own row, which says where it went or draws the imagekeepsOwnRow in hooks/shell.ts; test "/skin shell off gives shell output back…"
Shell cards and tool timings after /resumeNo command in the header and no time: both are recorded as the call runscommandAtom, durationAtom in hooks/register.tsx
Slash-command errors and /skin's own outputClaude Code's own rowtest "slash-command output with key: value lines is a table, prose keeps its row"
/skin calm and Claude Code's spinnerCalm hands the spinner back to Claude Code, whose own spinner still movesSpinner in hooks/register.tsx
The settings pane on mobileA line saying to use the terminal or the desktop appPane in hooks/register.tsx
File paths as linksColoured, not clickable: the engine's Link takes only https: and http://localhostsafeHref in hooks/links.ts
Folding on the desktopCode cards fold; table cards and the shell card keep their full height or their own 6 + 6 foldhooks/fold.tsx
Icons and the railIcons on the desktop only (the terminal draws no Svg); the rail on the terminal onlylookOf in hooks/register.tsx

Compared with

Other mods that change how the transcript looks, from their READMEs on 2026-10-10. Blank means the README does not say.

skinsPrismantisglinttweakccclaude-gfm-renderccstatusline
HowFunction-hooks pluginFunction-hooks pluginFunction-hooks plugin, a Prismantis forkPatches Claude Code's JavaScript; reapply after each updateFunction-hooks modStatus line command
Claude Code2.1.287+2.1.287+2.1.289+Verified on 2.1.1622.1.286+
Platforms namedWindows by hand, Linux by tests; CI on Linux, Windows, macOS (compat)macOS terminal by hand; CI on macOS, Linux, WindowsWindows, macOS, LinuxTerminal and desktop appWindows guide in its docs
Themes15, repaint any colour, or have Claude design one15 and mono, 20 colour slots15Your own, for Claude Code's whole UIPowerline themes
Recolours Claude Code's own UINoNoNoYesNoNo
Tool rowsIcons, timings, rail; open to the input and answer4 stylesNoNo
Read-only output hidden/skin quiettoolOutput: quietNoNo
Edit diffsCard on the desktop; the engine's diff in an opened row on the terminalClaude Code's ownNoNo
`diff fencesDiff card or numbered rows, new only copyNumbered, ⧉ new onlyNo
Shell outputCard on both surfaces, paths, numbers and errors colouredBoxed in expanded groupsNoNo
Code highlighting on the terminal20 language families in the skin's coloursPrism, two dozen languages20+ languagesNo
TablesCard or cell grid, side by side with diagrams4 styles, side by side with diagrams4 formatsNoNo
Mermaid17 kinds; sequence, state, class, ER on the terminal onlyFlowchart, sequence, state, class, ER, xy; no pieFlowchart, sequence, xyFlowchart, sequence, state, class, ER, xy; SVG on the desktopNo
MathCard on the desktop, stacked cells on the terminalTypeset image in kitty and Ghostty via RaTeX, text elsewhereNot handledNo
LinksURLs as OSC 8 links; this turn's files colouredLinks and bare URLs clickableURLs and file paths clickable; changed files coloured; link bandNo
Long blocks foldedCode, tables, shell output (/skin fold)Code, lists, tablesNo
Right-to-left textNoHebrew and ArabicHebrew and ArabicNo
Usage metersContext and plan limits above the promptNoMany widgets, Powerline
Spinner wordsThe skin's, with a shimmerYour own verbs and animationsNoNo
CopyCards, tables as markdown or as drawn, copy reply, /skin copyButtons, /prismantis copy, HTML tablesCode, tables, diagrams, lists, quotesNo
API for other mods$.skins.markdown$.prismantis.markdownNo

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.

Privacy

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 ..

Vendored code

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

Develop

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.

More mods

  • redgreen: test results in a pane beside the chat
  • smart-compact: compacts after a commit, green tests, before the prompt cache expires, or when Claude asks
  • mod-store: browse, search and install 2,700 mods from a pane

License

MIT

Source 78 files
hooks/register.tsx 1235 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderSurface, Timer } from 'claude-code'
3
4import type { CustomSkin, Disclosure, Prefs, SkinSlot, SkinsMarkdownArgs, Touch, TurnStats, UsageSnap } from '../types'
5import { DEFAULT_PREFS, nextTables, parsePrefs, runSkinCommand, TOGGLES, withCalm } from './command'
6import { buildCustom, resolveSkin, skinNames, withSlot } from './custom'
7import { forTheme, gnomeDark, macDark, resolveLight, windowsDark } from './light'
8import { parseFolders, prefsFor, withFolder, withoutFolder } from './folders'
9import { DESIGN_TOOL, runDesign } from './designer'
10import type { DesignState } from './designer'
11import { clipLines, diffstat, pick } from './format'
12import { bodyOf, inputFields, isOpen } from './detail'
13import { disclosedRow, openedRows } from './detail-rows'
14import { copyOf } from './markdown'
15import { CHART_HINT } from './mermaid'
16import { commandSegments } from './command-output'
17import { askBand, desktopSpinnerRow, diffCard, footerRow, terminalCard, usageBand, groupRow, promptRow, quietResult, 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 lines
1import type { CalmSnapshot, Prefs } from '../types'
2
3export const DEFAULT_PREFS: Prefs = {
4  skin: 'noir',
5  icons: 'unicode',
6  prompt: 'left',
7  words: 'skin',
8  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}
228
hooks/custom.ts 185 lines
1import type { CustomSkin, SkinSlot } from '../types'
2import type { Skin } from './skin'
3import { SKINS } from './themes'
4
5export const SLOTS: readonly SkinSlot[] = [
6  'user',
7  'fg',
8  'muted',
9  'surface',
10  'zebra',
11  'ok',
12  'err',
13  'warn',
14  'read',
15  'write',
16  'run',
17  'search',
18  'web',
19  'mcp',
20  'other',
21]
22
23// What each slot paints, for the settings pane and the agent's tool.
24export const SLOT_HELP: Readonly<Record<SkinSlot, string>> = {
25  user: 'accent: the rail, your prompt marker, the spinner',
26  fg: 'body text',
27  muted: 'secondary text: commands, paths, timings',
28  surface: 'band behind a table header',
29  zebra: 'every other table row; keep it a step off the background',
30  ok: 'a finished call',
31  err: 'a failed call',
32  warn: 'an interrupted call',
33  read: 'Read',
34  write: 'Edit and Write',
35  run: 'Bash and PowerShell',
36  search: 'Glob and Grep',
37  web: 'WebFetch and WebSearch',
38  mcp: 'MCP tools',
39  other: 'anything else in a group',
40}
41
42export type Customs = Readonly<Record<string, CustomSkin>>
43
44const HEX = /^#[0-9a-f]{6}$/i
45const NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
46const MAX_WORDS = 12
47const MAX_WORD = 24
48
49const builtIn = (name: string): Skin | undefined => SKINS.find(skin => skin.name === name)
50
51export const skinNames = (custom: Customs): string[] => [
52  ...SKINS.map(skin => skin.name),
53  ...Object.keys(custom).filter(name => builtIn(name) === undefined),
54]
55
56// A made skin is its base with the slots and words it changed laid over it.
57export function resolveSkin(name: string, custom: Customs): Skin | undefined {
58  const own = builtIn(name)
59
60  if (own !== undefined) {
61    return own
62  }
63
64  const made = custom[name]
65  const base = made === undefined ? undefined : (builtIn(made.base) ?? SKINS[0])
66
67  if (made === undefined || base === undefined) {
68    return undefined
69  }
70
71  return {
72    name: made.name,
73    label: made.label,
74    palette: { ...base.palette, ...made.palette },
75    spinner: made.spinner.length > 0 ? made.spinner : base.spinner,
76    done: made.done.length > 0 ? made.done : base.done,
77  }
78}
79
80export type Draft = {
81  name?: unknown
82  label?: unknown
83  base?: unknown
84  palette?: unknown
85  spinner?: unknown
86  done?: unknown
87}
88
89function words(value: unknown, field: string, fallback: string[]): string[] | string {
90  if (value === undefined) {
91    return fallback
92  }
93
94  const valid =
95    Array.isArray(value) &&
96    value.length <= MAX_WORDS &&
97    value.every(word => typeof word === 'string' && word.trim() !== '' && word.length <= MAX_WORD)
98
99  return valid
100    ? (value as string[]).map(word => word.trim())
101    : `${field} must be up to ${MAX_WORDS} words of 1-${MAX_WORD} characters`
102}
103
104function palette(value: unknown, fallback: CustomSkin['palette']): CustomSkin['palette'] | string {
105  if (value === undefined) {
106    return fallback
107  }
108
109  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
110    return 'palette must be an object of slot: "#rrggbb"'
111  }
112
113  const entries = Object.entries(value as Record<string, unknown>)
114  const bad = entries.filter(
115    ([slot, hex]) => !SLOTS.includes(slot as SkinSlot) || typeof hex !== 'string' || !HEX.test(hex),
116  )
117
118  if (bad.length > 0) {
119    return `bad palette entries: ${bad.map(([slot]) => slot).join(', ')}. Slots: ${SLOTS.join(', ')}; values #rrggbb`
120  }
121
122  return { ...fallback, ...Object.fromEntries(entries.map(([slot, hex]) => [slot, (hex as string).toLowerCase()])) }
123}
124
125// Builds a made skin from what the agent or the pane sent, or says what is wrong with it.
126export function buildCustom(draft: Draft, existing: CustomSkin | undefined): CustomSkin | string {
127  const name = typeof draft.name === 'string' ? draft.name.trim().toLowerCase() : ''
128
129  if (!NAME.test(name)) {
130    return 'name must be 1-32 lowercase letters, digits or hyphens'
131  }
132
133  if (builtIn(name) !== undefined || name === 'off') {
134    return `"${name}" is taken by a built-in skin; pick another name`
135  }
136
137  const base = typeof draft.base === 'string' ? draft.base : (existing?.base ?? SKINS[0]?.name ?? '')
138
139  if (builtIn(base) === undefined) {
140    return `base must be one of ${SKINS.map(skin => skin.name).join(', ')}`
141  }
142
143  const label = typeof draft.label === 'string' && draft.label.trim() !== '' ? draft.label.trim().slice(0, 32) : (existing?.label ?? name)
144  const colours = palette(draft.palette, existing?.palette ?? {})
145  const spinner = words(draft.spinner, 'spinner', existing?.spinner ?? [])
146  const done = words(draft.done, 'done', existing?.done ?? [])
147
148  for (const result of [colours, spinner, done]) {
149    if (typeof result === 'string') {
150      return result
151    }
152  }
153
154  return {
155    name,
156    label,
157    base,
158    palette: colours as CustomSkin['palette'],
159    spinner: spinner as string[],
160    done: done as string[],
161  }
162}
163
164// One slot changed from the settings pane: a built-in skin forks into `my-<name>`.
165export function withSlot(
166  current: string,
167  custom: Customs,
168  slot: SkinSlot,
169  hex: string,
170): { name: string; skin: CustomSkin } | string {
171  if (!HEX.test(hex)) {
172    return 'use a colour like #7aa2f7'
173  }
174
175  const made = custom[current]
176  const name = made === undefined ? `my-${current}` : current
177  const base = made?.base ?? current
178  const skin = buildCustom(
179    { name, base, label: made?.label ?? `My ${current}`, palette: { [slot]: hex } },
180    custom[name],
181  )
182
183  return typeof skin === 'string' ? skin : { name, skin }
184}
185
hooks/light.ts 114 lines
1import type { ProcessRunResult } from 'claude-code'
2import type { Palette, Skin } from './skin'
3
4// Every skin works on a light background too. A skin may carry its own light palette;
5// the rest are derived: body text goes near-black, bands go near-white, and each colour
6// is deepened until it reads on white.
7
8const channels = (hex: string): [number, number, number] => [
9  parseInt(hex.slice(1, 3), 16),
10  parseInt(hex.slice(3, 5), 16),
11  parseInt(hex.slice(5, 7), 16),
12]
13
14const toHex = (rgb: readonly number[]): string =>
15  `#${rgb.map(value => Math.round(Math.max(0, Math.min(255, value))).toString(16).padStart(2, '0')).join('')}`
16
17// `amount` of the way from `hex` to black.
18export const deepen = (hex: string, amount: number): string =>
19  toHex(channels(hex).map(value => value * (1 - amount)))
20
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
114
hooks/folders.ts 21 lines
1import type { Prefs } from '../types'
2import { parsePrefs } from './command'
3
4// Folders that keep their own look, by the session's directory. A folder not named
5// here follows the default prefs.
6export type Folders = Record<string, Prefs>
7
8export function parseFolders(raw: unknown, names: readonly string[]): Folders {
9  const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
10
11  return Object.fromEntries(Object.entries(saved).map(([folder, prefs]) => [folder, parsePrefs(prefs, names)]))
12}
13
14export const prefsFor = (folder: string, folders: Folders, fallback: Prefs): Prefs =>
15  (Object.hasOwn(folders, folder) ? folders[folder] : undefined) ?? fallback
16
17export const withFolder = (folders: Folders, folder: string, prefs: Prefs): Folders => ({ ...folders, [folder]: prefs })
18
19export const withoutFolder = (folders: Folders, folder: string): Folders =>
20  Object.fromEntries(Object.entries(folders).filter(([name]) => name !== folder))
21
hooks/designer.ts 141 lines
1import type { CustomSkin, Prefs } from '../types'
2import { DEFAULT_PREFS, tablesOr } from './command'
3import { buildCustom, resolveSkin, SLOT_HELP, skinNames } from './custom'
4import { SKINS } from './themes'
5
6// The tool the person's own agent calls to design a skin with them.
7export const DESIGN_TOOL = {
8  name: 'design',
9  description: [
10    'Design, change and apply skins for the skins mod, which restyles this Claude Code transcript:',
11    'tool rows on a timeline rail, tables, the spinner, the turn footer. Changes show at once.',
12    'Call {"action":"show"} first: it returns the current skin, every slot with what it paints, and the names in use.',
13    '"save" makes or updates a skin: name (lowercase, hyphens), base (a built-in skin it starts from),',
14    'palette ({slot: "#rrggbb"}, only the slots to change), spinner (gerunds, like "Brewing"),',
15    'done (past tense, like "Brewed"), apply (default true).',
16    '"apply" switches to a skin by name, "delete" removes a made one,',
17    '"settings" switches rail, shimmer, band, clip, markdown, quiet, charts (booleans), tables ("on", "text" or "off") and icons ("unicode" or "ascii").',
18    'Good skins keep fg and muted readable on a dark background, and surface and zebra one small step off it.',
19  ].join(' '),
20  inputSchema: {
21    type: 'object',
22    properties: {
23      action: { type: 'string', enum: ['show', 'save', 'apply', 'delete', 'settings'] },
24      name: { type: 'string' },
25      label: { type: 'string' },
26      base: { type: 'string', enum: SKINS.map(skin => skin.name) },
27      palette: { type: 'object', additionalProperties: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' } },
28      spinner: { type: 'array', items: { type: 'string' }, maxItems: 12 },
29      done: { type: 'array', items: { type: 'string' }, maxItems: 12 },
30      apply: { type: 'boolean' },
31      settings: {
32        type: 'object',
33        properties: {
34          rail: { type: 'boolean' },
35          tables: { enum: [true, false, 'on', 'text', 'off'] },
36          shimmer: { type: 'boolean' },
37          band: { type: 'boolean' },
38          clip: { type: 'boolean' },
39          markdown: { type: 'boolean' },
40          quiet: { type: 'boolean' },
41          charts: { type: 'boolean' },
42          icons: { type: 'string', enum: ['unicode', 'ascii'] },
43        },
44      },
45    },
46    required: ['action'],
47  },
48} as const
49
50export type DesignState = { prefs: Prefs; custom: Record<string, CustomSkin> }
51
52export type DesignOutcome = { text: string; state: DesignState; isError: boolean }
53
54type Input = Record<string, unknown>
55
56const fail = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: true })
57
58const ok = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: false })
59
60function show(state: DesignState): DesignOutcome {
61  const skin = resolveSkin(state.prefs.skin, state.custom)
62
63  return ok(
64    state,
65    JSON.stringify({
66      current: state.prefs.skin,
67      palette: skin?.palette ?? null,
68      spinner: skin?.spinner ?? [],
69      done: skin?.done ?? [],
70      builtIn: SKINS.map(one => one.name),
71      made: Object.keys(state.custom),
72      slots: SLOT_HELP,
73      settings: state.prefs,
74    }),
75  )
76}
77
78function save(input: Input, state: DesignState): DesignOutcome {
79  const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
80  const skin = buildCustom(input, state.custom[name])
81
82  if (typeof skin === 'string') {
83    return fail(state, skin)
84  }
85
86  const custom = { ...state.custom, [skin.name]: skin }
87  const prefs = input.apply === false ? state.prefs : { ...state.prefs, skin: skin.name }
88
89  return ok({ prefs, custom }, `saved ${skin.name}${input.apply === false ? '' : ' and applied it'}`)
90}
91
92function settings(input: Input, state: DesignState): DesignOutcome {
93  const asked = (typeof input.settings === 'object' && input.settings !== null ? input.settings : {}) as Input
94  const flag = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
95  const icons = asked.icons === 'ascii' || asked.icons === 'unicode' ? asked.icons : state.prefs.icons
96  const prefs: Prefs = {
97    ...state.prefs,
98    rail: flag(asked.rail, state.prefs.rail),
99    tables: tablesOr(asked.tables, state.prefs.tables),
100    shimmer: flag(asked.shimmer, state.prefs.shimmer),
101    band: flag(asked.band, state.prefs.band),
102    clipOutput: flag(asked.clip, state.prefs.clipOutput),
103    markdown: flag(asked.markdown, state.prefs.markdown),
104    quiet: flag(asked.quiet, state.prefs.quiet),
105    charts: flag(asked.charts, state.prefs.charts),
106    icons,
107  }
108
109  return ok({ ...state, prefs }, `settings: ${JSON.stringify(prefs)}`)
110}
111
112export function runDesign(raw: unknown, state: DesignState): DesignOutcome {
113  const input = (typeof raw === 'object' && raw !== null ? raw : {}) as Input
114  const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
115
116  switch (input.action) {
117    case 'show':
118      return show(state)
119    case 'save':
120      return save(input, state)
121    case 'apply':
122      return skinNames(state.custom).includes(name)
123        ? ok({ ...state, prefs: { ...state.prefs, skin: name } }, `applied ${name}`)
124        : fail(state, `no skin named "${name}"; show lists them`)
125    case 'delete': {
126      if (state.custom[name] === undefined) {
127        return fail(state, `"${name}" is not a made skin; built-in skins cannot be deleted`)
128      }
129
130      const custom = Object.fromEntries(Object.entries(state.custom).filter(([key]) => key !== name))
131      const prefs = state.prefs.skin === name ? { ...state.prefs, skin: DEFAULT_PREFS.skin } : state.prefs
132
133      return ok({ prefs, custom }, `deleted ${name}`)
134    }
135    case 'settings':
136      return settings(input, state)
137    default:
138      return fail(state, 'action must be show, save, apply, delete or settings')
139  }
140}
141
hooks/format.ts 85 lines
1// Plain string helpers behind the row builders.
2
3export const oneLine = (text: string): string =>
4  (text.split('\n', 1)[0] ?? '').replace(/\s+/g, ' ').trim()
5
6const slashed = (path: string): string => path.replace(/\\/g, '/')
7
8// A path under the session's directory shows relative to it; any other stays as given.
9export function shortenPath(path: string, cwd: string): string {
10  const full = slashed(path)
11  const root = slashed(cwd).replace(/\/+$/, '')
12
13  if (root === '' || !full.toLowerCase().startsWith(`${root.toLowerCase()}/`)) {
14    return path
15  }
16
17  return full.slice(root.length + 1)
18}
19
20export function formatDuration(ms: number): string {
21  const seconds = Math.round(ms / 1000)
22
23  if (seconds < 1) {
24    return '<1s'
25  }
26
27  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
28}
29
30// A tool call's own time: `340ms`, `2.1s`, `1m 4s`.
31export function formatMs(ms: number): string {
32  if (ms < 1000) {
33    return `${Math.round(ms)}ms`
34  }
35
36  return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms)
37}
38
39// Lines added and removed, read from an Edit or Write record's patch.
40export function diffstat(output: unknown): { added: number; removed: number } | null {
41  const patch = (output as { structuredPatch?: unknown } | null)?.structuredPatch
42
43  if (!Array.isArray(patch)) {
44    return null
45  }
46
47  const lines = patch.flatMap(hunk => {
48    const hunkLines = (hunk as { lines?: unknown } | null)?.lines
49
50    return Array.isArray(hunkLines) ? hunkLines.filter(line => typeof line === 'string') : []
51  }) as string[]
52
53  return {
54    added: lines.filter(line => line.startsWith('+')).length,
55    removed: lines.filter(line => line.startsWith('-')).length,
56  }
57}
58
59// The same seed always picks the same item, so a row keeps its word when it redraws.
60export function pick<T>(items: readonly T[], seed: string): T | undefined {
61  let hash = 5381
62
63  for (let i = 0; i < seed.length; i++) {
64    hash = (hash * 33 + seed.charCodeAt(i)) >>> 0
65  }
66
67  return items[hash % items.length]
68}
69
70// Keeps the first `head` and last `tail` lines of a long text and names how many it hid.
71export function clipLines(text: string, head: number, tail: number): string {
72  const lines = text.split('\n')
73  const hidden = lines.length - head - tail
74
75  if (hidden <= 1) {
76    return text
77  }
78
79  return [
80    ...lines.slice(0, head),
81    `… ${hidden} lines hidden`,
82    ...lines.slice(lines.length - tail),
83  ].join('\n')
84}
85
hooks/detail.ts 256 lines
1import { shortenPath } from './format'
2import { SHELLS, shellResultOf } from './shell'
3import type { ShellResult } from './shell'
4import { hunksOf } from './svg-diff'
5import type { DiffInput } from './svg-diff'
6
7// What a tool row shows when it is opened: the call's input in full, then its result in
8// the form the call's kind reads best in. Anything this cannot read is `stock`, which the
9// row answers with Claude Code's own drawing, so an opened row is never empty.
10
11export type Field = { label: string; value: string }
12
13export type Body =
14  | { kind: 'file'; path: string; text: string; startLine: number; total: number }
15  | { kind: 'matches'; lines: string[]; note: string }
16  | { kind: 'shell'; result: ShellResult }
17  | { kind: 'diff'; diff: DiffInput }
18  | { kind: 'text'; text: string; isError: boolean }
19  | { kind: 'none' }
20  | { kind: 'stock' }
21
22// Lines of a read or a search shown before the rest folds behind a `show all` control.
23export const DETAIL_LINES = 20
24
25// The engine's `Code` and `Markdown` take at most 10000 characters a block.
26export const MAX_BLOCK = 9000
27
28export const isOpen = (disclosure: string, isErrored: boolean): boolean =>
29  disclosure === 'open' || (disclosure !== 'closed' && isErrored)
30
31type Fields = Readonly<Record<string, unknown>>
32
33// Tab and newline are the only control characters a drawn string may hold.
34export const clean = (text: string): string => text.replace(/\r\n?/g, '\n').replace(/\u001b\[[0-9;?]*[A-Za-z]|[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
35
36const fieldsOf = (value: unknown): Fields => (typeof value === 'object' && value !== null ? (value as Fields) : {})
37
38// Text the edit's diff already shows, which would only repeat it above.
39const SHOWN_BY_DIFF = new Set(['old_string', 'new_string', 'content', 'edits'])
40
41const LABELS: Readonly<Record<string, string>> = {
42  file_path: 'path',
43  notebook_path: 'path',
44  run_in_background: 'background',
45  output_mode: 'mode',
46  '-i': 'ignore case',
47  '-n': 'line numbers',
48  '-A': 'after',
49  '-B': 'before',
50  '-C': 'context',
51  head_limit: 'limit',
52}
53
54const valueText = (value: unknown): string =>
55  typeof value === 'string' ? value : typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value, null, 2)
56
57// A read's `offset` and `limit` as the line range they ask for.
58function rangeOf(fields: Fields): Field[] {
59  const offset = typeof fields.offset === 'number' ? fields.offset : undefined
60  const limit = typeof fields.limit === 'number' ? fields.limit : undefined
61
62  if (offset === undefined && limit === undefined) {
63    return []
64  }
65
66  const from = offset ?? 1
67
68  return [{ label: 'lines', value: limit === undefined ? `${from}–end` : `${from}–${from + limit - 1}` }]
69}
70
71// The call's input, each field as given; paths under the session's folder made relative.
72export function inputFields(tool: string, input: unknown, cwd: string): Field[] {
73  const fields = fieldsOf(input)
74  const isRead = tool === 'Read'
75  const isEdit = tool === 'Edit' || tool === 'MultiEdit' || tool === 'Write' || tool === 'NotebookEdit'
76  const listed = Object.entries(fields)
77    .filter(([, value]) => value !== undefined && value !== null && value !== '')
78    .filter(([key]) => !(isRead && (key === 'offset' || key === 'limit')) && !(isEdit && SHOWN_BY_DIFF.has(key)))
79    .map(([key, value]) => ({
80      label: LABELS[key] ?? key,
81      value: /path$/.test(key) && typeof value === 'string' ? shortenPath(value, cwd) : valueText(value),
82    }))
83
84  return isRead ? [...listed, ...rangeOf(fields)] : listed
85}
86
87// A failed call's output is the text the model read, its error tags taken off.
88const errorText = (output: unknown): string =>
89  (typeof output === 'string' ? output : valueText(output)).replace(/<\/?tool_use_error>/g, '').trim()
90
91function readBody(output: unknown): Body {
92  const record = fieldsOf(output)
93  const file = fieldsOf(record.file)
94
95  if (record.type !== 'text' || typeof file.content !== 'string') {
96    // Images, PDFs and notebooks are Claude Code's to draw.
97    return { kind: 'stock' }
98  }
99
100  return {
101    kind: 'file',
102    path: typeof file.filePath === 'string' ? file.filePath : '',
103    text: clean(file.content).replace(/\n$/, ''),
104    startLine: typeof file.startLine === 'number' ? file.startLine : 1,
105    total: typeof file.totalLines === 'number' ? file.totalLines : 0,
106  }
107}
108
109const count = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
110
111// Grep answers with matching lines, matching files or counts; Glob with files.
112function searchBody(output: unknown, cwd: string): Body {
113  const record = fieldsOf(output)
114  const files = Array.isArray(record.filenames) ? record.filenames.filter((name): name is string => typeof name === 'string') : []
115  const truncated = record.truncated === true ? ', truncated' : ''
116
117  if (typeof record.content === 'string' && record.content.trim() !== '') {
118    const lines = clean(record.content).replace(/\n$/, '').split('\n')
119    const numLines = typeof record.numLines === 'number' ? record.numLines : lines.length
120
121    return { kind: 'matches', lines: lines.map(line => shortenPath(line, cwd)), note: `${count(numLines, 'line')}${truncated}` }
122  }
123
124  if (files.length === 0 && typeof record.numFiles !== 'number') {
125    return typeof output === 'string' ? { kind: 'matches', lines: output.trim().split('\n'), note: '' } : { kind: 'stock' }
126  }
127
128  return files.length === 0
129    ? { kind: 'none' }
130    : { kind: 'matches', lines: files.map(file => shortenPath(file, cwd)), note: `${count(files.length, 'file')}${truncated}` }
131}
132
133function webBody(tool: string, output: unknown): Body {
134  const record = fieldsOf(output)
135
136  if (tool === 'WebFetch' && typeof record.result === 'string') {
137    return { kind: 'text', text: clean(record.result), isError: false }
138  }
139
140  if (tool === 'WebSearch' && Array.isArray(record.results)) {
141    const lines = record.results.flatMap(result =>
142      typeof result === 'string'
143        ? [result]
144        : (Array.isArray(fieldsOf(result).content) ? (fieldsOf(result).content as unknown[]) : []).map(hit => {
145            const link = fieldsOf(hit)
146
147            return `${valueText(link.title)}  ${valueText(link.url)}`
148          }),
149    )
150
151    return { kind: 'matches', lines, note: count(lines.length, 'result') }
152  }
153
154  return { kind: 'stock' }
155}
156
157// An MCP tool's result: its text blocks, else the record as JSON; an image is stock.
158function genericBody(output: unknown): Body {
159  if (typeof output === 'string') {
160    return output.trim() === '' ? { kind: 'none' } : { kind: 'text', text: clean(output), isError: false }
161  }
162
163  const blocks = Array.isArray(output) ? output : Array.isArray(fieldsOf(output).content) ? (fieldsOf(output).content as unknown[]) : null
164
165  if (blocks !== null) {
166    if (blocks.some(block => fieldsOf(block).type === 'image')) {
167      return { kind: 'stock' }
168    }
169
170    const text = blocks.map(block => valueText(fieldsOf(block).text ?? block)).join('\n')
171
172    return text.trim() === '' ? { kind: 'none' } : { kind: 'text', text: clean(text), isError: false }
173  }
174
175  return output === undefined || output === null ? { kind: 'none' } : { kind: 'text', text: clean(valueText(output)), isError: false }
176}
177
178// The result of a finished call, as the opened row draws it; `none` while it runs.
179export function bodyOf(tool: string, output: unknown, isErrored: boolean, isRunning: boolean, cwd: string): Body {
180  if (isRunning || output === undefined) {
181    return { kind: 'none' }
182  }
183
184  if (SHELLS.has(tool)) {
185    const result = shellResultOf(output, isErrored)
186
187    // A command sent to the background, or one whose output is an image, is stock.
188    return result === null || fieldsOf(output).backgroundTaskId !== undefined || fieldsOf(output).isImage === true
189      ? { kind: 'stock' }
190      : { kind: 'shell', result }
191  }
192
193  if (isErrored) {
194    return { kind: 'text', text: clean(errorText(output)), isError: true }
195  }
196
197  switch (tool) {
198    case 'Read':
199      return readBody(output)
200    case 'Grep':
201    case 'Glob':
202      return searchBody(output, cwd)
203    case 'Edit':
204    case 'MultiEdit':
205    case 'Write': {
206      const diff = hunksOf(output)
207
208      return diff === null ? { kind: 'stock' } : { kind: 'diff', diff }
209    }
210    case 'WebFetch':
211    case 'WebSearch':
212      return webBody(tool, output)
213    case 'NotebookEdit':
214      return { kind: 'stock' }
215    default:
216      return genericBody(output)
217  }
218}
219
220// A diff's hunks as unified-diff text, for the engine's `Code` in its `diff` format.
221export function unifiedDiff(diff: DiffInput): string {
222  return diff.hunks
223    .map(hunk => {
224      const lines = hunk.lines.filter(line => !line.startsWith('\\'))
225      const oldCount = lines.filter(line => !line.startsWith('+')).length
226      const newCount = lines.filter(line => !line.startsWith('-')).length
227
228      return [`@@ -${hunk.oldStart},${oldCount} +${hunk.newStart},${newCount} @@`, ...lines].join('\n')
229    })
230    .join('\n')
231}
232
233// Text cut into blocks the engine takes, each with the line number it starts at.
234export function chunksOf(lines: readonly string[], startLine: number, max = MAX_BLOCK): { text: string; startLine: number }[] {
235  const chunks: { text: string; startLine: number }[] = []
236  let current: string[] = []
237  let size = 0
238  let at = startLine
239
240  for (const [i, line] of lines.entries()) {
241    const cut = line.length > max ? `${line.slice(0, max - 1)}…` : line
242
243    if (current.length > 0 && size + cut.length + 1 > max) {
244      chunks.push({ text: current.join('\n'), startLine: at })
245      at = startLine + i
246      current = []
247      size = 0
248    }
249
250    current.push(cut)
251    size += cut.length + 1
252  }
253
254  return current.length === 0 ? chunks : [...chunks, { text: current.join('\n'), startLine: at }]
255}
256
hooks/detail-rows.tsx 158 lines
1import type { RenderElement } from 'claude-code'
2
3import { chunksOf, DETAIL_LINES, MAX_BLOCK, unifiedDiff } from './detail'
4import type { Body, Field } from './detail'
5import { shortenPath } from './format'
6import { diffCard, terminalCard } from './rows'
7import type { Look } from './rows'
8import { OUTPUT_FOLD, shellRows } from './shell-rows'
9
10// An opened tool row: what the call was asked, then what it answered, under the row the
11// chevron sits on. Long answers fold behind a `show all` control of their own.
12
13export type Opened = {
14  fields: readonly Field[]
15  body: Body
16  // The shell command, for its card's header.
17  command: string
18  isErrored: boolean
19  cwd: string
20  columns: number
21  isAll: boolean
22  toggleAll: () => void
23  // Claude Code's own drawing of the call, for a body this cannot draw.
24  stock: () => Promise<RenderElement>
25}
26
27const SHELL_FOLD = { head: 8, tail: 4 }
28
29// Shell output shows whole when folding is off or the person opened it.
30const isWhole = (look: Look, opened: Opened): boolean => opened.isAll || !look.prefs.fold || look.folds?.open.has(OUTPUT_FOLD) === true
31
32// The row with its chevron at the right; `▸` closed, `▾` open.
33export function disclosedRow(look: Look, row: RenderElement, isOpen: boolean, toggle: () => void, opened?: RenderElement) {
34  const { Box, Button } = look.ui
35  const ascii = look.prefs.icons === 'ascii'
36  const chevron = isOpen ? (ascii ? 'v' : '▾') : ascii ? '>' : '▸'
37
38  return (
39    <Box flexDirection="column">
40      <Box flexDirection="row" alignItems="flex-end">
41        <Box flexGrow={1} flexShrink={1} flexDirection="column">
42          {row}
43        </Box>
44        <Box flexShrink={0} paddingLeft={1}>
45          <Button key="disclose" label={chevron} plain dimColor onPress={toggle} />
46        </Box>
47      </Box>
48      {opened ?? ''}
49    </Box>
50  )
51}
52
53function fieldRows(look: Look, fields: readonly Field[]) {
54  const { Text } = look.ui
55  const { palette } = look.skin
56
57  return fields.map(field => (
58    <Text>
59      <Text color={palette.muted}>{`${field.label}  `}</Text>
60      <Text color={palette.fg}>{field.value.length > MAX_BLOCK ? `${field.value.slice(0, MAX_BLOCK)}…` : field.value}</Text>
61    </Text>
62  ))
63}
64
65// `show all 120 lines` under a folded answer, `show less` once it is open.
66function moreButton(look: Look, total: number, opened: Opened) {
67  const { Button } = look.ui
68
69  return total <= DETAIL_LINES ? (
70    ''
71  ) : (
72    <Button key="show-all" label={opened.isAll ? 'show less' : `show all ${total} lines`} plain dimColor onPress={opened.toggleAll} />
73  )
74}
75
76function textLines(look: Look, lines: readonly string[], color: string, opened: Opened) {
77  const { Box, Text } = look.ui
78  const shown = opened.isAll ? lines : lines.slice(0, DETAIL_LINES)
79
80  return (
81    <Box flexDirection="column">
82      {shown.map(line => (
83        <Text color={color} wrap="truncate-end">
84          {line === '' ? ' ' : line}
85        </Text>
86      ))}
87      {moreButton(look, lines.length, opened)}
88    </Box>
89  )
90}
91
92async function bodyRows(look: Look, opened: Opened): Promise<RenderElement | string> {
93  const { Box, Code, Text } = look.ui
94  const { palette } = look.skin
95  const { body } = opened
96
97  switch (body.kind) {
98    case 'file': {
99      const lines = body.text.split('\n')
100      const shown = opened.isAll ? lines : lines.slice(0, DETAIL_LINES)
101
102      return (
103        <Box flexDirection="column">
104          {chunksOf(shown, body.startLine).map(chunk => (
105            <Code source={chunk.text} path={body.path} startLine={chunk.startLine} wrap="truncate-end" />
106          ))}
107          {moreButton(look, lines.length, opened)}
108        </Box>
109      )
110    }
111    case 'matches':
112      return (
113        <Box flexDirection="column">
114          {body.note === '' ? '' : <Text color={palette.muted}>{body.note}</Text>}
115          {textLines(look, body.lines, palette.fg, opened)}
116        </Box>
117      )
118    case 'shell':
119      return look.svg !== undefined
120        ? terminalCard(look, look.svg, body.result, opened.isErrored, opened.columns)
121        : shellRows(look, opened.command, body.result, opened.isErrored, {
122            stdout: isWhole(look, opened) ? null : SHELL_FOLD,
123            stderr: isWhole(look, opened) || opened.isErrored ? null : SHELL_FOLD,
124          })
125    case 'diff': {
126      const path = shortenPath(body.diff.path, opened.cwd)
127
128      if (look.svg !== undefined) {
129        return diffCard(look, look.svg, body.diff, path, opened.columns)
130      }
131
132      const source = unifiedDiff(body.diff)
133
134      // A diff cut mid-hunk no longer parses: one too long for a block is Claude Code's.
135      return source.length > MAX_BLOCK ? opened.stock() : <Code source={source} format="diff" path={body.diff.path} wrap="truncate-end" />
136    }
137    case 'text':
138      return textLines(look, body.text.split('\n'), body.isError ? palette.err : palette.fg, opened)
139    case 'none':
140      return ''
141    case 'stock':
142      return opened.stock()
143  }
144}
145
146// What an opened row holds: the input, a line's breath, the answer.
147export async function openedRows(look: Look, opened: Opened) {
148  const { Box } = look.ui
149  const body = await bodyRows(look, opened)
150
151  return (
152    <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
153      {fieldRows(look, opened.fields)}
154      {body === '' ? '' : <Box marginTop={opened.fields.length === 0 ? 0 : 1} flexDirection="column">{body}</Box>}
155    </Box>
156  )
157}
158
hooks/markdown.ts 381 lines
1// Finds the tables and fenced code in a reply so they can be drawn as cards; the rest
2// stays markdown.
3
4export type Align = 'left' | 'right' | 'center'
5
6export type Table = { kind: 'table'; header: string[]; align: Align[]; rows: string[][] }
7
8// `raw` is the fence as written, for the surfaces that keep Claude Code's own drawing.
9export type Code = { kind: 'code'; lang: string; code: string; raw: string }
10
11// `tex` is the formula alone; `raw` the block as written, kept for the copy button.
12export type Math = { kind: 'math'; tex: string; raw: string }
13
14export type Segment = { kind: 'text'; text: string } | Table | Code | Math
15
16// Fences the desktop marks runnable with a Run button of its own. A card is an image and
17// cannot carry it, so these keep Claude Code's drawing there.
18const SHELLS = new Set(['bash', 'sh', 'shell', 'zsh', 'fish', 'console', 'powershell', 'pwsh', 'ps1', 'cmd', 'bat'])
19
20export const isShell = (segment: Segment): boolean => segment.kind === 'code' && SHELLS.has(segment.lang)
21
22const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
23const FENCE = /^\s*(```|~~~)\s*([\w+#.-]*)/
24
25// Inline emphasis and code ticks read as noise in a padded cell.
26const clean = (cell: string): string =>
27  cell.replace(/\*\*|__|`/g, '').replace(/\\\|/g, '|').trim()
28
29export function cellsOf(line: string): string[] {
30  const inner = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
31
32  return inner.split(/(?<!\\)\|/).map(clean)
33}
34
35const alignOf = (cell: string): Align => {
36  const spec = cell.trim()
37
38  if (spec.startsWith(':') && spec.endsWith(':')) {
39    return 'center'
40  }
41
42  return spec.endsWith(':') ? 'right' : 'left'
43}
44
45const fit = (cells: string[], width: number): string[] =>
46  Array.from({ length: width }, (_, i) => cells[i] ?? '')
47
48// `tables` off leaves tables as text; a fence `fence` turns down stays text too, so a
49// reply can be split for its charts alone. `math` takes display formulas out as their own
50// segments: ````math`` fences, `$$…$$` and `\[…\]`.
51export type SplitOptions = { tables: boolean; fence: (lang: string, code: string) => boolean; math?: boolean }
52
53const SPLIT_ALL: SplitOptions = { tables: true, fence: () => true }
54
55export function splitReply(markdown: string, options: SplitOptions = SPLIT_ALL): Segment[] {
56  const lines = markdown.split('\n')
57  const segments: Segment[] = []
58  let text: string[] = []
59  let inFence = false
60
61  const flush = () => {
62    if (text.join('\n').trim() !== '') {
63      segments.push({ kind: 'text', text: text.join('\n') })
64    }
65
66    text = []
67  }
68
69  for (let i = 0; i < lines.length; i++) {
70    const line = lines[i] ?? ''
71    const next = lines[i + 1] ?? ''
72
73    const fence = FENCE.exec(line)
74
75    if (options.math === true && !inFence) {
76      const formula = displayMath(lines, i, fence)
77
78      if (formula !== null) {
79        flush()
80        segments.push({ kind: 'math', tex: formula.tex, raw: lines.slice(i, formula.end + 1).join('\n') })
81        i = formula.end
82        continue
83      }
84    }
85
86    // A closed fence becomes a code segment; one still streaming stays text.
87    if (fence !== null && !inFence) {
88      const close = lines.findIndex((other, j) => j > i && FENCE.test(other) && other.trim().replace(/[`~]/g, '') === '')
89
90      if (close !== -1) {
91        const lang = (fence[2] ?? '').toLowerCase()
92        const code = lines.slice(i + 1, close).join('\n')
93
94        if (!options.fence(lang, code)) {
95          text.push(...lines.slice(i, close + 1))
96          i = close
97          continue
98        }
99
100        flush()
101        segments.push({ kind: 'code', lang, code, raw: lines.slice(i, close + 1).join('\n') })
102        i = close
103        continue
104      }
105
106      inFence = true
107    } else if (fence !== null) {
108      inFence = false
109    }
110
111    const header = cellsOf(line)
112    const isTable =
113      options.tables &&
114      !inFence &&
115      line.includes('|') &&
116      SEPARATOR.test(next) &&
117      header.length > 1 &&
118      cellsOf(next).length === header.length
119
120    if (!isTable) {
121      text.push(line)
122      continue
123    }
124
125    flush()
126
127    const rows: string[][] = []
128    i += 2
129
130    while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim() !== '') {
131      rows.push(fit(cellsOf(lines[i] ?? ''), header.length))
132      i++
133    }
134
135    i--
136    segments.push({ kind: 'table', header, align: cellsOf(next).map(alignOf), rows })
137  }
138
139  flush()
140
141  return segments
142}
143
144const DISPLAY_OPEN = /^\s*(\$\$|\\\[)(.*)$/
145
146// A display formula starting on line `i`: where it ends and the TeX inside. One still
147// streaming (no closing delimiter yet) is not one, so it stays text until it closes.
148function displayMath(lines: readonly string[], i: number, fence: RegExpExecArray | null): { tex: string; end: number } | null {
149  const line = lines[i] ?? ''
150
151  if (fence !== null) {
152    if ((fence[2] ?? '').toLowerCase() !== 'math') {
153      return null
154    }
155
156    const close = lines.findIndex((other, j) => j > i && FENCE.test(other) && other.trim().replace(/[`~]/g, '') === '')
157
158    return close === -1 ? null : { tex: lines.slice(i + 1, close).join('\n'), end: close }
159  }
160
161  const open = DISPLAY_OPEN.exec(line)
162
163  if (open === null) {
164    return null
165  }
166
167  const closer = open[1] === '$$' ? '$$' : '\\]'
168  const rest = open[2] ?? ''
169  const sameLine = rest.indexOf(closer)
170
171  // Whole on one line, ` x `, with nothing but space after it.
172  if (sameLine !== -1) {
173    const tex = rest.slice(0, sameLine)
174
175    return rest.slice(sameLine + closer.length).trim() === '' && tex.trim() !== '' ? { tex, end: i } : null
176  }
177
178  for (let j = i + 1; j < lines.length; j++) {
179    const other = lines[j] ?? ''
180    const at = other.indexOf(closer)
181
182    if (at !== -1) {
183      if (other.slice(at + closer.length).trim() !== '') {
184        return null
185      }
186
187      const tex = [rest, ...lines.slice(i + 1, j), other.slice(0, at)].join('\n')
188
189      return tex.trim() === '' ? null : { tex, end: j }
190    }
191
192    // A blank line ends a paragraph, and a formula with it.
193    if (other.trim() === '') {
194      return null
195    }
196  }
197
198  return null
199}
200
201// Terminal cells a character takes: wide East Asian characters and emoji take two,
202// combining marks, zero-width joiners and skin-tone modifiers none.
203const WIDE: readonly (readonly [number, number])[] = [
204  [0x1100, 0x115f],
205  [0x2e80, 0x303e],
206  [0x3041, 0x33ff],
207  [0x3400, 0x4dbf],
208  [0x4e00, 0x9fff],
209  [0xa000, 0xa4cf],
210  [0xa960, 0xa97f],
211  [0xac00, 0xd7a3],
212  [0xf900, 0xfaff],
213  [0xfe10, 0xfe19],
214  [0xfe30, 0xfe6f],
215  [0xff00, 0xff60],
216  [0xffe0, 0xffe6],
217  [0x1f300, 0x1f64f],
218  [0x1f680, 0x1f6ff],
219  [0x1f900, 0x1f9ff],
220  [0x1fa70, 0x1faff],
221  [0x20000, 0x3fffd],
222]
223
224export const charWidth = (char: string): number => {
225  const code = char.codePointAt(0) ?? 0
226
227  if (/\p{Mn}|\p{Me}|\u200d|[\ufe00-\ufe0f]|\p{Emoji_Modifier}/u.test(char)) {
228    return 0
229  }
230
231  return WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
232}
233
234export const widthOf = (text: string): number => [...text].reduce((sum, char) => sum + charWidth(char), 0)
235
236// `text` cut to `width` cells, marked with an ellipsis where it lost text.
237const cutTo = (text: string, width: number): string => {
238  if (widthOf(text) <= width) {
239    return text
240  }
241
242  let kept = ''
243  let used = 0
244
245  for (const char of text) {
246    const next = charWidth(char)
247
248    if (used + next > width - 1) {
249      break
250    }
251
252    kept += char
253    used += next
254  }
255
256  return `${kept}…`
257}
258
259// Natural column widths, narrowed from the widest down until the table fits.
260export function columnWidths(table: Table, maxWidth: number, gap: number): number[] {
261  const widths = table.header.map((cell, col) =>
262    Math.max(widthOf(cell), ...table.rows.map(row => widthOf(row[col] ?? ''))),
263  )
264  const budget = maxWidth - gap * (widths.length - 1)
265
266  while (widths.reduce((sum, width) => sum + width, 0) > budget) {
267    const widest = widths.indexOf(Math.max(...widths))
268
269    if ((widths[widest] ?? 0) <= 3) {
270      break
271    }
272
273    widths[widest] = (widths[widest] ?? 0) - 1
274  }
275
276  return widths
277}
278
279// A cell cut to its column, marked with an ellipsis where it lost text.
280export function cutCell(text: string, width: number): string {
281  return cutTo(text, width)
282}
283
284// Splits a word too long for its column into pieces that each fit.
285function breakWord(word: string, width: number, measure: (text: string) => number): string[] {
286  const pieces: string[] = []
287  let piece = ''
288
289  for (const char of word) {
290    if (piece !== '' && measure(piece + char) > width) {
291      pieces.push(piece)
292      piece = char
293    } else {
294      piece += char
295    }
296  }
297
298  return piece === '' ? pieces : [...pieces, piece]
299}
300
301// `text` as lines that fit `width`; past `maxLines`, the last line ends in `…`. `measure`
302// is terminal cells by default; the desktop card passes its pixel measure.
303export function wrapLines(text: string, width: number, maxLines: number, measure: (text: string) => number = widthOf): string[] {
304  const lines: string[] = []
305  let line = ''
306
307  for (const word of text.split(/\s+/).filter(Boolean)) {
308    const candidate = line === '' ? word : `${line} ${word}`
309
310    if (measure(candidate) <= width) {
311      line = candidate
312      continue
313    }
314
315    if (line !== '') {
316      lines.push(line)
317    }
318
319    const pieces = measure(word) <= width ? [word] : breakWord(word, width, measure)
320    lines.push(...pieces.slice(0, -1))
321    line = pieces.at(-1) ?? ''
322  }
323
324  const all = line === '' ? lines : [...lines, line]
325
326  if (all.length <= maxLines) {
327    return all.length === 0 ? [''] : all
328  }
329
330  const kept = all.slice(0, maxLines)
331  const chars = [...(kept[maxLines - 1] ?? '')]
332
333  while (chars.length > 0 && measure(`${chars.join('')}…`) > width) {
334    chars.pop()
335  }
336
337  return [...kept.slice(0, -1), `${chars.join('')}…`]
338}
339
340export function padCell(text: string, width: number, align: Align): string {
341  const cut = cutTo(text, width)
342  const room = width - widthOf(cut)
343
344  if (align === 'right') {
345    return ' '.repeat(room) + cut
346  }
347
348  if (align === 'center') {
349    const left = Math.floor(room / 2)
350
351    return ' '.repeat(left) + cut + ' '.repeat(room - left)
352  }
353
354  return cut + ' '.repeat(room)
355}
356
357// What `/skin copy` puts on the clipboard: the whole reply, or with `code` its last code
358// block; a message instead when there is nothing to copy.
359export function copyOf(reply: string, code: boolean): { text: string } | { message: string } {
360  if (reply.trim() === '') {
361    return { message: 'Nothing to copy yet' }
362  }
363
364  if (!code) {
365    return { text: reply }
366  }
367
368  const last = splitReply(reply).findLast((segment): segment is Code => segment.kind === 'code')
369
370  return last === undefined ? { message: 'No code block in the last reply' } : { text: last.code }
371}
372
373// A table as it is drawn, boxed in monospace, for pasting where markdown does not render.
374export function tableArt(table: Table): string {
375  const widths = table.header.map((cell, col) => Math.max(widthOf(cell), ...table.rows.map(row => widthOf(row[col] ?? ''))))
376  const rule = (left: string, mid: string, right: string) => `${left}${widths.map(width => '─'.repeat(width + 2)).join(mid)}${right}`
377  const row = (cells: readonly string[]) => `│${widths.map((width, col) => ` ${padCell(cells[col] ?? '', width, table.align[col] ?? 'left')} `).join('│')}│`
378
379  return [rule('╭', '┬', '╮'), row(table.header), rule('├', '┼', '┤'), ...table.rows.map(row), rule('╰', '┴', '╯')].join('\n')
380}
381
hooks/mermaid.ts 403 lines
1// The Mermaid people actually write in a reply, read well enough to draw: flowcharts,
2// xy charts and pies here, the planning and shape kinds in their own files. Anything past
3// that (subgraphs, styling, other diagram kinds) reads as null, and the fence keeps its
4// code-block drawing.
5
6import { frontmatterOf, listOf, numbersOf, plural, statementsOf, unquote } from './mermaid-lex'
7import { parseGantt, parseJourney, parseKanban, parseTimeline } from './mermaid-plan'
8import type { Gantt, Journey, Kanban, Timeline } from './mermaid-plan'
9import { parseGitGraph, parseMindmap, parseQuadrant, parseRadar, parseSankey } from './mermaid-shape'
10import type { GitGraph, Mindmap, Quadrant, Radar, Sankey } from './mermaid-shape'
11import { parsePacket, parseTreemap } from './mermaid-data'
12import type { Packet, Treemap } from './mermaid-data'
13import { parseArchitecture, parseBlock, parseC4 } from './mermaid-system'
14import type { Architecture, Block, C4 } from './mermaid-system'
15
16export type * from './mermaid-plan'
17export type * from './mermaid-shape'
18export type * from './mermaid-data'
19export type * from './mermaid-system'
20
21export type Shape = 'box' | 'round' | 'diamond'
22
23export type FlowNode = { id: string; label: string; shape: Shape }
24
25export type FlowEdge = { from: string; to: string; label: string; line: 'solid' | 'dotted' | 'thick'; isArrow: boolean }
26
27// `ranks` are the nodes by depth, each rank in the order the source named its nodes;
28// `back` marks the edges that point up the ranks, as in a retry loop.
29export type Flow = {
30  kind: 'flow'
31  title: string
32  direction: 'down' | 'right'
33  nodes: FlowNode[]
34  edges: FlowEdge[]
35  ranks: string[][]
36  back: FlowEdge[]
37}
38
39export type Series = { kind: 'bar' | 'line'; name: string; values: number[] }
40
41export type XyChart = { kind: 'xy'; title: string; labels: string[]; yLabel: string; min: number; max: number; series: Series[] }
42
43export type Slice = { label: string; value: number }
44
45export type Pie = { kind: 'pie'; title: string; slices: Slice[] }
46
47export type Chart = Flow | XyChart | Pie | Gantt | Timeline | Journey | Kanban | Mindmap | Quadrant | Radar | Sankey | GitGraph | Treemap | Packet | Block | Architecture | C4
48
49// Past these a picture stops being easier to read than the source.
50export const MAX_NODES = 40
51const MAX_POINTS = 60
52const MAX_SLICES = 16
53
54export function parseMermaid(source: string): Chart | null {
55  const { title, lines } = frontmatterOf(source)
56  const chart = parseKind(lines)
57
58  // A frontmatter title names any chart that did not name itself.
59  return chart === null || chart.title !== '' || title === '' ? chart : { ...chart, title }
60}
61
62// The header word and the parser that reads the lines after it.
63const KINDS: readonly (readonly [RegExp, (body: readonly string[]) => Chart | null])[] = [
64  [/^xychart(-beta)?\b/, parseXy],
65  [/^gantt\b/, parseGantt],
66  [/^timeline\b/, parseTimeline],
67  [/^journey\b/, parseJourney],
68  [/^kanban\b/, parseKanban],
69  [/^mindmap\b/, parseMindmap],
70  [/^quadrantChart\b/, parseQuadrant],
71  [/^radar(-beta)?\b/, parseRadar],
72  [/^sankey(-beta)?\b/, parseSankey],
73  [/^gitGraph\b/, parseGitGraph],
74  [/^treemap(-beta)?\b/, parseTreemap],
75  [/^packet(-beta)?\b/, parsePacket],
76  [/^block(-beta)?\s*$/, parseBlock],
77  [/^architecture(-beta)?\s*$/, parseArchitecture],
78]
79
80function parseKind(lines: readonly string[]): Chart | null {
81  const start = lines.findIndex(line => line.trim() !== '' && !line.trim().startsWith('%%'))
82  const head = (lines[start] ?? '').trim()
83  const body = lines.slice(start + 1)
84
85  // `graph LR; a-->b` puts its first statements on the header line.
86  if (/^(flowchart|graph)\b/.test(head)) {
87    const [direction = '', ...inline] = head.split(';')
88
89    return parseFlow(direction.trim(), [...inline, ...body])
90  }
91
92  if (/^pie\b/.test(head)) {
93    return parsePie(head, body)
94  }
95
96  if (/^C4(Context|Container|Component|Dynamic|Deployment)\s*$/.test(head)) {
97    return parseC4(head, body)
98  }
99
100  const parse = KINDS.find(([pattern]) => pattern.test(head))?.[1]
101
102  return parse === undefined ? null : parse(body)
103}
104
105// A node reference with its optional label: `A`, `A[Box]`, `A(Round)`, `A{Choice}`, and
106// the doubled forms Mermaid draws as circles, stadiums, subroutines and hexagons.
107const NODE =
108  /^([A-Za-z0-9_]+)\s*(?:\(\((.*?)\)\)|\(\[(.*?)\]\)|\[\[(.*?)\]\]|\{\{(.*?)\}\}|\[(.*?)\]|\((.*?)\)|\{(.*?)\})?/
109
110// An edge: `-->`, `---`, `-.->`, `==>`, with a label as `-->|yes|` or `-- yes -->`.
111const EDGE =
112  /^(?:(-->|---|-\.->|-\.-|==>|===)\s*(?:\|([^|]*)\|)?|(--|-\.|==)\s*([^|>-][^>]*?)\s*(-->|---|\.->|\.-|==>|===))\s*/
113
114function readNode(text: string): { node: FlowNode; label: boolean; rest: string } | null {
115  const match = NODE.exec(text)
116
117  if (match === null) {
118    return null
119  }
120
121  const [whole, id = ''] = match
122  const groups = match.slice(2)
123  const at = groups.findIndex(group => group !== undefined)
124  const shape: Shape = at === 3 || at === 6 ? 'diamond' : at === 0 || at === 1 || at === 5 ? 'round' : 'box'
125
126  return {
127    node: { id, label: at === -1 ? id : unquote(groups[at] ?? ''), shape },
128    label: at !== -1,
129    rest: text.slice(whole.length).trim(),
130  }
131}
132
133function parseFlow(head: string, body: readonly string[]): Flow | null {
134  const direction = /\b(LR|RL)\b/.test(head) ? 'right' : 'down'
135  const nodes = new Map<string, FlowNode>()
136  const edges: FlowEdge[] = []
137
138  const meet = (read: { node: FlowNode; label: boolean }) => {
139    const known = nodes.get(read.node.id)
140
141    // A later bare mention keeps the label an earlier one gave.
142    if (known === undefined || read.label) {
143      nodes.set(read.node.id, read.node)
144    }
145  }
146
147  for (const statement of statementsOf(body)) {
148    if (/^(subgraph\b|end$|direction\s)/.test(statement)) {
149      return null
150    }
151
152    let read = readNode(statement)
153
154    if (read === null) {
155      return null
156    }
157
158    meet(read)
159
160    while (read.rest !== '') {
161      const edge = EDGE.exec(read.rest)
162
163      if (edge === null) {
164        return null
165      }
166
167      const arrow = edge[1] ?? edge[5] ?? ''
168      const from: string = read.node.id
169      const next = readNode(read.rest.slice(edge[0].length))
170
171      if (next === null) {
172        return null
173      }
174
175      meet(next)
176      edges.push({
177        from,
178        to: next.node.id,
179        label: unquote(edge[2] ?? edge[4] ?? ''),
180        line: arrow.includes('.') ? 'dotted' : arrow.includes('=') ? 'thick' : 'solid',
181        isArrow: arrow.endsWith('>'),
182      })
183      read = next
184    }
185  }
186
187  if (nodes.size === 0 || nodes.size > MAX_NODES) {
188    return null
189  }
190
191  const order = [...nodes.keys()]
192  const back = backEdges(order, edges)
193  const forward = edges.filter(edge => !back.includes(edge))
194  const depth = new Map(order.map(id => [id, 0]))
195
196  // Longest path from a source: each node one rank below the deepest node feeding it.
197  for (let pass = 0; pass < order.length; pass++) {
198    for (const edge of forward) {
199      if (edge.from !== edge.to) {
200        depth.set(edge.to, Math.max(depth.get(edge.to) ?? 0, (depth.get(edge.from) ?? 0) + 1))
201      }
202    }
203  }
204
205  const ranks: string[][] = []
206
207  for (const id of order) {
208    const rank = depth.get(id) ?? 0
209    ranks[rank] = [...(ranks[rank] ?? []), id]
210  }
211
212  return { kind: 'flow', title: '', direction, nodes: order.map(id => nodes.get(id) as FlowNode), edges, ranks: ranks.filter(rank => rank !== undefined), back }
213}
214
215// The edges that close a cycle, found walking from each node in source order.
216function backEdges(order: readonly string[], edges: readonly FlowEdge[]): FlowEdge[] {
217  const state = new Map<string, 'open' | 'done'>()
218  const back: FlowEdge[] = []
219
220  const walk = (id: string) => {
221    state.set(id, 'open')
222
223    for (const edge of edges.filter(candidate => candidate.from === id)) {
224      const seen = state.get(edge.to)
225
226      if (seen === 'open') {
227        back.push(edge)
228      } else if (seen === undefined) {
229        walk(edge.to)
230      }
231    }
232
233    state.set(id, 'done')
234  }
235
236  for (const id of order) {
237    if (!state.has(id)) {
238      walk(id)
239    }
240  }
241
242  return back
243}
244
245// A nice round step for about `count` gridlines across `span`.
246export function niceStep(span: number, count = 4): number {
247  const raw = span / count
248  const power = 10 ** Math.floor(Math.log10(raw))
249  const unit = raw / power
250
251  return (unit <= 1 ? 1 : unit <= 2 ? 2 : unit <= 2.5 ? 2.5 : unit <= 5 ? 5 : 10) * power
252}
253
254function parseXy(body: readonly string[]): XyChart | null {
255  let title = ''
256  let labels: string[] = []
257  let yLabel = ''
258  let range: [number, number] | null = null
259  const series: Series[] = []
260
261  for (const statement of statementsOf(body)) {
262    const titled = /^title\s+(.*)$/.exec(statement)
263    const xAxis = /^x-axis\s*(?:("[^"]*"|[^[\d\s-][^[]*?))?\s*(?:\[(.*)\]|(-?[\d.]+)\s*-->\s*(-?[\d.]+))?$/.exec(statement)
264    const yAxis = /^y-axis\s*(?:("[^"]*"|[^\d\s-][^\d]*?))?\s*(?:(-?[\d.]+)\s*-->\s*(-?[\d.]+))?$/.exec(statement)
265    const plotted = /^(bar|line)\s*(?:("[^"]*"|\S+)\s*)?\[(.*)\]$/.exec(statement)
266
267    if (titled !== null) {
268      title = unquote(titled[1] ?? '')
269    } else if (xAxis !== null) {
270      labels = xAxis[2] === undefined ? [] : listOf(xAxis[2])
271    } else if (yAxis !== null) {
272      yLabel = unquote(yAxis[1] ?? '')
273      range = yAxis[2] === undefined ? null : [Number(yAxis[2]), Number(yAxis[3])]
274    } else if (plotted !== null) {
275      const values = numbersOf(plotted[3] ?? '')
276
277      if (values === null || values.length === 0 || values.length > MAX_POINTS) {
278        return null
279      }
280
281      series.push({ kind: plotted[1] === 'line' ? 'line' : 'bar', name: unquote(plotted[2] ?? ''), values })
282    } else {
283      return null
284    }
285  }
286
287  if (series.length === 0) {
288    return null
289  }
290
291  const points = Math.max(...series.map(one => one.values.length))
292  const all = series.flatMap(one => one.values)
293  const low = Math.min(0, ...all)
294  const high = Math.max(...all)
295  const [min, max] = range ?? [low, high === low ? low + 1 : low + Math.ceil((high - low) / niceStep(high - low)) * niceStep(high - low)]
296
297  if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) {
298    return null
299  }
300
301  return {
302    kind: 'xy',
303    title,
304    labels: Array.from({ length: points }, (_, i) => labels[i] ?? String(i + 1)),
305    yLabel,
306    min,
307    max,
308    series,
309  }
310}
311
312function parsePie(head: string, body: readonly string[]): Pie | null {
313  let title = unquote(/\btitle\s+(.*)$/.exec(head)?.[1] ?? '')
314  const slices: Slice[] = []
315
316  for (const statement of statementsOf(body)) {
317    const titled = /^title\s+(.*)$/.exec(statement)
318    const slice = /^"([^"]*)"\s*:\s*(\d+(?:\.\d+)?)$/.exec(statement)
319
320    if (titled !== null) {
321      title = unquote(titled[1] ?? '')
322    } else if (slice !== null) {
323      slices.push({ label: unquote(slice[1] ?? ''), value: Number(slice[2]) })
324    } else {
325      return null
326    }
327  }
328
329  const total = slices.reduce((sum, slice) => sum + slice.value, 0)
330
331  return slices.length === 0 || slices.length > MAX_SLICES || total <= 0 ? null : { kind: 'pie', title, slices }
332}
333
334// What a chart is called in a card header and how much it holds.
335export function chartHeading(chart: Chart): { kind: string; count: string } {
336  switch (chart.kind) {
337    case 'flow':
338      return { kind: 'Flowchart', count: plural(chart.nodes.length, 'step') }
339    case 'xy':
340      return { kind: 'Chart', count: chart.series.length > 1 ? `${chart.series.length} series` : plural(chart.labels.length, 'point') }
341    case 'pie':
342      return { kind: 'Pie', count: plural(chart.slices.length, 'slice') }
343    case 'gantt':
344      return { kind: 'Gantt', count: plural(chart.tasks.length, 'task') }
345    case 'timeline':
346      return { kind: 'Timeline', count: plural(chart.periods.length, 'period') }
347    case 'journey':
348      return { kind: 'Journey', count: plural(chart.steps.length, 'step') }
349    case 'kanban':
350      return { kind: 'Kanban', count: plural(chart.columns.reduce((sum, column) => sum + column.cards.length, 0), 'card') }
351    case 'mindmap':
352      return { kind: 'Mindmap', count: plural(chart.count, 'node') }
353    case 'quadrant':
354      return { kind: 'Quadrant', count: plural(chart.points.length, 'point') }
355    case 'radar':
356      return { kind: 'Radar', count: plural(chart.curves.length, 'curve') }
357    case 'sankey':
358      return { kind: 'Sankey', count: plural(chart.links.length, 'flow') }
359    case 'git':
360      return { kind: 'Git graph', count: plural(chart.commits.length, 'commit') }
361    case 'treemap':
362      return { kind: 'Treemap', count: plural(chart.leaves, 'item') }
363    case 'packet':
364      return { kind: 'Packet', count: plural(chart.bits, 'bit') }
365    case 'block':
366      return { kind: 'Block diagram', count: plural(chart.boxes, 'block') }
367    case 'architecture':
368      return { kind: 'Architecture', count: plural(chart.services.filter(service => !service.isJunction).length, 'service') }
369    case 'c4':
370      return { kind: `C4 ${chart.level.toLowerCase()}`, count: plural(chart.boxes, 'element') }
371  }
372}
373
374// A number as a chart labels it: whole where it is, else to two places, with thousands
375// grouped.
376export const formatValue = (value: number): string =>
377  value.toLocaleString('en-US', { maximumFractionDigits: Number.isInteger(value) ? 0 : 2 })
378
379// A share as a percent, one decimal under 10% and none where it would read `9.0%`.
380export const formatPercent = (share: number): string => `${Number((share * 100).toFixed(share < 0.1 ? 1 : 0))}%`
381
382// A series' legend name: its own, else its kind, numbered among the unnamed ones of that kind.
383export function seriesName(chart: XyChart, i: number): string {
384  const series = chart.series[i]
385
386  if (series === undefined || series.name !== '') {
387    return series?.name ?? ''
388  }
389
390  const same = chart.series.filter(other => other.kind === series.kind && other.name === '')
391
392  return same.length === 1 ? series.kind : `${series.kind} ${same.indexOf(series) + 1}`
393}
394
395// What reaches the model: one short paragraph, sent only while charts are on.
396export const CHART_HINT = [
397  '# Charts',
398  'Fenced ```mermaid draws as a picture here: flowchart TD|LR, xychart-beta (bar/line), pie, gantt, timeline, journey,',
399  'kanban, mindmap, quadrantChart, radar-beta, sankey-beta, gitGraph, treemap-beta, packet-beta, block-beta,',
400  'architecture-beta, C4Context; on the terminal also sequence, state, class and ER diagrams.',
401  'No subgraphs or styling, at most 40 nodes. Draw one when a process, schedule, numeric comparison or breakdown reads better as a picture.',
402].join(' ')
403
hooks/command-output.ts 75 lines
1import { splitReply } from './markdown'
2import type { Segment, Table } from './markdown'
3
4// What a slash command printed, as the segments a reply is drawn from: its own tables and
5// fences, or a run of `key: value` lines (`/cost`, `/status`) as a two-column table. Null
6// when there is nothing to draw, so the row keeps Claude Code's drawing.
7
8const PAIR = /^\s*([^:|`]{1,40}?):\s+(\S.*?)\s*$/
9
10// Colour codes some commands still print; a card cannot show them.
11const ANSI = /\u001b\[[0-9;]*m/g
12
13const pairOf = (line: string): [string, string] | null => {
14  const match = PAIR.exec(line)
15
16  return match === null ? null : [(match[1] ?? '').trim(), match[2] ?? '']
17}
18
19function pairTables(text: string, command: string): Segment[] {
20  const segments: Segment[] = []
21  let prose: string[] = []
22  let pairs: [string, string][] = []
23
24  const flushProse = () => {
25    if (prose.join('\n').trim() !== '') {
26      segments.push({ kind: 'text', text: prose.join('\n') })
27    }
28
29    prose = []
30  }
31
32  // One pair alone reads better as the line it was.
33  const flushPairs = () => {
34    if (pairs.length >= 2) {
35      flushProse()
36      const table: Table = { kind: 'table', header: [command, ''], align: ['left', 'left'], rows: pairs }
37      segments.push(table)
38    } else {
39      prose.push(...pairs.map(([key, value]) => `${key}: ${value}`))
40    }
41
42    pairs = []
43  }
44
45  for (const line of text.split('\n')) {
46    const pair = pairOf(line)
47
48    if (pair !== null) {
49      pairs.push(pair)
50      continue
51    }
52
53    flushPairs()
54    prose.push(line)
55  }
56
57  flushPairs()
58  flushProse()
59
60  return segments
61}
62
63export function commandSegments(raw: string, command: string): Segment[] | null {
64  const text = raw.replace(ANSI, '')
65  const split = splitReply(text)
66
67  if (split.some(segment => segment.kind !== 'text')) {
68    return split
69  }
70
71  const paired = pairTables(text, command === '' ? 'output' : command)
72
73  return paired.some(segment => segment.kind === 'table') ? paired : null
74}
75