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…

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

Draws

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

<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

Needs Claude Code 2.1.287 or later, in a terminal or the desktop app's Code tab. Run both inside Claude Code:

/plugin marketplace add hellosverre/claude-skins
/plugin install skins@hellosverre-mods

Restart Claude Code. The noir skin is on; type /skin to change it.

Update

skins checks for a new release once a day and shows a toast when you are behind. To update:

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

Then restart Claude Code. Open sessions keep the version they started with.

Uninstall

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