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
v0.5.13MITupdated 2026-10-20woolleymusic/claude-mods/mods/skins
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skins
│ ┃ Skins ✕ │ ┃ Skin 1: noir ● 2: tokyo-night 3: dracula ╭─────────────────────────────────────────────────────────────────╮ │ ┃ │ fix the failing auth test and add an audit log call │ │ ┃ ╭─────────────────────────────────────────── ╰─────────────────────────────────────────────────────────────────╯ │ ┃ │ │ ┃ │ ╭──────────────────────────────────────╮ │ ┃ │ │ add rate limiting to the hub api │ │ ┃ │ ╰──────────────────────────────────────╯ ● 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) │ ┃ │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ●─ Read 2 · Search 1 ▸ │ ┃ │ Done. I made refresh reject expired claims and added an audit call. │ ┃ │ ╭──────────────────────────────────────╮ │ ┃ │ │ Route Limit Window │ │ ┃ │ │ /chat 60 1 min │ ◆ Done in 42s · 9 tools │ ┃ │ │ /upload 10 1 min │ │ ┃ │ │ /search 120 1 min │ › /skin │ ┃ │ ╰──────────────────────────────────────╯ │ ┃ │ │ ┃ │ │ ┃ │ ⠋ 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% full 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.

Looking for more mods? /plugin install mod-store@hellosverre-mods, then /mods browses and installs about 2,700 of them from a pane, showing what each one can access first.

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

/skin help lists every command in Claude Code; /skin list shows every skin and setting.

Skins

CommandWhat it does
/skinSettings: pick a skin with a live preview, toggle parts, repaint any colour
/skin <name>Switch skin (names below)
/skin listEvery skin, yours included, and every setting
/skin helpEvery command, in a table
/skin off · onBack to Claude Code's own drawing, or skins back on
/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
/skin copy · copy codeCopy Claude's last reply, or just its last code block
/skin calm · calm offNo motion, one line per tool row, read-only output folded, failures always whole; calm off puts back what you had

Built-in skins: noir, tokyo-night, dracula, catppuccin, rose-pine, nord, gruvbox, kanagawa, everforest, one-dark, solarized, night-owl, ayu, github, mono.

Options

CommandWhat it does
/skin tables on/text/offTables as cards, as selectable text on the desktop, or Claude Code's own
/skin prompt left/right/plainIn the terminal, which side your prompt's outline sits on, or a plain leading marker. The desktop keeps Claude Code's own messages, with copy and rewind
/skin words claude/skinWhose words the spinner and the turn footer show: Claude Code's own, or the skin's (the default)
/skin icons unicode/asciiThe icon set, and ASCII-only diagrams in the terminal

Parts, each switched with /skin <part> on or off:

PartWhat it does
railThe coloured rail beside tool rows
shimmerThe band of light across the spinner word
bandThe usage meters above the prompt
countdownTime left on each limit in the band
compactThe Compact button in the band, with a nudge from 70% context (on by default)
clipTrim Claude Code's own shell output to its head and tail
markdownAlerts and task lists drawn
quietReads, searches and read-only commands folded to one row
chartsMermaid fences as charts
mathMath typeset
commandsSlash-command output as tables and cards
shellShell output as a card
highlightCode blocks highlighted in the skin's colours
hintsTell Claude that charts draw here
linksURLs and paths as links
copyCopy buttons on cards
foldLong blocks fold behind ▾ N more
expandEvery run of reads and searches open, instead of the one line Read 3 · Search 2 ▸; off by default

Design one with Claude. Ask "make me a skin that feels like a sunset". skins gives Claude a design tool, so it builds the skin and applies it while you watch. Repainting a built-in skin saves it as your own my-<skin>. Choices are remembered across sessions.

Preview

Drawn by the mod's own card code (scripts/previews.ts), not screenshots.

<img alt="A Claude Code turn with the noir skin: tool rows with icons, an edit as a diff card, and a table card" src="docs/previews/hero-dark.svg">

<img alt="The four spinner animations: thinking, running a tool, writing, waiting" src="docs/previews/spinners-dark.svg">

<img alt="Shell output in a terminal card" src="docs/previews/terminal-dark.svg">

<img alt="A code block as a card with line numbers" src="docs/previews/code-dark.svg">

<img alt="Context and plan limit rings" src="docs/previews/usage-dark.svg">

What it redraws

PartDesktop appTerminal
Tool callsIcon per kind, spinner while running, lines changed, time taken. ▸ opens the row: the call's input in full and its answer (a read's text with line numbers, a search's matches, the shell card, the diff); a failed call opens by itselfA node on the turn's rail, with the same ▸
EditsDiff card with +N −M and numbered linesClaude Code's own diff
Shell commandsTerminal card with status, stderr apart, long output folded; paths, numbers and error, warning and pass words colouredCard with the command, the output coloured the same way, ✓ or ✗ exit N, stderr in red under its own label, each stream folded to 8 + 4 lines (/skin shell off for Claude Code's own)
Quiet output (off by default)Reads, searches and read-only commands fold to one row; a failure keeps its error lineThe same
TablesCard with header rule, zebra rows, colour swatchesCell grid with a header band
Charts `mermaid as a card: flowchart, xy, pie, gantt, timeline, journey, kanban, mindmap, quadrant, radar, sankey, gitGraph, treemap, packet, block, architecture, C4The same seventeen drawn in cells, ASCII with /skin icons ascii
Math$$…$$, \[…\] and `math as a typeset card: fractions, roots, sums and integrals with limits, matrices, aligned lines; $…$ inline turns into UnicodeFractions and limits stacked in cells, inline $…$ as Unicode
Command output/cost, /context and plugin output: tables, code and key: value runs as cardsThe same as cell grids
Alerts and task lists> [!NOTE] as a titled box, - [ ] with ticks and a done countThe same
Code blocksCard with language, line numbers, highlighting; shell blocks keep the Run buttonFramed, the language on the frame, highlighted in the skin's colours: TS/JS, JSON, shells, PowerShell, Python, Rust, Go, C/C++, C#, F#, Java, YAML, TOML, HTML/XML, CSS, SQL, diff, Markdown, Lua (/skin highlight off for Claude Code's own)
SpinnerAnimated icon per phaseThe skin's word with a band of light
Above the promptContext and plan-limit rings, Compact button, nudge at 70%Block meters, same button
Your promptsClaude Code's own, so copy, rewind and selection stayRounded outline, or a plain marker
Links and filesURLs are the app's own links; tool rows colour files this turn created (ok) or edited (warn)URLs in prose are OSC 8 links where the terminal draws them (FORCE_HYPERLINK=1 or 0 overrides the check), plain text elsewhere; paths with :line, numbers and versions coloured, this turn's files in ok or warn in rows and prose
Diff fences `diff as the diff card, with Copy and new only (the code after the change)Numbered old and new lines, the + and - in the skin's colours and the code highlighted in the language its +++ path or a `diff-fsharp tag names, the same two copies
Side by sideTwo or more tables or charts in a row share it when each gets 75 columns; stack otherwiseTables and diagrams in a row sit side by side while their natural widths fit; stack otherwise
Long blocksCode cards past 24 lines cut there, with ▾ N moreCode past 24 lines, tables past 14 rows and shell output past 8 + 4 lines fold behind ▾ N more / ▴ less

Every card has a Copy button (tables also as text, the table as drawn; a reply of several blocks ends in copy reply) and animates once, on first draw, respecting reduced motion. Only the drawing changes: the stored conversation and what the model reads are untouched. Agents, plan mode and the permission prompt keep Claude Code's own drawing.

Chart hints. While charts are on, Claude gets a short model-only note (about 130 tokens, estimated from its 458 characters) saying `mermaid fences draw here, so it reaches for a chart when one reads better. Never for headless claude -p runs. /skin hints off drops it and keeps charts drawing.

Light and dark. Every skin has both and follows Claude Code's theme. On auto it follows your terminal (COLORFGBG) or the system. Force one with SKINS_THEME=light or SKINS_THEME=dark.

For other mods

skins adds $.skins.markdown to $. It draws markdown the way skins draws a reply, in the person's skin. It answers a tree, or undefined when skins is off or the text holds nothing it draws, and throws on a malformed argument. It draws no copy or fold controls, because a press cannot cross from one mod's tree to another's. List skins under dependencies in your plugin.json and Claude Code lays its types into your .claude-plugin/types/.

let drawn
try {
  drawn = await $.skins.markdown({ surface: e.surface, text, columns: e.props.bodyColumns })
} catch {}
return drawn ?? <Markdown text={text} />

Compatibility

Checked by hand on Windows (terminal and desktop app, 0.3.0) and run on Linux (terminal, 0.5.0, Claude Code 2.1.288, 168 engine tests). CI runs the tests, the manifest checks and the type check on Linux, Windows and macOS. macOS, VS Code and mobile have not been looked at by hand. The surface × OS matrix and render times per row are in docs/compat.md. To measure on your machine:

scripts/bench.sh

Known limitations

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

Compared with

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

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

Where they are better: tweakcc recolours all of Claude Code, not only what a hook can redraw, at the cost of patching the binary. Prismantis handles right-to-left text, copies tables as HTML and typesets math as images. glint makes file paths clickable and keeps a band of the last reply's links. ccstatusline has a far deeper status line. claude-gfm-render draws sequence and other diagrams as SVG on the desktop, where skins falls back to a code card. What skins plans next is in ROADMAP.md.

Privacy

skins draws and remembers, nothing else. It reads the session directory, your context and plan usage, the theme setting and SKINS_THEME / COLORFGBG, and keeps its settings in the mod store. Its one network call is the daily version check: a GET of this repo's plugin.json on raw.githubusercontent.com, sending nothing. On auto it asks the OS for light or dark mode (defaults on macOS, gsettings on GNOME). It compacts or copies only when you press the button. Check it yourself with claude plugin validate ..

Vendored code

Terminal diagrams are laid out by beautiful-mermaid (MIT), bundled into hooks/vendor/mermaid-ascii.js with its licence in the header. To rebuild it:

cd scripts/vendor && npm install && npm run build

Develop

git clone https://github.com/hellosverre/claude-skins && cd claude-skins
claude --plugin-dir .

Saving a file reloads the mod in the running session. Tests: claude plugin test .. Previews: npx tsx scripts/previews.ts. A new built-in skin is one file: copy hooks/themes/nord.ts, change the colours and words, add a line to hooks/themes/index.ts.

More mods

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

License

MIT

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