Skins for Claude Code's transcript: themed tool rows, reply gutters and spinner words. /skin swaps them live.

A one-line band above the Claude Code prompt, a roomy Cockpit panel beside the conversation, and seven community mods, installed as one plugin. The band shows only what you glance at. The panel has everything else: prompt-cache countdown, context, plan limits with their reset times, session cost, your next tasks and project shortcuts. Everything that spends tokens or redraws your screen starts off.
◔ 42m ctx 72.5k Next Write docs/specs/PLAN-2.md for the phase 2 slices Continue Details
The band is drawn with Claude's own text and buttons, so it takes the app's fonts and theme, truncates the task by itself, and cannot flash or resize. Details (or /cockpit) opens the panel as a tab in the right-hand side panel.
<img src="docs/images/band.png" alt="The band above the prompt in the Claude desktop app: working, context 704.8k at 70 percent, the next task, and a Details button" width="720">
<img src="docs/images/panel.png" alt="The Cockpit panel in the side panel: prompt cache with a green bar, context at 70 percent, plan limits with reset times, session cost, next up and shortcuts" width="320">
These are screenshots of the real desktop app, showing the dark theme.
| Plugin | What it does | Default here | Origin |
|---|---|---|---|
| cockpit | The band, the Cockpit panel, next-task list, Continue / What's next / Compact buttons, guards, and a cheat-sheet skill | on | this repo (MIT) |
| cache-tax | Shows the rewrite price before a cold send; opt-in keep-warm for the prompt cache | warns, never drops; keep-warm off | karanb192/cache-tax |
claude-reflect (reflect-mod) | Notices corrections and offers to save them as rules in CLAUDE.md | paused | BayramAnnakov/claude-reflect |
| skins | Redraws the transcript: tool rows, diff cards, table and code cards, spinner | off, its own band off | hellosverre/claude-skins |
| filetree | IDE-style file tree in the sidebar with git status and read/write activity | opens with /filetree (does not dock by itself) | data-goblin/claude-code-filetree |
| replay-theater | Step through the last turn's file edits, one diff at a time | on | anthropics/claude-code-playground |
| blast-radius | Holds rm -r, git reset --hard, git clean, force push and migrations until you press Proceed | on | anthropics/claude-code-playground |
| savvy-progress | Progress bar and agents panel for flows and subagents | idle until something reports | JohnnyVizz/claude-kit |
The seven community mods are vendored here at pinned commits, with small patches (listed in each folder's SOURCE.md) so they start quiet and stack above each other instead of replacing each other's band. Their original licences are kept.
Needs Claude Code 2.1.287 or later.
/plugin marketplace add cGradying/claude-code-cockpit
/plugin install cockpit@janvin-mods
or from a terminal:
claude plugin marketplace add cGradying/claude-code-cockpit
claude plugin install cockpit@janvin-mods --scope user
cockpit lists the seven mods as dependencies, so installing it installs them all. If a dependency does not install by itself, install it by name, for example claude plugin install skins@janvin-mods. Then restart Claude Code (or the desktop app) and open a new session.
To remove everything: /plugin, pick cockpit, uninstall, and do the same for each mod you no longer want. Each one is independent.
The band. One line above the prompt: the cache countdown (◔ 42m, green, then amber, then red), context, and the next task. The 5-hour and weekly limits join the line only once they pass 60%, and 9router down shows only if it was up and stopped. Buttons sit at the right; click them, or focus the band (ctrl+x then tab) and press the key.
| Button | Key | Does |
|---|---|---|
| Continue | c | Sends "continue with the next task" for the first open line of .claude/NEXT.md, and asks Claude to check it off when done. Hidden while a turn runs. |
| Details | d | Opens the Cockpit panel. |
| Compact | k | Appears past 70% context. Runs /compact and tells it to keep the plan, open tasks, PRs, decisions and open bugs. |
The Cockpit panel. One tab in the side panel with room for each number: prompt cache (time left, a bar, what happens if it lapses), context (tokens, percent, window size, Compact), plan limits (each with a bar and when it resets), this session (cost, 9router), your next five tasks (Continue, What's next?), and your project shortcuts. Bars are solid boxes sized in percent and follow the app's light or dark theme. Open it with Details or /cockpit.
Guards that run quietly. A shell command carrying a Claude or Anthropic co-author line is refused. A prompt containing a pasted API key is held once (press Enter again to send it anyway). Prompts about UI or design get a hidden nudge toward the impeccable skill if you have it.
Commands. /cockpit, /next, /keepwarm, /cache-tax, /reflect-pause, /reflect-queue, /skin, /filetree, /replay, /agents-info. Details in docs/COMMANDS.md; inside Claude Code, the cockpit:help skill prints the same cheat sheet.
Three things in the bundle can cost tokens or change your screen, so they start off:
/keepwarm. The cold-send guard only warns by default: it shows the rewrite price and still sends your message./reflect-pause turns it on. remember: <rule> works either way and needs no model./skin noir turns it on.How to change each: docs/CONFIGURATION.md.
NEXT.md, quick.json./filetree (set autoOpen to on to dock it at session start). It needs a wide window, and upstream says it does not load in WSL sessions of the desktop app.The code in cockpit/ is MIT, Copyright (c) 2026 cGradying (see LICENSE). Each vendored mod keeps its own licence file and authorship: the two Anthropic mods are Apache-2.0, the rest are MIT. See THIRD_PARTY_NOTICES.md. Thanks to the authors of all seven.
hooks/register.tsx 654 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3
4import type { CustomSkin, Prefs, SkinSlot, TurnStats, UsageSnap } from '../types'
5import { DEFAULT_PREFS, nextTables, parsePrefs, runSkinCommand, TOGGLES } from './command'
6import { buildCustom, resolveSkin, skinNames, withSlot } from './custom'
7import { forTheme, resolveLight } 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 { isShell, splitReply } from './markdown'
13import { askBand, desktopSpinnerRow, diffCard, footerRow, terminalCard, usageBand, groupRow, promptRow, replyRows, spinnerRow, toolRow } from './rows'
14import type { Look, SvgElement, Ui } from './rows'
15import { galleryPane } from './gallery'
16import { settingsPane } from './settings'
17import { sightings } from './sightings'
18import { ICONS } from './skin'
19import type { Skin } from './skin'
20import { hunksOf } from './svg-diff'
21import { shellOutputOf } from './svg-terminal'
22import { limitLabel, metersOf } from './svg-usage'
23import { shortenPath } from './format'
24import { kindOf, summarize } from './tools'
25
26const SETTINGS = 'skins-settings'
27const GALLERY = 'skins-gallery'
28const DESIGN = `mcp__skins__${DESIGN_TOOL.name}`
29const DESIGN_MATCH = /^mcp__skins__design$/
30
31// Markdown takes at most 10000 characters a block; a longer prompt keeps Claude Code's
32// own drawing, which folds a big paste.
33const MAX_MARKDOWN = 9000
34const MAX_PROMPT = 4000
35
36const CLIP_HEAD = 8
37const CLIP_TAIL = 4
38const FRAME_MS = 90
39const KEPT_TURNS = 40
40
41const prefsAtom = atom({ plugin: 'skins', key: 'prefs' } as const, DEFAULT_PREFS)
42const customAtom = atom({ plugin: 'skins', key: 'custom' } as const, {})
43const startedAtom = atom({ plugin: 'skins', key: 'startedAt' } as const, 0)
44const frameAtom = atom({ plugin: 'skins', key: 'frame' } as const, 0)
45const turnsAtom = atom({ plugin: 'skins', key: 'turns' } as const, {})
46const durationAtom = atom({ plugin: 'skins', key: 'duration' } as const, -1)
47const editingAtom = atom({ plugin: 'skins', key: 'editing' } as const, 'user' as SkinSlot)
48const lightAtom = atom({ plugin: 'skins', key: 'isLight' } as const, false)
49const imagesAtom = atom({ plugin: 'skins', key: 'images' } as const, false)
50const usageAtom = atom({ plugin: 'skins', key: 'usage' } as const, { context: null, limits: [] } as UsageSnap)
51const compactingAtom = atom({ plugin: 'skins', key: 'compacting' } as const, false)
52const pinnedAtom = atom({ plugin: 'skins', key: 'pinned' } as const, false)
53
54const EDITS = new Set(['Edit', 'MultiEdit', 'Write'])
55
56// Only a person's own typing becomes a prompt row: a task notification or a peer's
57// message is not theirs to dress as theirs.
58const TYPED = new Set(['composer', 'bridge', 'sdk'])
59
60type Active = { prefs: Prefs; skin: Skin; custom: Record<string, CustomSkin> }
61
62const NO_STATS: TurnStats = { tools: 0, added: 0, removed: 0 }
63
64async function activeSkin($: EngineInterface): Promise<Active | null> {
65 const prefs = await read($, prefsAtom)
66 const custom = await read($, customAtom)
67 const skin = resolveSkin(prefs.skin, custom)
68
69 return skin === undefined ? null : { prefs, skin: forTheme(skin, await read($, lightAtom)), custom }
70}
71
72// The system's appearance, for an `auto` theme: macOS's AppleInterfaceStyle, else GNOME's
73// color-scheme. Undefined when neither answers.
74async function systemDark($: EngineInterface): Promise<boolean | undefined> {
75 try {
76 const mac = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 2000 })
77
78 // Unset (exit 1, "does not exist") is how macOS says light.
79 return mac.exitCode === 0 ? mac.stdout.trim() === 'Dark' : /does not exist/.test(mac.stderr) ? false : undefined
80 } catch {}
81
82 try {
83 const gnome = await $.process.run(['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], {
84 timeoutMs: 2000,
85 })
86
87 return gnome.exitCode === 0 ? gnome.stdout.includes('dark') : undefined
88 } catch {
89 return undefined
90 }
91}
92
93// Claude Code's own theme decides whether skins draw for a light or a dark background:
94// `SKINS_THEME` first, then the theme setting, and for `auto` the terminal and the system.
95async function refreshTheme($: EngineInterface): Promise<void> {
96 const rows = await $.config.list()
97 const hints = {
98 override: await $.env.get('SKINS_THEME'),
99 theme: rows.find(row => row.key === 'theme')?.value,
100 colorfgbg: await $.env.get('COLORFGBG'),
101 }
102 // Asks the system only when nothing before it decided.
103 const needsSystem = resolveLight(hints) !== resolveLight({ ...hints, systemDark: false })
104 const isLight = resolveLight({ ...hints, systemDark: needsSystem ? await systemDark($) : undefined })
105
106 await update($, lightAtom, () => isLight)
107}
108
109// Every surface's element table names Svg, but the terminal draws it as nothing, so
110// vector icons are for the other surfaces only.
111const lookOf = (
112 ui: Ui & { Svg?: SvgElement },
113 active: Active,
114 surface: RenderSurface,
115 copy?: (text: string) => void,
116): Look => ({
117 ui,
118 skin: active.skin,
119 icons: ICONS[active.prefs.icons],
120 prefs: active.prefs,
121 surface,
122 ...(surface !== 'terminal' && ui.Svg !== undefined ? { svg: ui.Svg } : {}),
123 ...(copy === undefined ? {} : { copy }),
124})
125
126// A drawing's cards animate on their first draw only, keyed by the drawing's instance
127// (the message id, the tool_use_id) so two replies never share a card.
128const seenCards = sightings()
129const drawnOnce = (instance: string) => (key: string) => seenCards(`${instance}:${key}`)
130
131// Made skins from the store, each checked again: the store may hold an older shape.
132function parseCustom(raw: unknown): Record<string, CustomSkin> {
133 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
134
135 return Object.fromEntries(
136 Object.values(saved)
137 .map(draft => buildCustom(draft as Record<string, unknown>, undefined))
138 .filter((skin): skin is CustomSkin => typeof skin !== 'string')
139 .map(skin => [skin.name, skin]),
140 )
141}
142
143async function load($: EngineInterface): Promise<void> {
144 const custom = parseCustom(await $.store.get('custom'))
145 const names = skinNames(custom)
146 const folders = parseFolders(await $.store.get('folders'), names)
147 const folder = await $.session.cwd()
148 const prefs = prefsFor(folder, folders, parsePrefs(await $.store.get('prefs'), names))
149
150 await update($, customAtom, () => custom)
151 await update($, prefsAtom, () => prefs)
152 await update($, pinnedAtom, () => Object.hasOwn(folders, folder))
153}
154
155// A pinned folder keeps its prefs to itself; every other folder shares the default.
156async function savePrefs($: EngineInterface, prefs: Prefs): Promise<void> {
157 if (!(await read($, pinnedAtom))) {
158 await $.store.set('prefs', prefs)
159
160 return
161 }
162
163 const names = skinNames(await read($, customAtom))
164 const folders = parseFolders(await $.store.get('folders'), names)
165 await $.store.set('folders', withFolder(folders, await $.session.cwd(), prefs))
166}
167
168// /skin pin, unpin and share: this folder's own look, or the default.
169async function runFolderCommand($: EngineInterface, word: string): Promise<string> {
170 const folder = await $.session.cwd()
171 const custom = await read($, customAtom)
172 const names = skinNames(custom)
173 const folders = parseFolders(await $.store.get('folders'), names)
174 const prefs = await read($, prefsAtom)
175
176 switch (word) {
177 case 'pin':
178 await $.store.set('folders', withFolder(folders, folder, prefs))
179 await update($, pinnedAtom, () => true)
180
181 return 'this folder keeps its own look'
182 case 'unpin': {
183 const fallback = parsePrefs(await $.store.get('prefs'), names)
184 await $.store.set('folders', withoutFolder(folders, folder))
185 await update($, pinnedAtom, () => false)
186 await update($, prefsAtom, () => fallback)
187
188 return `this folder follows the default: ${fallback.skin}`
189 }
190 default:
191 await $.store.set('prefs', prefs)
192
193 return `default look: ${prefs.skin}`
194 }
195}
196
197async function refreshUsage($: EngineInterface): Promise<void> {
198 const usage = await $.session.usage()
199 const snap: UsageSnap = {
200 context: usage.context.percent ?? null,
201 limits: usage.rateLimits.map(limit => ({ label: limitLabel(limit.kind), percent: limit.percentUsed })),
202 }
203
204 // The same numbers keep the same value, so the band is not redrawn and its rings do not
205 // fill again.
206 await update($, usageAtom, previous => (JSON.stringify(previous) === JSON.stringify(snap) ? previous : snap))
207}
208
209async function commit($: EngineInterface, state: DesignState): Promise<void> {
210 await update($, customAtom, () => state.custom)
211 await update($, prefsAtom, () => state.prefs)
212 await $.store.set('custom', state.custom)
213 await savePrefs($, state.prefs)
214}
215
216async function designState($: EngineInterface): Promise<DesignState> {
217 return { prefs: await read($, prefsAtom), custom: await read($, customAtom) }
218}
219
220export const register: Register = on => {
221 // What the turn on the main loop has done so far, for its footer.
222 let stats: TurnStats = NO_STATS
223 let isWorking = false
224 let ticker: Timer | undefined
225 // The width the last reply was drawn at, shown by /skin list to tune table sizing.
226 let lastColumns: number | undefined
227
228 on('session.start', async ($, e, next) => {
229 await $.command.register({
230 name: 'skin',
231 description: 'Open the skin settings, or /skin <name | list | off>',
232 argumentHint: '[gallery | name | list | off | pin | unpin | share | rail | tables | shimmer | band | clip | icons]',
233 immediate: true,
234 })
235 await $.tool.register({
236 name: DESIGN_TOOL.name,
237 description: DESIGN_TOOL.description,
238 inputSchema: DESIGN_TOOL.inputSchema,
239 })
240 await load($)
241 await refreshUsage($)
242 await refreshTheme($)
243
244 // Only the spinner reads the frame, so a tick redraws the spinner and nothing else.
245 ticker?.cancel()
246 ticker = $.clock.every(FRAME_MS, () => {
247 if (isWorking) {
248 void update($, frameAtom, frame => frame + 1)
249 }
250 })
251
252 return next(e)
253 })
254
255 // /clear, /resume and /branch reset $.state to its defaults and skip session.start.
256 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
257 await load($)
258 await refreshTheme($)
259
260 return next(e)
261 })
262
263 on('turn.start', async ($, e, next) => {
264 stats = NO_STATS
265 isWorking = true
266 const now = await $.clock.now()
267 await update($, startedAtom, () => now)
268
269 return next(e)
270 })
271
272 on('config.set', async ($, e, next) => {
273 const result = await next(e)
274
275 if (e.key === 'theme') {
276 await refreshTheme($)
277 }
278
279 return result
280 })
281
282 // Notes which prompts carried images, so their row keeps Claude Code's drawing of them.
283 on('session.append', async ($, e, next) => {
284 const stored = await next(e)
285 const hasImage = e.door === 'prompt' && e.message.content.some(block => block.type === 'image')
286
287 if (hasImage && stored.uuid !== undefined) {
288 await update($, memberOf(imagesAtom, { requestId: stored.uuid }), () => true)
289 }
290
291 return stored
292 })
293
294 // A turn's end and a plan limit's move are when the band's numbers change.
295 on('session.measure', async ($, e, next) => {
296 await refreshUsage($)
297
298 return next(e)
299 })
300
301 on('turn.complete', async ($, e, next) => {
302 if (e.agentId === undefined) {
303 await refreshUsage($)
304 isWorking = false
305 const finished = stats
306 await update($, turnsAtom, turns =>
307 Object.fromEntries([...Object.entries(turns), [String(e.durationMs), finished]].slice(-KEPT_TURNS)),
308 )
309 }
310
311 return next(e)
312 })
313
314 // Times every call and counts the main loop's calls and changed lines.
315 on('tool.call', async ($, e, next) => {
316 const startedAt = await $.clock.now()
317 const ran = await next(e)
318 const ms = (await $.clock.now()) - startedAt
319
320 await update($, memberOf(durationAtom, { requestId: e.tool_use_id }), () => ms)
321
322 if (e.agentId === undefined && e.tool !== DESIGN && ran.deny === undefined) {
323 const diff = diffstat(ran.result)
324 stats = {
325 tools: stats.tools + 1,
326 added: stats.added + (diff?.added ?? 0),
327 removed: stats.removed + (diff?.removed ?? 0),
328 }
329 }
330
331 return ran
332 })
333
334 on('tool.call', { tool: DESIGN_MATCH }, async ($, e) => {
335 const outcome = runDesign(e, await designState($))
336
337 if (outcome.isError) {
338 return { deny: outcome.text }
339 }
340
341 await commit($, outcome.state)
342
343 return { result: outcome.text }
344 })
345
346 on('command.run', { command: 'skin' }, async ($, e) => {
347 if (e.args.trim() === '') {
348 await $.ui.open({ id: SETTINGS, title: 'Skins', focus: true, closeOnEscape: true })
349
350 return {}
351 }
352
353 if (e.args.trim().toLowerCase() === 'gallery') {
354 await $.ui.open({ id: GALLERY, title: 'Skin gallery', focus: true, closeOnEscape: true })
355
356 return {}
357 }
358
359 const word = e.args.trim().toLowerCase()
360
361 if (word === 'pin' || word === 'unpin' || word === 'share') {
362 $.ui.toast(await runFolderCommand($, word))
363
364 return {}
365 }
366
367 const current = await read($, prefsAtom)
368 const custom = await read($, customAtom)
369 const outcome = runSkinCommand(e.args, current, skinNames(custom))
370
371 if (outcome.prefs !== current) {
372 await commit($, { prefs: outcome.prefs, custom })
373 }
374
375 if (outcome.channel === 'row') {
376 const width = lastColumns === undefined ? '' : `
377reply width: ${lastColumns} columns`
378
379 const folder = (await read($, pinnedAtom)) ? 'this folder: pinned' : 'this folder: follows the default'
380
381 return { text: e.args.trim() === 'list' ? `${outcome.message}\n${folder}${width}` : outcome.message }
382 }
383
384 $.ui.toast(outcome.message)
385
386 return {}
387 })
388
389 on('ui.render', { component: 'Pane', requestId: SETTINGS }, async ($, e) => {
390 const prefs = await read($, prefsAtom)
391 const custom = await read($, customAtom)
392 const editing = await read($, editingAtom)
393 const skin = resolveSkin(prefs.skin, custom) ?? resolveSkin(DEFAULT_PREFS.skin, custom)
394 const ui = $.ui.resolve(e)
395
396 if (skin === undefined || e.surface === 'mobile' || !('Input' in ui)) {
397 return <ui.Text>Open the skin settings in the terminal or the desktop app.</ui.Text>
398 }
399
400 const look = lookOf(ui, { prefs, custom, skin }, e.surface)
401 const state = { prefs, custom }
402
403 return settingsPane(look, ui, { names: skinNames(custom), editing, width: e.props.bodyColumns }, {
404 pick: name => void commit($, { ...state, prefs: { ...prefs, skin: name } }),
405 toggle: word => void commit($, { ...state, prefs: { ...prefs, [TOGGLES[word]]: !prefs[TOGGLES[word]] } }),
406 tables: () => void commit($, { ...state, prefs: { ...prefs, tables: nextTables(prefs.tables) } }),
407 icons: () =>
408 void commit($, { ...state, prefs: { ...prefs, icons: prefs.icons === 'unicode' ? 'ascii' : 'unicode' } }),
409 edit: slot => void update($, editingAtom, () => slot),
410 paint: hex => {
411 const made = withSlot(prefs.skin === 'off' ? DEFAULT_PREFS.skin : prefs.skin, custom, editing, hex)
412
413 if (typeof made === 'string') {
414 $.ui.toast(made)
415
416 return
417 }
418
419 void commit($, { prefs: { ...prefs, skin: made.name }, custom: { ...custom, [made.name]: made.skin } })
420 },
421 })
422 })
423
424 // Every element the skin draws, numbered, to point at when asking for a change.
425 on('ui.render', { component: 'Pane', requestId: GALLERY }, async ($, e) => {
426 const active = await activeSkin($)
427 const ui = $.ui.resolve(e)
428
429 if (active === null) {
430 return <ui.Text>The gallery shows a skin. Pick one with /skin first.</ui.Text>
431 }
432
433 return galleryPane(lookOf(ui, active, e.surface), e.props.bodyColumns)
434 })
435
436 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
437 const kind = kindOf(e.props.tool)
438 const active = await activeSkin($)
439
440 if (kind === null || active === null) {
441 return next(e)
442 }
443
444 const ms = await read($, memberOf(durationAtom, e))
445 const diff = diffstat(e.props.output)
446 const target = summarize(e.props.tool, e.props.input, await $.session.cwd())
447
448 return toolRow(lookOf($.ui.resolve(e), active, e.surface), e.props, kind, target, {
449 ...(ms >= 0 && !e.props.isRunning ? { ms } : {}),
450 ...(diff === null ? {} : diff),
451 })
452 })
453
454 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
455 const active = await activeSkin($)
456
457 if (active === null || e.props.isExpanded) {
458 return next(e)
459 }
460
461 return groupRow(lookOf($.ui.resolve(e), active, e.surface), e.props.calls)
462 })
463
464 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
465 const active = await activeSkin($)
466 const output = e.props.output as { stdout?: unknown } | null
467 const copy = (text: string) => {
468 void $.ui.copy({ text, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
469 }
470 const look = active === null ? undefined : { ...lookOf($.ui.resolve(e), active, e.surface, copy), isFirstDraw: drawnOnce(e.requestId) }
471 const columns = e.viewport?.columns ?? 100
472
473 // The desktop gets cards: a diff for an edit, a terminal for a shell command.
474 if (look?.svg !== undefined && EDITS.has(e.props.tool) && !e.props.isErrored) {
475 const diff = hunksOf(e.props.output)
476
477 if (diff !== null) {
478 return diffCard(look, look.svg, diff, shortenPath(diff.path, await $.session.cwd()), columns)
479 }
480 }
481
482 if (look?.svg !== undefined && e.props.tool === 'Bash') {
483 const shell = shellOutputOf(e.props.output)
484
485 if (shell !== null) {
486 return terminalCard(look, look.svg, shell, e.props.isErrored, columns)
487 }
488 }
489
490 if (!active?.prefs.clipOutput || e.props.tool !== 'Bash' || typeof output?.stdout !== 'string') {
491 return next(e)
492 }
493
494 const stdout = clipLines(output.stdout, CLIP_HEAD, CLIP_TAIL)
495
496 return stdout === output.stdout
497 ? next(e)
498 : next({ ...e, props: { ...e.props, output: { ...output, stdout } } })
499 })
500
501 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
502 const active = await activeSkin($)
503
504 if (active === null || !TYPED.has(e.props.origin.kind) || e.props.text.length > MAX_PROMPT) {
505 return next(e)
506 }
507
508 const look = lookOf($.ui.resolve(e), active, e.surface)
509 const hasImages = await read($, memberOf(imagesAtom, e))
510
511 // Claude Code draws the images; the text is already in the outline above them.
512 return promptRow(look, e.props.text, hasImages ? await next({ ...e, props: { ...e.props, text: '' } }) : undefined)
513 })
514
515 // A reply keeps Claude Code's own drawing unless it holds a table to draw.
516 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
517 const active = await activeSkin($)
518
519 const text = e.props.text
520
521 if (active === null || active.prefs.tables === 'off' || !/\||```|~~~/.test(text)) {
522 return next(e)
523 }
524
525 const segments = splitReply(text)
526 const fits = segments.every(segment => segment.kind === 'table' || (segment.kind === 'text' ? segment.text : segment.raw).length <= MAX_MARKDOWN)
527
528 // Off the terminal a shell fence keeps the app's drawing, for its Run button; a reply
529 // with nothing else to draw is left to the app whole.
530 const drawn = e.surface === 'terminal' ? segments : segments.filter(segment => !isShell(segment))
531
532 if (!fits || !drawn.some(segment => segment.kind !== 'text')) {
533 return next(e)
534 }
535
536 // Every surface's table names Svg, but the terminal draws it as nothing. As text, tables
537 // are text grids and code the app's own selectable blocks.
538 const ui = $.ui.resolve(e)
539 const svg = e.surface !== 'terminal' && active.prefs.tables === 'on' && 'Svg' in ui ? ui.Svg : undefined
540 lastColumns = e.viewport?.columns
541 const copy = (copied: string) => {
542 void $.ui.copy({ text: copied, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
543 }
544
545 return replyRows({ ...lookOf(ui, active, e.surface, copy), isFirstDraw: drawnOnce(e.requestId) }, segments, e.viewport?.columns ?? 100, svg)
546 })
547
548 // The terminal's spinner gets the skin's word with a shimmer; the desktop's keeps its
549 // word, which says what the step is doing, beside an animated icon.
550 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
551 const active = await activeSkin($)
552
553 if (active === null) {
554 return next(e)
555 }
556
557 if (e.surface !== 'terminal') {
558 const look = lookOf($.ui.resolve(e), active, e.surface)
559
560 return look.svg === undefined
561 ? next(e)
562 : desktopSpinnerRow(look, look.svg, e.props.mode, e.props.message ?? e.props.word)
563 }
564
565 const word = pick(active.skin.spinner, e.props.word) ?? e.props.word
566
567 if (!active.prefs.shimmer || e.props.message !== null) {
568 return next({ ...e, props: { ...e.props, word } })
569 }
570
571 const frame = await read($, frameAtom)
572 const startedAt = await read($, startedAtom)
573 const elapsed = startedAt === 0 ? 0 : (await $.clock.now()) - startedAt
574
575 return spinnerRow(lookOf($.ui.resolve(e), active, e.surface), word, frame, elapsed)
576 })
577
578 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
579 const active = await activeSkin($)
580
581 if (active === null) {
582 return next(e)
583 }
584
585 const turns = await read($, turnsAtom)
586 const word = pick(active.skin.done, e.props.word) ?? e.props.word
587
588 return footerRow(lookOf($.ui.resolve(e), active, e.surface), word, e.props.durationMs, turns[String(e.props.durationMs)])
589 })
590
591 // The band above the prompt: context and plan limits. Another mod's drawing there,
592 // and a survey, keep their place.
593 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
594 const active = await activeSkin($)
595 const meters = metersOf(await read($, usageAtom))
596
597 if (active === null || !active.prefs.band || e.props.hasSurvey || meters.length === 0) {
598 return next(e)
599 }
600
601 const look = lookOf($.ui.resolve(e), active, e.surface)
602 const { Box } = look.ui
603 const theirs = await next(e)
604 // Compacting mid-turn would cut the turn's own context out from under it.
605 // Runs Claude Code's own /compact, so the person sees its usual progress and result.
606 // Work a press starts is abandoned when the press ends, which cancels a compaction
607 // still running, so a timer starts it in a dispatch of its own, which returns the run
608 // so that dispatch lasts until the compaction ends. The button hides until then, so
609 // repeated presses do not queue one /compact each.
610 const isCompacting = await read($, compactingAtom)
611 const compact = () => {
612 void update($, compactingAtom, () => true)
613 $.clock.after(1, () =>
614 $.command
615 .run({ command: 'compact' })
616 .catch((error: unknown) => $.ui.toast(`Compacting failed: ${error instanceof Error ? error.message : String(error)}`))
617 .finally(() => update($, compactingAtom, () => false)),
618 )
619 }
620
621 return (
622 <Box flexDirection="column">
623 {usageBand(look, meters, !e.props.isWorking && !isCompacting, compact)}
624 {theirs}
625 </Box>
626 )
627 })
628
629 // Claude Code's own dialog stays whole: the skin only adds a band above it.
630 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
631 const active = await activeSkin($)
632 const theirs = await next(e)
633
634 if (active === null) {
635 return theirs
636 }
637
638 const headers = e.props.questions
639 .map(question => (question as { header?: unknown } | null)?.header)
640 .filter((header): header is string => typeof header === 'string' && header !== '')
641 const look = lookOf($.ui.resolve(e), active, e.surface)
642 const { Box } = look.ui
643
644 return headers.length === 0 ? (
645 theirs
646 ) : (
647 <Box flexDirection="column">
648 {askBand(look, headers)}
649 {theirs}
650 </Box>
651 )
652 })
653}
654hooks/command.ts 133 lines1import type { Prefs } from '../types'
2
3export const DEFAULT_PREFS: Prefs = {
4 skin: 'noir',
5 icons: 'unicode',
6 rail: true,
7 tables: 'on',
8 shimmer: true,
9 band: true,
10 clipOutput: false,
11}
12
13// cockpit patch: what a first run starts as. The skin is off and the band above the prompt is
14// off (the cockpit plugin owns that slot), so nothing is redrawn until the user runs /skin.
15// DEFAULT_PREFS stays as upstream: `/skin on` and the repaint paths still go to its skin.
16export const START_PREFS: Prefs = { ...DEFAULT_PREFS, skin: 'off', band: false }
17
18// The on/off settings, by the word /skin and the settings pane use for each. Tables have a
19// third state, `text`, so they are switched on their own.
20export const TOGGLES = {
21 rail: 'rail',
22 shimmer: 'shimmer',
23 band: 'band',
24 clip: 'clipOutput',
25} as const satisfies Record<string, keyof Prefs>
26
27export type ToggleWord = keyof typeof TOGGLES
28
29export type Outcome = {
30 prefs: Prefs
31 message: string
32 // `row` prints a transcript line (listings, mistakes); `toast` confirms a change quietly.
33 channel: 'row' | 'toast'
34}
35
36const flagOr = (value: unknown, fallback: boolean): boolean =>
37 typeof value === 'boolean' ? value : fallback
38
39export const TABLE_MODES = ['on', 'text', 'off'] as const satisfies readonly Prefs['tables'][]
40
41// Tables were on/off before `text`, so a stored boolean still reads.
42export function tablesOr(value: unknown, fallback: Prefs['tables']): Prefs['tables'] {
43 if (typeof value === 'boolean') {
44 return value ? 'on' : 'off'
45 }
46
47 return TABLE_MODES.find(mode => mode === value) ?? fallback
48}
49
50// The settings pane's tables button steps through the modes in order.
51export const nextTables = (mode: Prefs['tables']): Prefs['tables'] =>
52 TABLE_MODES[(TABLE_MODES.indexOf(mode) + 1) % TABLE_MODES.length] ?? 'on'
53
54// What the store hands back may be old, hand-edited or from another version.
55export function parsePrefs(raw: unknown, names: readonly string[]): Prefs {
56 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
57 const skin = saved.skin
58
59 return {
60 skin:
61 typeof skin === 'string' && (skin === 'off' || names.includes(skin))
62 ? skin
63 : START_PREFS.skin,
64 icons: saved.icons === 'ascii' ? 'ascii' : 'unicode',
65 rail: flagOr(saved.rail, DEFAULT_PREFS.rail),
66 tables: tablesOr(saved.tables, DEFAULT_PREFS.tables),
67 shimmer: flagOr(saved.shimmer, DEFAULT_PREFS.shimmer),
68 band: flagOr(saved.band, START_PREFS.band),
69 clipOutput: flagOr(saved.clipOutput, DEFAULT_PREFS.clipOutput),
70 }
71}
72
73const changed = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'toast' })
74
75const refused = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'row' })
76
77const onOff = (on: boolean): string => (on ? 'on' : 'off')
78
79export const listing = (current: Prefs, names: readonly string[]): string =>
80 [
81 ...names.map(name => `${name === current.skin ? '●' : '○'} ${name}`),
82 `${current.skin === 'off' ? '●' : '○'} off`,
83 [
84 `icons ${current.icons}`,
85 `rail ${onOff(current.rail)}`,
86 `tables ${current.tables}`,
87 `shimmer ${onOff(current.shimmer)}`,
88 `band ${onOff(current.band)}`,
89 `clip ${onOff(current.clipOutput)}`,
90 ].join(' · '),
91 ].join('\n')
92
93const isToggle = (word: string): word is ToggleWord => Object.hasOwn(TOGGLES, word)
94
95// Every argument but none: with none, /skin opens the settings pane instead.
96export function runSkinCommand(args: string, current: Prefs, names: readonly string[]): Outcome {
97 const [head = '', value] = args.trim().toLowerCase().split(/\s+/)
98
99 if (isToggle(head)) {
100 const field = TOGGLES[head]
101
102 return value === 'on' || value === 'off'
103 ? changed({ ...current, [field]: value === 'on' }, `${head} ${value}`)
104 : refused(current, `usage: /skin ${head} on|off`)
105 }
106
107 switch (head) {
108 case 'list':
109 return refused(current, listing(current, names))
110 // `default` and `stock` are what people reach for when they want Claude Code's own look.
111 case 'off':
112 case 'default':
113 case 'stock':
114 return changed({ ...current, skin: 'off' }, 'skins off')
115 case 'on':
116 return current.skin === 'off'
117 ? changed({ ...current, skin: DEFAULT_PREFS.skin }, `skin: ${DEFAULT_PREFS.skin}`)
118 : changed(current, `skin: ${current.skin}`)
119 case 'tables':
120 return value === 'on' || value === 'text' || value === 'off'
121 ? changed({ ...current, tables: value }, `tables ${value}`)
122 : refused(current, 'usage: /skin tables on|text|off')
123 case 'icons':
124 return value === 'unicode' || value === 'ascii'
125 ? changed({ ...current, icons: value }, `icons: ${value}`)
126 : refused(current, 'usage: /skin icons unicode|ascii')
127 default:
128 return names.includes(head)
129 ? changed({ ...current, skin: head }, `skin: ${head}`)
130 : refused(current, `unknown skin "${head}". /skin list shows them`)
131 }
132}
133hooks/custom.ts 185 lines1import type { CustomSkin, SkinSlot } from '../types'
2import type { Skin } from './skin'
3import { SKINS } from './themes'
4
5export const SLOTS: readonly SkinSlot[] = [
6 'user',
7 'fg',
8 'muted',
9 'surface',
10 'zebra',
11 'ok',
12 'err',
13 'warn',
14 'read',
15 'write',
16 'run',
17 'search',
18 'web',
19 'mcp',
20 'other',
21]
22
23// What each slot paints, for the settings pane and the agent's tool.
24export const SLOT_HELP: Readonly<Record<SkinSlot, string>> = {
25 user: 'accent: the rail, your prompt marker, the spinner',
26 fg: 'body text',
27 muted: 'secondary text: commands, paths, timings',
28 surface: 'band behind a table header',
29 zebra: 'every other table row; keep it a step off the background',
30 ok: 'a finished call',
31 err: 'a failed call',
32 warn: 'an interrupted call',
33 read: 'Read',
34 write: 'Edit and Write',
35 run: 'Bash and PowerShell',
36 search: 'Glob and Grep',
37 web: 'WebFetch and WebSearch',
38 mcp: 'MCP tools',
39 other: 'anything else in a group',
40}
41
42export type Customs = Readonly<Record<string, CustomSkin>>
43
44const HEX = /^#[0-9a-f]{6}$/i
45const NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
46const MAX_WORDS = 12
47const MAX_WORD = 24
48
49const builtIn = (name: string): Skin | undefined => SKINS.find(skin => skin.name === name)
50
51export const skinNames = (custom: Customs): string[] => [
52 ...SKINS.map(skin => skin.name),
53 ...Object.keys(custom).filter(name => builtIn(name) === undefined),
54]
55
56// A made skin is its base with the slots and words it changed laid over it.
57export function resolveSkin(name: string, custom: Customs): Skin | undefined {
58 const own = builtIn(name)
59
60 if (own !== undefined) {
61 return own
62 }
63
64 const made = custom[name]
65 const base = made === undefined ? undefined : (builtIn(made.base) ?? SKINS[0])
66
67 if (made === undefined || base === undefined) {
68 return undefined
69 }
70
71 return {
72 name: made.name,
73 label: made.label,
74 palette: { ...base.palette, ...made.palette },
75 spinner: made.spinner.length > 0 ? made.spinner : base.spinner,
76 done: made.done.length > 0 ? made.done : base.done,
77 }
78}
79
80export type Draft = {
81 name?: unknown
82 label?: unknown
83 base?: unknown
84 palette?: unknown
85 spinner?: unknown
86 done?: unknown
87}
88
89function words(value: unknown, field: string, fallback: string[]): string[] | string {
90 if (value === undefined) {
91 return fallback
92 }
93
94 const valid =
95 Array.isArray(value) &&
96 value.length <= MAX_WORDS &&
97 value.every(word => typeof word === 'string' && word.trim() !== '' && word.length <= MAX_WORD)
98
99 return valid
100 ? (value as string[]).map(word => word.trim())
101 : `${field} must be up to ${MAX_WORDS} words of 1-${MAX_WORD} characters`
102}
103
104function palette(value: unknown, fallback: CustomSkin['palette']): CustomSkin['palette'] | string {
105 if (value === undefined) {
106 return fallback
107 }
108
109 if (typeof value !== 'object' || value === null || Array.isArray(value)) {
110 return 'palette must be an object of slot: "#rrggbb"'
111 }
112
113 const entries = Object.entries(value as Record<string, unknown>)
114 const bad = entries.filter(
115 ([slot, hex]) => !SLOTS.includes(slot as SkinSlot) || typeof hex !== 'string' || !HEX.test(hex),
116 )
117
118 if (bad.length > 0) {
119 return `bad palette entries: ${bad.map(([slot]) => slot).join(', ')}. Slots: ${SLOTS.join(', ')}; values #rrggbb`
120 }
121
122 return { ...fallback, ...Object.fromEntries(entries.map(([slot, hex]) => [slot, (hex as string).toLowerCase()])) }
123}
124
125// Builds a made skin from what the agent or the pane sent, or says what is wrong with it.
126export function buildCustom(draft: Draft, existing: CustomSkin | undefined): CustomSkin | string {
127 const name = typeof draft.name === 'string' ? draft.name.trim().toLowerCase() : ''
128
129 if (!NAME.test(name)) {
130 return 'name must be 1-32 lowercase letters, digits or hyphens'
131 }
132
133 if (builtIn(name) !== undefined || name === 'off') {
134 return `"${name}" is taken by a built-in skin; pick another name`
135 }
136
137 const base = typeof draft.base === 'string' ? draft.base : (existing?.base ?? SKINS[0]?.name ?? '')
138
139 if (builtIn(base) === undefined) {
140 return `base must be one of ${SKINS.map(skin => skin.name).join(', ')}`
141 }
142
143 const label = typeof draft.label === 'string' && draft.label.trim() !== '' ? draft.label.trim().slice(0, 32) : (existing?.label ?? name)
144 const colours = palette(draft.palette, existing?.palette ?? {})
145 const spinner = words(draft.spinner, 'spinner', existing?.spinner ?? [])
146 const done = words(draft.done, 'done', existing?.done ?? [])
147
148 for (const result of [colours, spinner, done]) {
149 if (typeof result === 'string') {
150 return result
151 }
152 }
153
154 return {
155 name,
156 label,
157 base,
158 palette: colours as CustomSkin['palette'],
159 spinner: spinner as string[],
160 done: done as string[],
161 }
162}
163
164// One slot changed from the settings pane: a built-in skin forks into `my-<name>`.
165export function withSlot(
166 current: string,
167 custom: Customs,
168 slot: SkinSlot,
169 hex: string,
170): { name: string; skin: CustomSkin } | string {
171 if (!HEX.test(hex)) {
172 return 'use a colour like #7aa2f7'
173 }
174
175 const made = custom[current]
176 const name = made === undefined ? `my-${current}` : current
177 const base = made?.base ?? current
178 const skin = buildCustom(
179 { name, base, label: made?.label ?? `My ${current}`, palette: { [slot]: hex } },
180 custom[name],
181 )
182
183 return typeof skin === 'string' ? skin : { name, skin }
184}
185hooks/light.ts 79 lines1import type { Palette, Skin } from './skin'
2
3// Every skin works on a light background too. A skin may carry its own light palette;
4// the rest are derived: body text goes near-black, bands go near-white, and each colour
5// is deepened until it reads on white.
6
7const channels = (hex: string): [number, number, number] => [
8 parseInt(hex.slice(1, 3), 16),
9 parseInt(hex.slice(3, 5), 16),
10 parseInt(hex.slice(5, 7), 16),
11]
12
13const toHex = (rgb: readonly number[]): string =>
14 `#${rgb.map(value => Math.round(Math.max(0, Math.min(255, value))).toString(16).padStart(2, '0')).join('')}`
15
16// `amount` of the way from `hex` to black.
17export const deepen = (hex: string, amount: number): string =>
18 toHex(channels(hex).map(value => value * (1 - amount)))
19
20export function toLight(palette: Palette): Palette {
21 const deep = (hex: string) => deepen(hex, 0.45)
22
23 return {
24 read: deep(palette.read),
25 write: deep(palette.write),
26 run: deep(palette.run),
27 search: deep(palette.search),
28 web: deep(palette.web),
29 mcp: deep(palette.mcp),
30 other: deep(palette.other),
31 user: deep(palette.user),
32 fg: '#1f1f1f',
33 muted: '#6b6b6b',
34 surface: '#ffffff',
35 zebra: '#f2f2f2',
36 ok: deepen(palette.ok, 0.5),
37 err: deepen(palette.err, 0.25),
38 warn: deepen(palette.warn, 0.5),
39 }
40}
41
42export const forTheme = (skin: Skin, isLight: boolean): Skin =>
43 isLight ? { ...skin, palette: skin.light ?? toLight(skin.palette) } : skin
44
45// Claude Code's theme names say light or dark in them: `light`, `light-daltonized`, ...
46export const isLightTheme = (value: unknown): boolean => typeof value === 'string' && value.includes('light')
47
48// Light, dark, or undefined when the name says neither (`auto`).
49const named = (value: unknown): boolean | undefined => {
50 const name = typeof value === 'string' ? value.trim().toLowerCase() : ''
51
52 return name.includes('light') ? true : name.includes('dark') ? false : undefined
53}
54
55// `COLORFGBG` is `<fg>;<bg>` (sometimes `<fg>;<default>;<bg>`); background 7 or 15 is a light one.
56const terminalLight = (colorfgbg: string | undefined): boolean | undefined => {
57 const bg = Number(colorfgbg?.split(';').at(-1))
58
59 return colorfgbg === undefined || Number.isNaN(bg) ? undefined : bg === 7 || bg === 15
60}
61
62export type ThemeHints = {
63 // `SKINS_THEME`, for a session whose theme comes from somewhere skins cannot read (`--settings`).
64 override?: string
65 // Claude Code's `theme` setting.
66 theme?: unknown
67 colorfgbg?: string
68 // The system's appearance, when it could be read.
69 systemDark?: boolean
70}
71
72// `SKINS_THEME` wins, then a theme that names light or dark, then, for `auto`, the
73// terminal's background and the system's appearance. Dark when nothing says.
74export const resolveLight = (hints: ThemeHints): boolean =>
75 named(hints.override) ??
76 named(hints.theme) ??
77 terminalLight(hints.colorfgbg) ??
78 (hints.systemDark === undefined ? false : !hints.systemDark)
79hooks/folders.ts 21 lines1import type { Prefs } from '../types'
2import { parsePrefs } from './command'
3
4// Folders that keep their own look, by the session's directory. A folder not named
5// here follows the default prefs.
6export type Folders = Record<string, Prefs>
7
8export function parseFolders(raw: unknown, names: readonly string[]): Folders {
9 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
10
11 return Object.fromEntries(Object.entries(saved).map(([folder, prefs]) => [folder, parsePrefs(prefs, names)]))
12}
13
14export const prefsFor = (folder: string, folders: Folders, fallback: Prefs): Prefs =>
15 (Object.hasOwn(folders, folder) ? folders[folder] : undefined) ?? fallback
16
17export const withFolder = (folders: Folders, folder: string, prefs: Prefs): Folders => ({ ...folders, [folder]: prefs })
18
19export const withoutFolder = (folders: Folders, folder: string): Folders =>
20 Object.fromEntries(Object.entries(folders).filter(([name]) => name !== folder))
21hooks/designer.ts 135 lines1import type { CustomSkin, Prefs } from '../types'
2import { DEFAULT_PREFS, tablesOr } from './command'
3import { buildCustom, resolveSkin, SLOT_HELP, skinNames } from './custom'
4import { SKINS } from './themes'
5
6// The tool the person's own agent calls to design a skin with them.
7export const DESIGN_TOOL = {
8 name: 'design',
9 description: [
10 'Design, change and apply skins for the skins mod, which restyles this Claude Code transcript:',
11 'tool rows on a timeline rail, tables, the spinner, the turn footer. Changes show at once.',
12 'Call {"action":"show"} first: it returns the current skin, every slot with what it paints, and the names in use.',
13 '"save" makes or updates a skin: name (lowercase, hyphens), base (a built-in skin it starts from),',
14 'palette ({slot: "#rrggbb"}, only the slots to change), spinner (gerunds, like "Brewing"),',
15 'done (past tense, like "Brewed"), apply (default true).',
16 '"apply" switches to a skin by name, "delete" removes a made one,',
17 '"settings" switches rail, shimmer, band, clip (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 icons: { type: 'string', enum: ['unicode', 'ascii'] },
40 },
41 },
42 },
43 required: ['action'],
44 },
45} as const
46
47export type DesignState = { prefs: Prefs; custom: Record<string, CustomSkin> }
48
49export type DesignOutcome = { text: string; state: DesignState; isError: boolean }
50
51type Input = Record<string, unknown>
52
53const fail = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: true })
54
55const ok = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: false })
56
57function show(state: DesignState): DesignOutcome {
58 const skin = resolveSkin(state.prefs.skin, state.custom)
59
60 return ok(
61 state,
62 JSON.stringify({
63 current: state.prefs.skin,
64 palette: skin?.palette ?? null,
65 spinner: skin?.spinner ?? [],
66 done: skin?.done ?? [],
67 builtIn: SKINS.map(one => one.name),
68 made: Object.keys(state.custom),
69 slots: SLOT_HELP,
70 settings: state.prefs,
71 }),
72 )
73}
74
75function save(input: Input, state: DesignState): DesignOutcome {
76 const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
77 const skin = buildCustom(input, state.custom[name])
78
79 if (typeof skin === 'string') {
80 return fail(state, skin)
81 }
82
83 const custom = { ...state.custom, [skin.name]: skin }
84 const prefs = input.apply === false ? state.prefs : { ...state.prefs, skin: skin.name }
85
86 return ok({ prefs, custom }, `saved ${skin.name}${input.apply === false ? '' : ' and applied it'}`)
87}
88
89function settings(input: Input, state: DesignState): DesignOutcome {
90 const asked = (typeof input.settings === 'object' && input.settings !== null ? input.settings : {}) as Input
91 const flag = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
92 const icons = asked.icons === 'ascii' || asked.icons === 'unicode' ? asked.icons : state.prefs.icons
93 const prefs: Prefs = {
94 ...state.prefs,
95 rail: flag(asked.rail, state.prefs.rail),
96 tables: tablesOr(asked.tables, state.prefs.tables),
97 shimmer: flag(asked.shimmer, state.prefs.shimmer),
98 band: flag(asked.band, state.prefs.band),
99 clipOutput: flag(asked.clip, state.prefs.clipOutput),
100 icons,
101 }
102
103 return ok({ ...state, prefs }, `settings: ${JSON.stringify(prefs)}`)
104}
105
106export function runDesign(raw: unknown, state: DesignState): DesignOutcome {
107 const input = (typeof raw === 'object' && raw !== null ? raw : {}) as Input
108 const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
109
110 switch (input.action) {
111 case 'show':
112 return show(state)
113 case 'save':
114 return save(input, state)
115 case 'apply':
116 return skinNames(state.custom).includes(name)
117 ? ok({ ...state, prefs: { ...state.prefs, skin: name } }, `applied ${name}`)
118 : fail(state, `no skin named "${name}"; show lists them`)
119 case 'delete': {
120 if (state.custom[name] === undefined) {
121 return fail(state, `"${name}" is not a made skin; built-in skins cannot be deleted`)
122 }
123
124 const custom = Object.fromEntries(Object.entries(state.custom).filter(([key]) => key !== name))
125 const prefs = state.prefs.skin === name ? { ...state.prefs, skin: DEFAULT_PREFS.skin } : state.prefs
126
127 return ok({ prefs, custom }, `deleted ${name}`)
128 }
129 case 'settings':
130 return settings(input, state)
131 default:
132 return fail(state, 'action must be show, save, apply, delete or settings')
133 }
134}
135hooks/format.ts 85 lines1// Plain string helpers behind the row builders.
2
3export const oneLine = (text: string): string =>
4 (text.split('\n', 1)[0] ?? '').replace(/\s+/g, ' ').trim()
5
6const slashed = (path: string): string => path.replace(/\\/g, '/')
7
8// A path under the session's directory shows relative to it; any other stays as given.
9export function shortenPath(path: string, cwd: string): string {
10 const full = slashed(path)
11 const root = slashed(cwd).replace(/\/+$/, '')
12
13 if (root === '' || !full.toLowerCase().startsWith(`${root.toLowerCase()}/`)) {
14 return path
15 }
16
17 return full.slice(root.length + 1)
18}
19
20export function formatDuration(ms: number): string {
21 const seconds = Math.round(ms / 1000)
22
23 if (seconds < 1) {
24 return '<1s'
25 }
26
27 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
28}
29
30// A tool call's own time: `340ms`, `2.1s`, `1m 4s`.
31export function formatMs(ms: number): string {
32 if (ms < 1000) {
33 return `${Math.round(ms)}ms`
34 }
35
36 return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms)
37}
38
39// Lines added and removed, read from an Edit or Write record's patch.
40export function diffstat(output: unknown): { added: number; removed: number } | null {
41 const patch = (output as { structuredPatch?: unknown } | null)?.structuredPatch
42
43 if (!Array.isArray(patch)) {
44 return null
45 }
46
47 const lines = patch.flatMap(hunk => {
48 const hunkLines = (hunk as { lines?: unknown } | null)?.lines
49
50 return Array.isArray(hunkLines) ? hunkLines.filter(line => typeof line === 'string') : []
51 }) as string[]
52
53 return {
54 added: lines.filter(line => line.startsWith('+')).length,
55 removed: lines.filter(line => line.startsWith('-')).length,
56 }
57}
58
59// The same seed always picks the same item, so a row keeps its word when it redraws.
60export function pick<T>(items: readonly T[], seed: string): T | undefined {
61 let hash = 5381
62
63 for (let i = 0; i < seed.length; i++) {
64 hash = (hash * 33 + seed.charCodeAt(i)) >>> 0
65 }
66
67 return items[hash % items.length]
68}
69
70// Keeps the first `head` and last `tail` lines of a long text and names how many it hid.
71export function clipLines(text: string, head: number, tail: number): string {
72 const lines = text.split('\n')
73 const hidden = lines.length - head - tail
74
75 if (hidden <= 1) {
76 return text
77 }
78
79 return [
80 ...lines.slice(0, head),
81 `… ${hidden} lines hidden`,
82 ...lines.slice(lines.length - tail),
83 ].join('\n')
84}
85hooks/markdown.ts 217 lines1// Finds the tables and fenced code in a reply so they can be drawn as cards; the rest
2// stays markdown.
3
4export type Align = 'left' | 'right' | 'center'
5
6export type Table = { kind: 'table'; header: string[]; align: Align[]; rows: string[][] }
7
8// `raw` is the fence as written, for the surfaces that keep Claude Code's own drawing.
9export type Code = { kind: 'code'; lang: string; code: string; raw: string }
10
11export type Segment = { kind: 'text'; text: string } | Table | Code
12
13// Fences the desktop marks runnable with a Run button of its own. A card is an image and
14// cannot carry it, so these keep Claude Code's drawing there.
15const SHELLS = new Set(['bash', 'sh', 'shell', 'zsh', 'fish', 'console', 'powershell', 'pwsh', 'ps1', 'cmd', 'bat'])
16
17export const isShell = (segment: Segment): boolean => segment.kind === 'code' && SHELLS.has(segment.lang)
18
19const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
20const FENCE = /^\s*(```|~~~)\s*([\w+#.-]*)/
21
22// Inline emphasis and code ticks read as noise in a padded cell.
23const clean = (cell: string): string =>
24 cell.replace(/\*\*|__|`/g, '').replace(/\\\|/g, '|').trim()
25
26export function cellsOf(line: string): string[] {
27 const inner = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
28
29 return inner.split(/(?<!\\)\|/).map(clean)
30}
31
32const alignOf = (cell: string): Align => {
33 const spec = cell.trim()
34
35 if (spec.startsWith(':') && spec.endsWith(':')) {
36 return 'center'
37 }
38
39 return spec.endsWith(':') ? 'right' : 'left'
40}
41
42const fit = (cells: string[], width: number): string[] =>
43 Array.from({ length: width }, (_, i) => cells[i] ?? '')
44
45export function splitReply(markdown: string): Segment[] {
46 const lines = markdown.split('\n')
47 const segments: Segment[] = []
48 let text: string[] = []
49 let inFence = false
50
51 const flush = () => {
52 if (text.join('\n').trim() !== '') {
53 segments.push({ kind: 'text', text: text.join('\n') })
54 }
55
56 text = []
57 }
58
59 for (let i = 0; i < lines.length; i++) {
60 const line = lines[i] ?? ''
61 const next = lines[i + 1] ?? ''
62
63 const fence = FENCE.exec(line)
64
65 // A closed fence becomes a code segment; one still streaming stays text.
66 if (fence !== null && !inFence) {
67 const close = lines.findIndex((other, j) => j > i && FENCE.test(other) && other.trim().replace(/[`~]/g, '') === '')
68
69 if (close !== -1) {
70 flush()
71 segments.push({
72 kind: 'code',
73 lang: (fence[2] ?? '').toLowerCase(),
74 code: lines.slice(i + 1, close).join('\n'),
75 raw: lines.slice(i, close + 1).join('\n'),
76 })
77 i = close
78 continue
79 }
80
81 inFence = true
82 } else if (fence !== null) {
83 inFence = false
84 }
85
86 const header = cellsOf(line)
87 const isTable =
88 !inFence &&
89 line.includes('|') &&
90 SEPARATOR.test(next) &&
91 header.length > 1 &&
92 cellsOf(next).length === header.length
93
94 if (!isTable) {
95 text.push(line)
96 continue
97 }
98
99 flush()
100
101 const rows: string[][] = []
102 i += 2
103
104 while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim() !== '') {
105 rows.push(fit(cellsOf(lines[i] ?? ''), header.length))
106 i++
107 }
108
109 i--
110 segments.push({ kind: 'table', header, align: cellsOf(next).map(alignOf), rows })
111 }
112
113 flush()
114
115 return segments
116}
117
118// Terminal cells a character takes: wide East Asian characters and emoji take two,
119// combining marks, zero-width joiners and skin-tone modifiers none.
120const WIDE: readonly (readonly [number, number])[] = [
121 [0x1100, 0x115f],
122 [0x2e80, 0x303e],
123 [0x3041, 0x33ff],
124 [0x3400, 0x4dbf],
125 [0x4e00, 0x9fff],
126 [0xa000, 0xa4cf],
127 [0xa960, 0xa97f],
128 [0xac00, 0xd7a3],
129 [0xf900, 0xfaff],
130 [0xfe10, 0xfe19],
131 [0xfe30, 0xfe6f],
132 [0xff00, 0xff60],
133 [0xffe0, 0xffe6],
134 [0x1f300, 0x1f64f],
135 [0x1f680, 0x1f6ff],
136 [0x1f900, 0x1f9ff],
137 [0x1fa70, 0x1faff],
138 [0x20000, 0x3fffd],
139]
140
141const charWidth = (char: string): number => {
142 const code = char.codePointAt(0) ?? 0
143
144 if (/\p{Mn}|\p{Me}|\u200d|[\ufe00-\ufe0f]|\p{Emoji_Modifier}/u.test(char)) {
145 return 0
146 }
147
148 return WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
149}
150
151export const widthOf = (text: string): number => [...text].reduce((sum, char) => sum + charWidth(char), 0)
152
153// `text` cut to `width` cells, marked with an ellipsis where it lost text.
154const cutTo = (text: string, width: number): string => {
155 if (widthOf(text) <= width) {
156 return text
157 }
158
159 let kept = ''
160 let used = 0
161
162 for (const char of text) {
163 const next = charWidth(char)
164
165 if (used + next > width - 1) {
166 break
167 }
168
169 kept += char
170 used += next
171 }
172
173 return `${kept}…`
174}
175
176// Natural column widths, narrowed from the widest down until the table fits.
177export function columnWidths(table: Table, maxWidth: number, gap: number): number[] {
178 const widths = table.header.map((cell, col) =>
179 Math.max(widthOf(cell), ...table.rows.map(row => widthOf(row[col] ?? ''))),
180 )
181 const budget = maxWidth - gap * (widths.length - 1)
182
183 while (widths.reduce((sum, width) => sum + width, 0) > budget) {
184 const widest = widths.indexOf(Math.max(...widths))
185
186 if ((widths[widest] ?? 0) <= 3) {
187 break
188 }
189
190 widths[widest] = (widths[widest] ?? 0) - 1
191 }
192
193 return widths
194}
195
196// A cell cut to its column, marked with an ellipsis where it lost text.
197export function cutCell(text: string, width: number): string {
198 return cutTo(text, width)
199}
200
201export function padCell(text: string, width: number, align: Align): string {
202 const cut = cutTo(text, width)
203 const room = width - widthOf(cut)
204
205 if (align === 'right') {
206 return ' '.repeat(room) + cut
207 }
208
209 if (align === 'center') {
210 const left = Math.floor(room / 2)
211
212 return ' '.repeat(left) + cut + ' '.repeat(room - left)
213 }
214
215 return cut + ' '.repeat(room)
216}
217hooks/rows.tsx 572 lines1import type { ElementTable, RenderSurface } from 'claude-code'
2
3import type { Prefs, TurnStats } from '../types'
4import { formatDuration, formatMs } from './format'
5import { columnWidths, cutCell, isShell } from './markdown'
6import type { Segment, Table } from './markdown'
7import type { Icons, Kind, Skin } from './skin'
8import { spinnerIcon, toolIcon } from './icons'
9import type { SpinnerMode } from './icons'
10import { codeSvg } from './svg-code'
11import { diffSvg } from './svg-diff'
12import type { DiffInput } from './svg-diff'
13import { cardWidth, settled } from './svg-kit'
14import { tableSvg } from './svg-table'
15import { terminalSvg } from './svg-terminal'
16import type { ShellOutput } from './svg-terminal'
17import { usageLine, usageSvg } from './svg-usage'
18import type { Meter } from './svg-usage'
19import { kindOf, toolLabel } from './tools'
20
21export type Ui = Pick<ElementTable, 'Box' | 'Text' | 'Markdown' | 'Button'>
22
23// The vector element, on the surfaces that have one (the desktop app).
24export type SvgElement = ElementTable<'desktop'>['Svg']
25
26export type Look = {
27 ui: Ui
28 skin: Skin
29 icons: Icons
30 prefs: Prefs
31 surface: RenderSurface
32 // The vector element where the surface draws one: icons replace glyphs there.
33 svg?: SvgElement
34 // Puts text on the clipboard of the surface drawing; absent where nothing can copy.
35 copy?: (text: string) => void
36 // True the first time this drawing shows the card by that key; a card drawn again is
37 // drawn settled. Absent, every card animates.
38 isFirstDraw?: (key: string) => boolean
39}
40
41export type Call = {
42 tool: string
43 input: unknown
44 isRunning: boolean
45 isErrored: boolean
46 isInterrupted: boolean
47}
48
49// What a tool row shows at its right edge.
50export type Meta = { ms?: number; added?: number; removed?: number }
51
52const KIND_LABEL: Readonly<Record<Kind, string>> = {
53 read: 'Read',
54 write: 'Edit',
55 run: 'Run',
56 search: 'Search',
57 web: 'Web',
58 mcp: 'MCP',
59 other: 'Other',
60}
61
62const CELL_PAD = 1
63
64// The worst thing that happened to any of the calls, else how far along they are.
65function status({ skin, icons }: Look, calls: readonly Call[]) {
66 if (calls.some(call => call.isInterrupted)) {
67 return { glyph: icons.interrupted, color: skin.palette.warn }
68 }
69
70 if (calls.some(call => call.isErrored)) {
71 return { glyph: icons.err, color: skin.palette.err }
72 }
73
74 if (calls.some(call => call.isRunning)) {
75 return { glyph: icons.running, color: skin.palette.muted }
76 }
77
78 return { glyph: icons.ok, color: skin.palette.ok }
79}
80
81// The connector above a node, which ties a turn's calls into one line down the left.
82function railLine(look: Look) {
83 const { Text } = look.ui
84
85 return <Text color={look.skin.palette.user}>{look.icons.rail}</Text>
86}
87
88function node(look: Look, calls: readonly Call[]) {
89 const { Text } = look.ui
90 const { glyph, color } = status(look, calls)
91 const branch = look.prefs.rail && look.surface === 'terminal' ? look.icons.branch : ''
92
93 return (
94 <Text color={color}>
95 {`${glyph}`}
96 <Text color={look.skin.palette.muted}>{`${branch} `}</Text>
97 </Text>
98 )
99}
100
101function metaSpans(look: Look, meta: Meta) {
102 const { Text } = look.ui
103 const { palette } = look.skin
104 const spans = []
105
106 if (meta.added !== undefined && meta.removed !== undefined && meta.added + meta.removed > 0) {
107 spans.push(<Text color={palette.ok}>{`+${meta.added}`}</Text>)
108 spans.push(<Text color={palette.err}>{` −${meta.removed}`}</Text>)
109 }
110
111 if (meta.ms !== undefined) {
112 spans.push(<Text color={palette.muted}>{`${spans.length > 0 ? ' ' : ''}${formatMs(meta.ms)}`}</Text>)
113 }
114
115 return spans
116}
117
118// A line with its facts flush right on the terminal and trailing it on the desktop,
119// which lays a row out its own way.
120function withMeta(look: Look, main: ReturnType<Ui['Text']>, meta: Meta) {
121 const { Box, Text } = look.ui
122 const spans = metaSpans(look, meta)
123
124 if (spans.length === 0) {
125 return main
126 }
127
128 if (look.surface !== 'terminal') {
129 return (
130 <Text wrap="truncate-end">
131 {main}
132 {' '}
133 {spans}
134 </Text>
135 )
136 }
137
138 return (
139 <Box flexDirection="row">
140 <Box flexGrow={1} flexShrink={1}>
141 {main}
142 </Box>
143 <Box flexShrink={0} paddingLeft={2}>
144 <Text>{spans}</Text>
145 </Box>
146 </Box>
147 )
148}
149
150function stack(look: Look, line: ReturnType<Ui['Text']>) {
151 const { Box } = look.ui
152
153 // Box-drawing rails need a monospace font, which only the terminal draws mod text in.
154 return look.prefs.rail && look.surface === 'terminal' ? (
155 <Box flexDirection="column">
156 {railLine(look)}
157 {line}
158 </Box>
159 ) : (
160 line
161 )
162}
163
164// On a surface with vector icons, the icon leads the row in place of the status glyph.
165function iconRow(look: Look, Svg: SvgElement, kind: Kind, calls: readonly Call[], line: ReturnType<Ui['Text']>) {
166 const { Box } = look.ui
167 const { color } = status(look, calls)
168 const source = toolIcon(kind, color, calls.some(call => call.isRunning))
169
170 return (
171 <Box flexDirection="row" columnGap={1} alignItems="center">
172 <Svg source={source} alt={kind} width={16} height={16} />
173 {line}
174 </Box>
175 )
176}
177
178export function toolRow(look: Look, call: Call, kind: Kind, target: string, meta: Meta) {
179 const { Text } = look.ui
180 const { palette } = look.skin
181
182 if (look.svg !== undefined) {
183 const line = (
184 <Text wrap="truncate-end">
185 <Text color={palette[kind]} bold>
186 {toolLabel(call.tool)}
187 </Text>
188 <Text color={call.isErrored ? palette.err : palette.muted}>{` ${target}`}</Text>
189 </Text>
190 )
191
192 return iconRow(look, look.svg, kind, [call], withMeta(look, line, meta))
193 }
194
195 const main = (
196 <Text wrap="truncate-end">
197 {node(look, [call])}
198 <Text color={palette[kind]}>{toolLabel(call.tool)}</Text>
199 <Text color={call.isErrored ? palette.err : palette.muted}>{` ${target}`}</Text>
200 </Text>
201 )
202
203 return stack(look, withMeta(look, main, meta))
204}
205
206// A run of reads and searches on one node: `●─ Read 3 · Search 2`.
207export function groupRow(look: Look, calls: readonly Call[]) {
208 const { Text } = look.ui
209 const { palette } = look.skin
210 const counts = new Map<Kind, number>()
211
212 for (const call of calls) {
213 const kind = kindOf(call.tool) ?? 'other'
214 counts.set(kind, (counts.get(kind) ?? 0) + 1)
215 }
216
217 const parts = [...counts].flatMap(([kind, count], i) => [
218 ...(i === 0 ? [] : [<Text color={palette.muted}>{' · '}</Text>]),
219 <Text color={palette[kind]}>{KIND_LABEL[kind]}</Text>,
220 <Text color={palette.muted}>{` ${count}`}</Text>,
221 ])
222
223 if (look.svg !== undefined) {
224 const lead = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'other'
225
226 return iconRow(look, look.svg, lead, calls, <Text wrap="truncate-end">{parts}</Text>)
227 }
228
229 return stack(
230 look,
231 <Text wrap="truncate-end">
232 {node(look, calls)}
233 {parts}
234 </Text>,
235 )
236}
237
238// The desktop's spinner row: an animated icon for what the turn is doing, beside the
239// step the desktop names (`Creating notes.md`).
240export function desktopSpinnerRow(look: Look, Svg: SvgElement, mode: SpinnerMode, text: string) {
241 const { Box, Text } = look.ui
242
243 return (
244 <Box flexDirection="row" columnGap={1} alignItems="center">
245 <Svg source={spinnerIcon(mode, look.skin.palette.user)} alt={mode} width={20} height={20} />
246 <Text color={look.skin.palette.muted}>{text}</Text>
247 </Box>
248 )
249}
250
251// Your message in a rounded outline sized to what you typed, so it stands apart from
252// replies without taking the full width. Images it carried follow below it, drawn by
253// Claude Code.
254export function promptRow(look: Look, text: string, images?: ReturnType<Ui['Text']>) {
255 const { Box, Text } = look.ui
256
257 return (
258 <Box flexDirection="column" alignItems="flex-start" marginY={1}>
259 <Box borderStyle="round" borderColor={look.skin.palette.muted} paddingX={1} flexShrink={1}>
260 <Text color={look.skin.palette.fg}>{text}</Text>
261 </Box>
262 {images ?? ''}
263 </Box>
264 )
265}
266
267const JUSTIFY = { left: 'flex-start', right: 'flex-end', center: 'center' } as const
268
269// The share of the reported width a table may take at its natural size. Past it, the
270// table spans its container and splits it between columns in proportion: the desktop
271// app reports the window's width, wider than the transcript column a reply sits in.
272const NATURAL_SHARE = 0.55
273
274// A grid of cells, so columns line up in the terminal's monospace and in the desktop
275// app's proportional font alike. `control`, such as a Copy button, sits on the frame's
276// top border at the right, as ╭──── Copy ─╮.
277export function tableRows(look: Look, table: Table, maxWidth: number, control?: ReturnType<Ui['Button']>) {
278 const { Box, Text } = look.ui
279 const { palette } = look.skin
280 const widths = columnWidths(table, maxWidth - 4, CELL_PAD * 2)
281 const natural = widths.reduce((sum, width) => sum + width + CELL_PAD * 2, 2)
282 const isFluid = natural > maxWidth * NATURAL_SHARE
283 const size = (i: number) =>
284 isFluid
285 ? { width: 0, flexGrow: Math.max(1, widths[i] ?? 1), flexShrink: 1 }
286 : { width: (widths[i] ?? 0) + CELL_PAD * 2, flexShrink: 0 }
287 const row = (cells: readonly string[], isHeader: boolean, band: string | undefined) => (
288 <Box flexDirection="row" {...(band === undefined ? {} : { backgroundColor: band })}>
289 {cells.map((cell, i) => (
290 <Box {...size(i)} paddingX={CELL_PAD} justifyContent={JUSTIFY[table.align[i] ?? 'left']}>
291 <Text color={palette.fg} bold={isHeader} wrap="truncate-end">
292 {cutCell(cell, widths[i] ?? 0)}
293 </Text>
294 </Box>
295 ))}
296 </Box>
297 )
298
299 return (
300 <Box
301 flexDirection="column"
302 marginY={1}
303 borderStyle="round"
304 borderColor={palette.muted}
305 {...(isFluid ? { width: '100%' } : { alignSelf: 'flex-start' as const })}
306 >
307 {row(table.header, true, palette.surface)}
308 {table.rows.map((cells, i) => row(cells, false, i % 2 === 1 ? palette.zebra : undefined))}
309 {control === undefined ? (
310 ''
311 ) : (
312 <Box position="absolute" top={-1} right={1}>
313 {control}
314 </Box>
315 )}
316 </Box>
317 )
318}
319
320// A card's source: animated on its first draw, settled on every draw after it.
321const sourceOf = (look: Look, key: string, source: string): string =>
322 look.isFirstDraw === undefined || look.isFirstDraw(key) ? source : settled(source)
323
324// A card drawn as an image, with its Copy button laid over the top-right corner the card
325// left free. The image cannot be pressed, so the button is a real one on top of it; the
326// box hugs the image so the corner is the card's, not the column's.
327function cardWithCopy(look: Look, Svg: SvgElement, built: { source: string; alt: string; width: number; height: number }, key: string, text: string) {
328 const { Box, Button } = look.ui
329 const copy = look.copy
330
331 return (
332 <Box marginY={1} alignSelf="flex-start">
333 <Svg source={sourceOf(look, key, built.source)} alt={built.alt} width={built.width} height={built.height} />
334 {copy === undefined ? (
335 ''
336 ) : (
337 <Box position="absolute" top={1} right={3}>
338 <Button key={key} label="Copy" plain dimColor onPress={() => copy(text)} />
339 </Box>
340 )}
341 </Box>
342 )
343}
344
345function tableCard(look: Look, table: Table, Svg: SvgElement, columns: number, key: string) {
346 const card = tableSvg(table, look.skin.palette, cardWidth(columns), look.copy !== undefined)
347
348 return cardWithCopy(look, Svg, card, key, tableMarkdown(table))
349}
350
351// A table as markdown again, for the clipboard.
352const tableMarkdown = (table: Table): string =>
353 [table.header, table.header.map(() => '---'), ...table.rows].map(cells => `| ${cells.join(' | ')} |`).join('\n')
354
355// A Copy button padded to sit on a border line; nothing where nothing can copy.
356function copyButton(look: Look, key: string, text: string) {
357 const { Button } = look.ui
358 const copy = look.copy
359
360 return copy === undefined ? undefined : <Button key={key} label=" Copy " plain dimColor onPress={() => copy(text)} />
361}
362
363// A small Copy button under a card or block, flush right; nothing where nothing can copy.
364export function copyRow(look: Look, key: string, text: string) {
365 const { Box, Button } = look.ui
366 const copy = look.copy
367
368 if (copy === undefined) {
369 return ''
370 }
371
372 return (
373 <Box flexDirection="row" justifyContent="flex-end">
374 <Button key={key} label="Copy" plain dimColor onPress={() => copy(text)} />
375 </Box>
376 )
377}
378
379export function replyRows(look: Look, segments: readonly Segment[], maxWidth: number, Svg?: SvgElement) {
380 const { Box, Markdown } = look.ui
381
382 return (
383 <Box flexDirection="column">
384 {segments.map((segment, i) => {
385 if (segment.kind === 'text') {
386 return <Markdown text={segment.text} />
387 }
388
389 // A shell fence on the desktop stays the app's own block, which has Run and Copy.
390 if (segment.kind === 'code' && Svg !== undefined && isShell(segment)) {
391 return <Markdown text={segment.raw} />
392 }
393
394 if (segment.kind === 'code') {
395 return Svg === undefined ? (
396 <Box flexDirection="column">
397 <Markdown text={segment.raw} />
398 {copyRow(look, `copy-${i}`, segment.code)}
399 </Box>
400 ) : (
401 codeCard(look, segment.lang, segment.code, Svg, maxWidth, `copy-${i}`)
402 )
403 }
404
405 return Svg === undefined ? (
406 tableRows(look, segment, maxWidth, copyButton(look, `copy-${i}`, tableMarkdown(segment)))
407 ) : (
408 tableCard(look, segment, Svg, maxWidth, `copy-${i}`)
409 )
410 })}
411 </Box>
412 )
413}
414
415// The word with a band of light sweeping through it, one letter a frame.
416export function spinnerRow(look: Look, word: string, frame: number, elapsedMs: number) {
417 const { Text } = look.ui
418 const { palette } = look.skin
419 const letters = [...word]
420 const head = (frame % (letters.length + 8)) - 4
421 const shade = (i: number): string => {
422 const distance = Math.abs(i - head)
423
424 return distance === 0 ? palette.fg : distance === 1 ? palette.user : palette.muted
425 }
426 const runs: { color: string; text: string }[] = []
427
428 for (const [i, letter] of letters.entries()) {
429 const color = shade(i)
430 const last = runs.at(-1)
431
432 if (last?.color === color) {
433 last.text += letter
434 } else {
435 runs.push({ color, text: letter })
436 }
437 }
438
439 return (
440 <Text>
441 <Text color={palette.user}>{`${look.icons.frames[frame % look.icons.frames.length] ?? ''} `}</Text>
442 {runs.map(run => (
443 <Text color={run.color}>{run.text}</Text>
444 ))}
445 <Text color={palette.muted}>{`… ${formatDuration(elapsedMs)}`}</Text>
446 </Text>
447 )
448}
449
450export function footerRow(look: Look, word: string, durationMs: number, stats: TurnStats | undefined) {
451 const { Text } = look.ui
452 const { palette } = look.skin
453 const facts = [
454 ...(stats === undefined || stats.tools === 0 ? [] : [`${stats.tools} tool${stats.tools === 1 ? '' : 's'}`]),
455 ]
456 const hasDiff = stats !== undefined && stats.added + stats.removed > 0
457
458 return (
459 <Text color={palette.muted}>
460 <Text color={palette.user}>{`${look.icons.done} `}</Text>
461 {`${word} in ${formatDuration(durationMs)}`}
462 {facts.length > 0 ? ` · ${facts.join(' · ')}` : ''}
463 {hasDiff ? ' · ' : ''}
464 {hasDiff ? <Text color={palette.ok}>{`+${stats.added}`}</Text> : ''}
465 {hasDiff ? <Text color={palette.err}>{` −${stats.removed}`}</Text> : ''}
466 </Text>
467 )
468}
469
470// A band above Claude Code's own question dialog: the topics it asks about, in the skin.
471export function askBand(look: Look, headers: readonly string[]) {
472 const { Text } = look.ui
473 const { palette } = look.skin
474
475 return (
476 <Text color={palette.muted}>
477 <Text color={palette.user}>{`${look.icons.prompt} `}</Text>
478 {headers.map((header, i) => (
479 <Text color={i === 0 ? palette.fg : palette.muted} bold={i === 0}>
480 {`${i === 0 ? '' : ' · '}${header}`}
481 </Text>
482 ))}
483 </Text>
484 )
485}
486
487// A vector card in a reply or under a tool row, a line's breath above and below it.
488function card(look: Look, Svg: SvgElement, built: { source: string; alt: string }, key: string) {
489 const { Box } = look.ui
490
491 return (
492 <Box marginY={1}>
493 <Svg source={sourceOf(look, key, built.source)} alt={built.alt} />
494 </Box>
495 )
496}
497
498export function codeCard(look: Look, lang: string, code: string, Svg: SvgElement, columns: number, key = 'copy-code') {
499 return cardWithCopy(look, Svg, codeSvg(code, lang, look.skin.palette, cardWidth(columns), look.copy !== undefined), key, code)
500}
501
502export function diffCard(look: Look, Svg: SvgElement, input: DiffInput, shownPath: string, columns: number) {
503 return card(look, Svg, diffSvg(input, shownPath, look.skin.palette, cardWidth(columns)), 'diff')
504}
505
506export function terminalCard(look: Look, Svg: SvgElement, output: ShellOutput, isErrored: boolean, columns: number) {
507 const text = [output.stdout, output.stderr].filter(part => part.trim() !== '').join('\n')
508 const withCopy = text === '' ? { ...look, copy: undefined } : look
509
510 return cardWithCopy(withCopy, Svg, terminalSvg(output, isErrored, look.skin.palette, cardWidth(columns), withCopy.copy !== undefined), 'copy-output', text)
511}
512
513// From this full, the band suggests compacting and makes it the main action.
514export const COMPACT_NUDGE = 70
515
516export const COMPACT_HOTKEY = '0'
517
518function meterView(look: Look, meters: readonly Meter[]) {
519 const { Box, Text } = look.ui
520 const { palette } = look.skin
521
522 if (look.svg !== undefined) {
523 const Svg = look.svg
524 const built = usageSvg(meters, palette)
525
526 return <Svg source={built.source} alt={built.alt} width={built.width} height={built.height} />
527 }
528
529 return (
530 <Box flexDirection="row" columnGap={3}>
531 {usageLine(meters).map(meter => (
532 <Text color={palette.muted}>
533 <Text color={palette.user}>{meter.bar}</Text>
534 {` ${meter.percent}% ${meter.label}`}
535 </Text>
536 ))}
537 </Box>
538 )
539}
540
541// The band above the prompt: the meters, and a Compact button that becomes the main
542// action, with a word on why, once the context is full enough to be worth it.
543export function usageBand(look: Look, meters: readonly Meter[], canCompact: boolean, compact: () => void) {
544 const { Box, Text, Button } = look.ui
545 const { palette } = look.skin
546 const context = meters.find(meter => meter.label === 'context')?.percent ?? 0
547 const isNudge = context >= COMPACT_NUDGE
548
549 return (
550 // The right edge stays clear: the band draws its own collapse mark ([-]) there.
551 <Box flexDirection="row" alignItems="center" columnGap={2} paddingRight={5}>
552 {meterView(look, meters)}
553 <Box flexGrow={1} />
554 {canCompact && isNudge ? <Text color={palette.warn}>{`Context is ${context}% full`}</Text> : ''}
555 {canCompact ? (
556 // A digit hotkey: typed alone into an empty prompt it presses the band's button, which
557 // is the only way in where the terminal reports no clicks. `plain` shows it: `0: Compact`.
558 <Button
559 key="compact"
560 label={isNudge ? 'Compact now' : 'Compact'}
561 hotkey={COMPACT_HOTKEY}
562 plain
563 {...(isNudge ? { variant: 'primary' as const } : { dimColor: true })}
564 onPress={compact}
565 />
566 ) : (
567 ''
568 )}
569 </Box>
570 )
571}
572hooks/gallery.tsx 104 lines1import type { RenderElement } from 'claude-code'
2
3import type { Table } from './markdown'
4import {
5 askBand,
6 codeCard,
7 desktopSpinnerRow,
8 diffCard,
9 footerRow,
10 groupRow,
11 promptRow,
12 replyRows,
13 spinnerRow,
14 terminalCard,
15 toolRow,
16 usageBand,
17} from './rows'
18import type { Look } from './rows'
19
20// Every element the skin draws, numbered, from the same builders the transcript uses, so
21// a note on #4 is a note on the real thing.
22
23const done = { isRunning: false, isErrored: false, isInterrupted: false }
24
25const TABLE: Table = {
26 kind: 'table',
27 header: ['Skin', 'Accent', 'Time'],
28 align: ['left', 'left', 'right'],
29 rows: [
30 ['noir', '#f5f5f5', '41s'],
31 ['tokyo-night', '#7aa2f7', '2.1s'],
32 ['dracula', '#bd93f9', '340ms'],
33 ],
34}
35
36const CODE = ['// Rate limit per route, keyed by user', 'export function limit(route: string, perMinute = 60) {', ' return (used: number) => used < perMinute', '}'].join('\n')
37
38const DIFF = {
39 path: '/work/apps/hub/src/server.ts',
40 isNewFile: false,
41 hunks: [{ oldStart: 12, newStart: 12, lines: [' const app = fastify()', '-app.listen(7447)', '+app.register(rateLimit, { max: 60 })', '+app.listen(7447)'] }],
42}
43
44export function galleryPane(look: Look, columns: number) {
45 const { Box, Text } = look.ui
46 const { palette } = look.skin
47 const svg = look.svg
48 let number = 0
49 const section = (title: string, ...items: RenderElement[]) => {
50 number += 1
51
52 return (
53 <Box flexDirection="column" marginBottom={1}>
54 <Text color={palette.muted} bold>{`${number} ${title}`}</Text>
55 {items}
56 </Box>
57 )
58 }
59 const width = Math.max(40, columns - 4)
60
61 return (
62 <Box flexDirection="column">
63 {section('Your prompt', promptRow(look, 'add rate limiting to the hub api'))}
64 {section(
65 'Tool rows: finished, running, failed',
66 toolRow(look, { tool: 'Bash', input: {}, ...done }, 'run', 'pnpm test --filter hub', { ms: 2140 }),
67 toolRow(look, { tool: 'Read', input: {}, ...done, isRunning: true }, 'read', 'docs/protocols.md', {}),
68 toolRow(look, { tool: 'Edit', input: {}, ...done, isErrored: true }, 'write', 'apps/hub/src/server.ts', {}),
69 )}
70 {section('Folded group of reads and searches', groupRow(look, [{ tool: 'Read', input: {}, ...done }, { tool: 'Read', input: {}, ...done }, { tool: 'Grep', input: {}, ...done }]))}
71 {svg === undefined
72 ? section('Edit diff card', <Text color={palette.muted}>Desktop only; the terminal keeps Claude Code's diff.</Text>)
73 : section('Edit diff card', diffCard(look, svg, DIFF, 'apps/hub/src/server.ts', width))}
74 {svg === undefined
75 ? section('Shell output card', <Text color={palette.muted}>Desktop only.</Text>)
76 : section(
77 'Shell output card: ok, failed',
78 terminalCard(look, svg, { stdout: 'Test Files 12 passed (12)\n Tests 148 passed (148)', stderr: '', interrupted: false }, false, width),
79 terminalCard(look, svg, { stdout: '', stderr: 'error TS2322: Type string is not assignable to number', interrupted: false }, true, width),
80 )}
81 {svg === undefined
82 ? section('Code block card', <Text color={palette.muted}>Desktop only; the terminal keeps Claude Code's markdown.</Text>)
83 : section('Code block card', codeCard(look, 'ts', CODE, svg, width))}
84 {section('Table', replyRows(look, [TABLE], width, svg))}
85 {svg === undefined
86 ? section('Spinner', spinnerRow(look, look.skin.spinner[0] ?? 'Working', 3, 12_000))
87 : section(
88 'Spinner: thinking, tool running, writing, waiting',
89 desktopSpinnerRow(look, svg, 'thinking', 'Thinking'),
90 desktopSpinnerRow(look, svg, 'tool-use', 'Running pnpm test'),
91 desktopSpinnerRow(look, svg, 'responding', 'Writing the reply'),
92 desktopSpinnerRow(look, svg, 'requesting', 'Waiting for the model'),
93 )}
94 {section(
95 'Band above the prompt: normal, then nudging to compact',
96 usageBand(look, [{ label: 'context', percent: 42 }, { label: '5h', percent: 18 }], true, () => undefined),
97 usageBand(look, [{ label: 'context', percent: 85 }, { label: '5h', percent: 61 }], true, () => undefined),
98 )}
99 {section('Band above a question', askBand(look, ['Approach', 'Store']))}
100 {section('Turn footer (terminal)', footerRow(look, look.skin.done[0] ?? 'Done', 41_000, { tools: 6, added: 18, removed: 3 }))}
101 </Box>
102 )
103}
104hooks/settings.tsx 153 lines1import type { ElementTable } from 'claude-code'
2
3import type { SkinSlot } from '../types'
4import type { ToggleWord } from './command'
5import { SLOTS } from './custom'
6import type { Table } from './markdown'
7import { footerRow, groupRow, promptRow, tableRows, toolRow } from './rows'
8import type { Look } from './rows'
9
10export type PaneUi = Pick<ElementTable<'terminal' | 'desktop'>, 'Box' | 'Text' | 'Markdown' | 'Button' | 'Input' | 'Select'>
11
12export type SettingsModel = {
13 names: readonly string[]
14 editing: SkinSlot
15 width: number
16}
17
18export type SettingsActions = {
19 pick: (name: string) => void
20 toggle: (word: ToggleWord) => void
21 tables: () => void
22 icons: () => void
23 edit: (slot: SkinSlot) => void
24 paint: (hex: string) => void
25}
26
27const SAMPLE: Table = {
28 kind: 'table',
29 header: ['Route', 'Limit', 'Window'],
30 align: ['left', 'right', 'left'],
31 rows: [
32 ['/chat', '60', '1 min'],
33 ['/upload', '10', '1 min'],
34 ['/search', '120', '1 min'],
35 ],
36}
37
38const done = { isRunning: false, isErrored: false, isInterrupted: false }
39
40// A turn as the skin would draw it, built from the same rows the transcript uses.
41function preview(look: Look, width: number) {
42 const { Box } = look.ui
43 const word = look.skin.done[0] ?? 'Done'
44
45 return (
46 <Box flexDirection="column" borderStyle="round" borderColor={look.skin.palette.muted} paddingX={1}>
47 {promptRow(look, 'add rate limiting to the hub api')}
48 {toolRow(look, { tool: 'Bash', input: {}, ...done }, 'run', 'pnpm test --filter hub', { ms: 2140 })}
49 {toolRow(look, { tool: 'Edit', input: {}, ...done }, 'write', 'apps/hub/src/server.ts', {
50 ms: 380,
51 added: 18,
52 removed: 3,
53 })}
54 {groupRow(look, [
55 { tool: 'Read', input: {}, ...done },
56 { tool: 'Read', input: {}, ...done },
57 { tool: 'Grep', input: {}, ...done },
58 ])}
59 {tableRows(look, SAMPLE, Math.max(20, width - 4))}
60 {footerRow(look, word, 41_000, { tools: 6, added: 18, removed: 3 })}
61 </Box>
62 )
63}
64
65const mark = (on: boolean): string => (on ? '●' : '○')
66
67export function settingsPane(look: Look, ui: PaneUi, model: SettingsModel, actions: SettingsActions) {
68 const { Box, Text, Button, Input, Select } = ui
69 const { palette } = look.skin
70 const { prefs } = look
71 const toggle = (word: ToggleWord, hotkey: string, on: boolean) => (
72 <Button
73 key={`toggle-${word}`}
74 label={`${word} ${mark(on)}`}
75 hotkey={hotkey}
76 plain
77 dimColor={!on}
78 onPress={() => actions.toggle(word)}
79 />
80 )
81
82 return (
83 <Box flexDirection="column" rowGap={1}>
84 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
85 <Text color={palette.muted}>Skin</Text>
86 {model.names.map((name, i) => (
87 <Button
88 key={`skin-${name}`}
89 label={name === prefs.skin ? `${name} ●` : name}
90 plain
91 dimColor={name !== prefs.skin}
92 onPress={() => actions.pick(name)}
93 {...(i < 9 ? { hotkey: String(i + 1) } : {})}
94 />
95 ))}
96 </Box>
97
98 {preview(look, model.width)}
99
100 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
101 <Text color={palette.muted}>Look</Text>
102 {toggle('rail', 'r', prefs.rail)}
103 <Button
104 key="tables"
105 label={`tables ${prefs.tables}`}
106 hotkey="t"
107 plain
108 dimColor={prefs.tables === 'off'}
109 onPress={() => actions.tables()}
110 />
111 {toggle('shimmer', 's', prefs.shimmer)}
112 {toggle('band', 'b', prefs.band)}
113 {toggle('clip', 'c', prefs.clipOutput)}
114 <Button key="icons" label={`icons ${prefs.icons}`} hotkey="i" plain onPress={() => actions.icons()} />
115 </Box>
116
117 <Box flexDirection="column">
118 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
119 {SLOTS.map(slot => (
120 <Text>
121 <Text backgroundColor={palette[slot]}>{' '}</Text>
122 <Text color={slot === model.editing ? palette.fg : palette.muted} bold={slot === model.editing}>
123 {` ${slot}`}
124 </Text>
125 </Text>
126 ))}
127 </Box>
128 <Box flexDirection="row" columnGap={2} marginTop={1}>
129 <Select
130 key="slot"
131 label="Colour"
132 value={model.editing}
133 options={SLOTS.map(slot => ({ value: slot, label: slot }))}
134 onSelect={(value: string) => actions.edit(value as SkinSlot)}
135 />
136 <Input
137 key="hex"
138 label={palette[model.editing]}
139 placeholder="#rrggbb"
140 value=""
141 submitLabel="paint"
142 onSubmit={(value: string) => actions.paint(value.trim())}
143 />
144 </Box>
145 </Box>
146
147 <Text color={palette.muted}>
148 Or ask Claude: <Text color={palette.fg}>“make me a skin that feels like a sunset”</Text>. Esc closes.
149 </Text>
150 </Box>
151 )
152}
153hooks/sightings.ts 27 lines1// Which cards have been drawn already, so a card's entrance plays on its first draw only.
2// Keys are a render instance (a message id, a tool_use_id) and the card's place in it.
3// Bounded: the oldest are forgotten first, and a forgotten card only animates once more.
4const LIMIT = 2000
5
6export function sightings(limit = LIMIT): (key: string) => boolean {
7 const seen = new Set<string>()
8
9 return key => {
10 if (seen.has(key)) {
11 return false
12 }
13
14 seen.add(key)
15
16 if (seen.size > limit) {
17 const [oldest] = seen
18
19 if (oldest !== undefined) {
20 seen.delete(oldest)
21 }
22 }
23
24 return true
25 }
26}
27