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

A modern skin for Claude Code. A mod that redraws the transcript: tool calls with icons and timings, edits as diff cards, tables and code as animated cards, shell output in a terminal card, a spinner that shows what Claude is doing, and a band above the prompt with your context and plan limits and a Compact button. Seven skins, light and dark, a settings page, and your own agent can design a new skin with you.
<img alt="The same Claude Code turn switching skins with /skin: noir, Tokyo Night, Dracula, Catppuccin" src="docs/demo.gif">
<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">
These previews are drawn by the mod's own card code (scripts/previews.ts), not screenshots.
Needs Claude Code 2.1.287 or later, in a terminal or the desktop app's Code tab.
/plugin marketplace add hellosverre/claude-skins
/plugin install skins@hellosverre-mods
| Site | Desktop app | Terminal |
|---|---|---|
| Tool calls | A line icon per kind, a spinning ring while it runs, lines changed and time taken | A node on the turn's rail with the same facts |
| Edits | A diff card: file, +N −M, numbered changed lines in green and red | Claude Code's own diff |
| Shell commands | A terminal card: status pill, output with stderr apart, long output folded, and a Copy button for the output | Claude Code's own output |
| Tables in replies | An animated card: header rule, zebra rows, swatches for colours, coloured diffs; Copy gives the markdown | A cell grid with a header band and zebra rows, and the same Copy |
| Code blocks in replies | A card with the language, line numbers and highlighting, and a Copy button | Claude Code's own markdown, and a Copy button |
| Spinner | An animated icon per phase: thinking, tool use, writing, waiting | The skin's word with a band of light through it |
| Above the prompt | Rings for context and each plan limit, a Compact button, and a nudge to compact from 70% context | Block meters and the same button |
| Turn footer | (not raised on desktop) | Time, tool count and lines changed |
| Your prompts | A rounded outline sized to what you typed; attached images stay below it | The same |
| The question dialog | A band naming its topics above Claude Code's own dialog | The same |
Every card rises in row by row and respects reduced motion. A skin only changes what is drawn: the stored conversation, and what the model reads, are untouched. Agents, plan mode and the permission prompt keep Claude Code's own drawing. The default skin is noir, black and white. Cards have no background of their own, so they sit in the page. On a light Claude Code theme every skin switches to its light palette. With the auto theme it follows your terminal's background (COLORFGBG) or, failing that, the system's light or dark mode. Set SKINS_THEME=light or SKINS_THEME=dark to choose yourself, for example when you launch with claude --settings '{"theme":"dark"}', which skins cannot see.
/skin opens the settings: pick a skin, see a live preview, switch the rail, tables, shimmer, band and icons, and repaint any colour. Repainting a built-in skin saves it as your own my-<skin>./skin gallery shows every element the skin draws, numbered, to point at when you want one changed.design tool and a short guide, so "make me a skin that feels like a sunset" builds one and applies it while you watch./skin <name>, /skin list, /skin off, and /skin rail|tables|shimmer|band|clip on|off for quick switches./skin pin gives this folder a look of its own: later changes here stay here. /skin unpin returns it to the default, and /skin share makes this folder's look the default for the rest. Made skins are shared by every folder.Your choices are remembered across sessions.
It draws and remembers. It reads the session's directory, your context and plan usage, Claude Code's theme setting and the SKINS_THEME and COLORFGBG variables; keeps its settings in the mod store; registers one tool for your agent; and compacts only when you press Compact, and copies only when you press Copy. With the auto theme it asks the system for its appearance (defaults read -g AppleInterfaceStyle on macOS, gsettings get org.gnome.desktop.interface color-scheme on GNOME); it starts no other process, touches no file and makes no network call. Check it yourself:
claude plugin validate .
A skin is one file. Copy hooks/themes/nord.ts, change the colours and words, then add one line to hooks/themes/index.ts.
claude plugin test
claude --plugin-dir .
Saving a file reloads the mod in the running session. /plugin-types writes the type declarations into .claude/types for your editor. npx tsx scripts/previews.ts redraws the README previews.
MIT
hooks/register.tsx 639 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, 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 { 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 { ICONS } from './skin'
18import type { Skin } from './skin'
19import { hunksOf } from './svg-diff'
20import { shellOutputOf } from './svg-terminal'
21import { limitLabel, metersOf } from './svg-usage'
22import { shortenPath } from './format'
23import { kindOf, summarize } from './tools'
24
25const SETTINGS = 'skins-settings'
26const GALLERY = 'skins-gallery'
27const DESIGN = `mcp__skins__${DESIGN_TOOL.name}`
28const DESIGN_MATCH = /^mcp__skins__design$/
29
30// Markdown takes at most 10000 characters a block; a longer prompt keeps Claude Code's
31// own drawing, which folds a big paste.
32const MAX_MARKDOWN = 9000
33const MAX_PROMPT = 4000
34
35const CLIP_HEAD = 8
36const CLIP_TAIL = 4
37const FRAME_MS = 90
38const KEPT_TURNS = 40
39
40const prefsAtom = atom({ plugin: 'skins', key: 'prefs' } as const, DEFAULT_PREFS)
41const customAtom = atom({ plugin: 'skins', key: 'custom' } as const, {})
42const startedAtom = atom({ plugin: 'skins', key: 'startedAt' } as const, 0)
43const frameAtom = atom({ plugin: 'skins', key: 'frame' } as const, 0)
44const turnsAtom = atom({ plugin: 'skins', key: 'turns' } as const, {})
45const durationAtom = atom({ plugin: 'skins', key: 'duration' } as const, -1)
46const editingAtom = atom({ plugin: 'skins', key: 'editing' } as const, 'user' as SkinSlot)
47const lightAtom = atom({ plugin: 'skins', key: 'isLight' } as const, false)
48const imagesAtom = atom({ plugin: 'skins', key: 'images' } as const, false)
49const usageAtom = atom({ plugin: 'skins', key: 'usage' } as const, { context: null, limits: [] } as UsageSnap)
50const compactingAtom = atom({ plugin: 'skins', key: 'compacting' } as const, false)
51const pinnedAtom = atom({ plugin: 'skins', key: 'pinned' } as const, false)
52
53const EDITS = new Set(['Edit', 'MultiEdit', 'Write'])
54
55// Only a person's own typing becomes a prompt row: a task notification or a peer's
56// message is not theirs to dress as theirs.
57const TYPED = new Set(['composer', 'bridge', 'sdk'])
58
59type Active = { prefs: Prefs; skin: Skin; custom: Record<string, CustomSkin> }
60
61const NO_STATS: TurnStats = { tools: 0, added: 0, removed: 0 }
62
63async function activeSkin($: EngineInterface): Promise<Active | null> {
64 const prefs = await read($, prefsAtom)
65 const custom = await read($, customAtom)
66 const skin = resolveSkin(prefs.skin, custom)
67
68 return skin === undefined ? null : { prefs, skin: forTheme(skin, await read($, lightAtom)), custom }
69}
70
71// The system's appearance, for an `auto` theme: macOS's AppleInterfaceStyle, else GNOME's
72// color-scheme. Undefined when neither answers.
73async function systemDark($: EngineInterface): Promise<boolean | undefined> {
74 try {
75 const mac = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 2000 })
76
77 // Unset (exit 1, "does not exist") is how macOS says light.
78 return mac.exitCode === 0 ? mac.stdout.trim() === 'Dark' : /does not exist/.test(mac.stderr) ? false : undefined
79 } catch {}
80
81 try {
82 const gnome = await $.process.run(['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], {
83 timeoutMs: 2000,
84 })
85
86 return gnome.exitCode === 0 ? gnome.stdout.includes('dark') : undefined
87 } catch {
88 return undefined
89 }
90}
91
92// Claude Code's own theme decides whether skins draw for a light or a dark background:
93// `SKINS_THEME` first, then the theme setting, and for `auto` the terminal and the system.
94async function refreshTheme($: EngineInterface): Promise<void> {
95 const rows = await $.config.list()
96 const hints = {
97 override: await $.env.get('SKINS_THEME'),
98 theme: rows.find(row => row.key === 'theme')?.value,
99 colorfgbg: await $.env.get('COLORFGBG'),
100 }
101 // Asks the system only when nothing before it decided.
102 const needsSystem = resolveLight(hints) !== resolveLight({ ...hints, systemDark: false })
103 const isLight = resolveLight({ ...hints, systemDark: needsSystem ? await systemDark($) : undefined })
104
105 await update($, lightAtom, () => isLight)
106}
107
108// Every surface's element table names Svg, but the terminal draws it as nothing, so
109// vector icons are for the other surfaces only.
110const lookOf = (
111 ui: Ui & { Svg?: SvgElement },
112 active: Active,
113 surface: RenderSurface,
114 copy?: (text: string) => void,
115): Look => ({
116 ui,
117 skin: active.skin,
118 icons: ICONS[active.prefs.icons],
119 prefs: active.prefs,
120 surface,
121 ...(surface !== 'terminal' && ui.Svg !== undefined ? { svg: ui.Svg } : {}),
122 ...(copy === undefined ? {} : { copy }),
123})
124
125// Made skins from the store, each checked again: the store may hold an older shape.
126function parseCustom(raw: unknown): Record<string, CustomSkin> {
127 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
128
129 return Object.fromEntries(
130 Object.values(saved)
131 .map(draft => buildCustom(draft as Record<string, unknown>, undefined))
132 .filter((skin): skin is CustomSkin => typeof skin !== 'string')
133 .map(skin => [skin.name, skin]),
134 )
135}
136
137async function load($: EngineInterface): Promise<void> {
138 const custom = parseCustom(await $.store.get('custom'))
139 const names = skinNames(custom)
140 const folders = parseFolders(await $.store.get('folders'), names)
141 const folder = await $.session.cwd()
142 const prefs = prefsFor(folder, folders, parsePrefs(await $.store.get('prefs'), names))
143
144 await update($, customAtom, () => custom)
145 await update($, prefsAtom, () => prefs)
146 await update($, pinnedAtom, () => Object.hasOwn(folders, folder))
147}
148
149// A pinned folder keeps its prefs to itself; every other folder shares the default.
150async function savePrefs($: EngineInterface, prefs: Prefs): Promise<void> {
151 if (!(await read($, pinnedAtom))) {
152 await $.store.set('prefs', prefs)
153
154 return
155 }
156
157 const names = skinNames(await read($, customAtom))
158 const folders = parseFolders(await $.store.get('folders'), names)
159 await $.store.set('folders', withFolder(folders, await $.session.cwd(), prefs))
160}
161
162// /skin pin, unpin and share: this folder's own look, or the default.
163async function runFolderCommand($: EngineInterface, word: string): Promise<string> {
164 const folder = await $.session.cwd()
165 const custom = await read($, customAtom)
166 const names = skinNames(custom)
167 const folders = parseFolders(await $.store.get('folders'), names)
168 const prefs = await read($, prefsAtom)
169
170 switch (word) {
171 case 'pin':
172 await $.store.set('folders', withFolder(folders, folder, prefs))
173 await update($, pinnedAtom, () => true)
174
175 return 'this folder keeps its own look'
176 case 'unpin': {
177 const fallback = parsePrefs(await $.store.get('prefs'), names)
178 await $.store.set('folders', withoutFolder(folders, folder))
179 await update($, pinnedAtom, () => false)
180 await update($, prefsAtom, () => fallback)
181
182 return `this folder follows the default: ${fallback.skin}`
183 }
184 default:
185 await $.store.set('prefs', prefs)
186
187 return `default look: ${prefs.skin}`
188 }
189}
190
191async function refreshUsage($: EngineInterface): Promise<void> {
192 const usage = await $.session.usage()
193 const snap: UsageSnap = {
194 context: usage.context.percent ?? null,
195 limits: usage.rateLimits.map(limit => ({ label: limitLabel(limit.kind), percent: limit.percentUsed })),
196 }
197
198 await update($, usageAtom, () => snap)
199}
200
201async function commit($: EngineInterface, state: DesignState): Promise<void> {
202 await update($, customAtom, () => state.custom)
203 await update($, prefsAtom, () => state.prefs)
204 await $.store.set('custom', state.custom)
205 await savePrefs($, state.prefs)
206}
207
208async function designState($: EngineInterface): Promise<DesignState> {
209 return { prefs: await read($, prefsAtom), custom: await read($, customAtom) }
210}
211
212export const register: Register = on => {
213 // What the turn on the main loop has done so far, for its footer.
214 let stats: TurnStats = NO_STATS
215 let isWorking = false
216 let ticker: Timer | undefined
217 // The width the last reply was drawn at, shown by /skin list to tune table sizing.
218 let lastColumns: number | undefined
219
220 on('session.start', async ($, e, next) => {
221 await $.command.register({
222 name: 'skin',
223 description: 'Open the skin settings, or /skin <name | list | off>',
224 argumentHint: '[gallery | name | list | off | pin | unpin | share | rail | tables | shimmer | band | clip | icons]',
225 immediate: true,
226 })
227 await $.tool.register({
228 name: DESIGN_TOOL.name,
229 description: DESIGN_TOOL.description,
230 inputSchema: DESIGN_TOOL.inputSchema,
231 })
232 await load($)
233 await refreshUsage($)
234 await refreshTheme($)
235
236 // Only the spinner reads the frame, so a tick redraws the spinner and nothing else.
237 ticker?.cancel()
238 ticker = $.clock.every(FRAME_MS, () => {
239 if (isWorking) {
240 void update($, frameAtom, frame => frame + 1)
241 }
242 })
243
244 return next(e)
245 })
246
247 // /clear, /resume and /branch reset $.state to its defaults and skip session.start.
248 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
249 await load($)
250 await refreshTheme($)
251
252 return next(e)
253 })
254
255 on('turn.start', async ($, e, next) => {
256 stats = NO_STATS
257 isWorking = true
258 const now = await $.clock.now()
259 await update($, startedAtom, () => now)
260
261 return next(e)
262 })
263
264 on('config.set', async ($, e, next) => {
265 const result = await next(e)
266
267 if (e.key === 'theme') {
268 await refreshTheme($)
269 }
270
271 return result
272 })
273
274 // Notes which prompts carried images, so their row keeps Claude Code's drawing of them.
275 on('session.append', async ($, e, next) => {
276 const stored = await next(e)
277 const hasImage = e.door === 'prompt' && e.message.content.some(block => block.type === 'image')
278
279 if (hasImage && stored.uuid !== undefined) {
280 await update($, memberOf(imagesAtom, { requestId: stored.uuid }), () => true)
281 }
282
283 return stored
284 })
285
286 // A turn's end and a plan limit's move are when the band's numbers change.
287 on('session.measure', async ($, e, next) => {
288 await refreshUsage($)
289
290 return next(e)
291 })
292
293 on('turn.complete', async ($, e, next) => {
294 if (e.agentId === undefined) {
295 await refreshUsage($)
296 isWorking = false
297 const finished = stats
298 await update($, turnsAtom, turns =>
299 Object.fromEntries([...Object.entries(turns), [String(e.durationMs), finished]].slice(-KEPT_TURNS)),
300 )
301 }
302
303 return next(e)
304 })
305
306 // Times every call and counts the main loop's calls and changed lines.
307 on('tool.call', async ($, e, next) => {
308 const startedAt = await $.clock.now()
309 const ran = await next(e)
310 const ms = (await $.clock.now()) - startedAt
311
312 await update($, memberOf(durationAtom, { requestId: e.tool_use_id }), () => ms)
313
314 if (e.agentId === undefined && e.tool !== DESIGN && ran.deny === undefined) {
315 const diff = diffstat(ran.result)
316 stats = {
317 tools: stats.tools + 1,
318 added: stats.added + (diff?.added ?? 0),
319 removed: stats.removed + (diff?.removed ?? 0),
320 }
321 }
322
323 return ran
324 })
325
326 on('tool.call', { tool: DESIGN_MATCH }, async ($, e) => {
327 const outcome = runDesign(e, await designState($))
328
329 if (outcome.isError) {
330 return { deny: outcome.text }
331 }
332
333 await commit($, outcome.state)
334
335 return { result: outcome.text }
336 })
337
338 on('command.run', { command: 'skin' }, async ($, e) => {
339 if (e.args.trim() === '') {
340 await $.ui.open({ id: SETTINGS, title: 'Skins', focus: true, closeOnEscape: true })
341
342 return {}
343 }
344
345 if (e.args.trim().toLowerCase() === 'gallery') {
346 await $.ui.open({ id: GALLERY, title: 'Skin gallery', focus: true, closeOnEscape: true })
347
348 return {}
349 }
350
351 const word = e.args.trim().toLowerCase()
352
353 if (word === 'pin' || word === 'unpin' || word === 'share') {
354 $.ui.toast(await runFolderCommand($, word))
355
356 return {}
357 }
358
359 const current = await read($, prefsAtom)
360 const custom = await read($, customAtom)
361 const outcome = runSkinCommand(e.args, current, skinNames(custom))
362
363 if (outcome.prefs !== current) {
364 await commit($, { prefs: outcome.prefs, custom })
365 }
366
367 if (outcome.channel === 'row') {
368 const width = lastColumns === undefined ? '' : `
369reply width: ${lastColumns} columns`
370
371 const folder = (await read($, pinnedAtom)) ? 'this folder: pinned' : 'this folder: follows the default'
372
373 return { text: e.args.trim() === 'list' ? `${outcome.message}\n${folder}${width}` : outcome.message }
374 }
375
376 $.ui.toast(outcome.message)
377
378 return {}
379 })
380
381 on('ui.render', { component: 'Pane', requestId: SETTINGS }, async ($, e) => {
382 const prefs = await read($, prefsAtom)
383 const custom = await read($, customAtom)
384 const editing = await read($, editingAtom)
385 const skin = resolveSkin(prefs.skin, custom) ?? resolveSkin(DEFAULT_PREFS.skin, custom)
386 const ui = $.ui.resolve(e)
387
388 if (skin === undefined || e.surface === 'mobile' || !('Input' in ui)) {
389 return <ui.Text>Open the skin settings in the terminal or the desktop app.</ui.Text>
390 }
391
392 const look = lookOf(ui, { prefs, custom, skin }, e.surface)
393 const state = { prefs, custom }
394
395 return settingsPane(look, ui, { names: skinNames(custom), editing, width: e.props.bodyColumns }, {
396 pick: name => void commit($, { ...state, prefs: { ...prefs, skin: name } }),
397 toggle: word => void commit($, { ...state, prefs: { ...prefs, [TOGGLES[word]]: !prefs[TOGGLES[word]] } }),
398 icons: () =>
399 void commit($, { ...state, prefs: { ...prefs, icons: prefs.icons === 'unicode' ? 'ascii' : 'unicode' } }),
400 edit: slot => void update($, editingAtom, () => slot),
401 paint: hex => {
402 const made = withSlot(prefs.skin === 'off' ? DEFAULT_PREFS.skin : prefs.skin, custom, editing, hex)
403
404 if (typeof made === 'string') {
405 $.ui.toast(made)
406
407 return
408 }
409
410 void commit($, { prefs: { ...prefs, skin: made.name }, custom: { ...custom, [made.name]: made.skin } })
411 },
412 })
413 })
414
415 // Every element the skin draws, numbered, to point at when asking for a change.
416 on('ui.render', { component: 'Pane', requestId: GALLERY }, async ($, e) => {
417 const active = await activeSkin($)
418 const ui = $.ui.resolve(e)
419
420 if (active === null) {
421 return <ui.Text>The gallery shows a skin. Pick one with /skin first.</ui.Text>
422 }
423
424 return galleryPane(lookOf(ui, active, e.surface), e.props.bodyColumns)
425 })
426
427 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
428 const kind = kindOf(e.props.tool)
429 const active = await activeSkin($)
430
431 if (kind === null || active === null) {
432 return next(e)
433 }
434
435 const ms = await read($, memberOf(durationAtom, e))
436 const diff = diffstat(e.props.output)
437 const target = summarize(e.props.tool, e.props.input, await $.session.cwd())
438
439 return toolRow(lookOf($.ui.resolve(e), active, e.surface), e.props, kind, target, {
440 ...(ms >= 0 && !e.props.isRunning ? { ms } : {}),
441 ...(diff === null ? {} : diff),
442 })
443 })
444
445 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
446 const active = await activeSkin($)
447
448 if (active === null || e.props.isExpanded) {
449 return next(e)
450 }
451
452 return groupRow(lookOf($.ui.resolve(e), active, e.surface), e.props.calls)
453 })
454
455 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
456 const active = await activeSkin($)
457 const output = e.props.output as { stdout?: unknown } | null
458 const copy = (text: string) => {
459 void $.ui.copy({ text, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
460 }
461 const look = active === null ? undefined : lookOf($.ui.resolve(e), active, e.surface, copy)
462 const columns = e.viewport?.columns ?? 100
463
464 // The desktop gets cards: a diff for an edit, a terminal for a shell command.
465 if (look?.svg !== undefined && EDITS.has(e.props.tool) && !e.props.isErrored) {
466 const diff = hunksOf(e.props.output)
467
468 if (diff !== null) {
469 return diffCard(look, look.svg, diff, shortenPath(diff.path, await $.session.cwd()), columns)
470 }
471 }
472
473 if (look?.svg !== undefined && e.props.tool === 'Bash') {
474 const shell = shellOutputOf(e.props.output)
475
476 if (shell !== null) {
477 return terminalCard(look, look.svg, shell, e.props.isErrored, columns)
478 }
479 }
480
481 if (!active?.prefs.clipOutput || e.props.tool !== 'Bash' || typeof output?.stdout !== 'string') {
482 return next(e)
483 }
484
485 const stdout = clipLines(output.stdout, CLIP_HEAD, CLIP_TAIL)
486
487 return stdout === output.stdout
488 ? next(e)
489 : next({ ...e, props: { ...e.props, output: { ...output, stdout } } })
490 })
491
492 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
493 const active = await activeSkin($)
494
495 if (active === null || !TYPED.has(e.props.origin.kind) || e.props.text.length > MAX_PROMPT) {
496 return next(e)
497 }
498
499 const look = lookOf($.ui.resolve(e), active, e.surface)
500 const hasImages = await read($, memberOf(imagesAtom, e))
501
502 // Claude Code draws the images; the text is already in the outline above them.
503 return promptRow(look, e.props.text, hasImages ? await next({ ...e, props: { ...e.props, text: '' } }) : undefined)
504 })
505
506 // A reply keeps Claude Code's own drawing unless it holds a table to draw.
507 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
508 const active = await activeSkin($)
509
510 const text = e.props.text
511
512 if (active === null || !active.prefs.tables || !/\||```|~~~/.test(text)) {
513 return next(e)
514 }
515
516 const segments = splitReply(text)
517 const fits = segments.every(segment => segment.kind === 'table' || (segment.kind === 'text' ? segment.text : segment.raw).length <= MAX_MARKDOWN)
518
519 if (!fits || !segments.some(segment => segment.kind !== 'text')) {
520 return next(e)
521 }
522
523 // Every surface's table names Svg, but the terminal draws it as nothing.
524 const ui = $.ui.resolve(e)
525 lastColumns = e.viewport?.columns
526 const copy = (copied: string) => {
527 void $.ui.copy({ text: copied, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
528 }
529
530 return replyRows(lookOf(ui, active, e.surface, copy), segments, e.viewport?.columns ?? 100, e.surface !== 'terminal' && 'Svg' in ui ? ui.Svg : undefined)
531 })
532
533 // The terminal's spinner gets the skin's word with a shimmer; the desktop's keeps its
534 // word, which says what the step is doing, beside an animated icon.
535 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
536 const active = await activeSkin($)
537
538 if (active === null) {
539 return next(e)
540 }
541
542 if (e.surface !== 'terminal') {
543 const look = lookOf($.ui.resolve(e), active, e.surface)
544
545 return look.svg === undefined
546 ? next(e)
547 : desktopSpinnerRow(look, look.svg, e.props.mode, e.props.message ?? e.props.word)
548 }
549
550 const word = pick(active.skin.spinner, e.props.word) ?? e.props.word
551
552 if (!active.prefs.shimmer || e.props.message !== null) {
553 return next({ ...e, props: { ...e.props, word } })
554 }
555
556 const frame = await read($, frameAtom)
557 const startedAt = await read($, startedAtom)
558 const elapsed = startedAt === 0 ? 0 : (await $.clock.now()) - startedAt
559
560 return spinnerRow(lookOf($.ui.resolve(e), active, e.surface), word, frame, elapsed)
561 })
562
563 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
564 const active = await activeSkin($)
565
566 if (active === null) {
567 return next(e)
568 }
569
570 const turns = await read($, turnsAtom)
571 const word = pick(active.skin.done, e.props.word) ?? e.props.word
572
573 return footerRow(lookOf($.ui.resolve(e), active, e.surface), word, e.props.durationMs, turns[String(e.props.durationMs)])
574 })
575
576 // The band above the prompt: context and plan limits. Another mod's drawing there,
577 // and a survey, keep their place.
578 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
579 const active = await activeSkin($)
580 const meters = metersOf(await read($, usageAtom))
581
582 if (active === null || !active.prefs.band || e.props.hasSurvey || meters.length === 0) {
583 return next(e)
584 }
585
586 const look = lookOf($.ui.resolve(e), active, e.surface)
587 const { Box } = look.ui
588 const theirs = await next(e)
589 // Compacting mid-turn would cut the turn's own context out from under it.
590 // Runs Claude Code's own /compact, so the person sees its usual progress and result.
591 // Work a press starts is abandoned when the press ends, which cancels a compaction
592 // still running, so a timer starts it in a dispatch of its own, which returns the run
593 // so that dispatch lasts until the compaction ends. The button hides until then, so
594 // repeated presses do not queue one /compact each.
595 const isCompacting = await read($, compactingAtom)
596 const compact = () => {
597 void update($, compactingAtom, () => true)
598 $.clock.after(1, () =>
599 $.command
600 .run({ command: 'compact' })
601 .catch((error: unknown) => $.ui.toast(`Compacting failed: ${error instanceof Error ? error.message : String(error)}`))
602 .finally(() => update($, compactingAtom, () => false)),
603 )
604 }
605
606 return (
607 <Box flexDirection="column">
608 {usageBand(look, meters, !e.props.isWorking && !isCompacting, compact)}
609 {theirs}
610 </Box>
611 )
612 })
613
614 // Claude Code's own dialog stays whole: the skin only adds a band above it.
615 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
616 const active = await activeSkin($)
617 const theirs = await next(e)
618
619 if (active === null) {
620 return theirs
621 }
622
623 const headers = e.props.questions
624 .map(question => (question as { header?: unknown } | null)?.header)
625 .filter((header): header is string => typeof header === 'string' && header !== '')
626 const look = lookOf($.ui.resolve(e), active, e.surface)
627 const { Box } = look.ui
628
629 return headers.length === 0 ? (
630 theirs
631 ) : (
632 <Box flexDirection="column">
633 {askBand(look, headers)}
634 {theirs}
635 </Box>
636 )
637 })
638}
639hooks/command.ts 109 lines1import type { Prefs } from '../types'
2
3export const DEFAULT_PREFS: Prefs = {
4 skin: 'noir',
5 icons: 'unicode',
6 rail: true,
7 tables: true,
8 shimmer: true,
9 band: true,
10 clipOutput: false,
11}
12
13// The on/off settings, by the word /skin and the settings pane use for each.
14export const TOGGLES = {
15 rail: 'rail',
16 tables: 'tables',
17 shimmer: 'shimmer',
18 band: 'band',
19 clip: 'clipOutput',
20} as const satisfies Record<string, keyof Prefs>
21
22export type ToggleWord = keyof typeof TOGGLES
23
24export type Outcome = {
25 prefs: Prefs
26 message: string
27 // `row` prints a transcript line (listings, mistakes); `toast` confirms a change quietly.
28 channel: 'row' | 'toast'
29}
30
31const flagOr = (value: unknown, fallback: boolean): boolean =>
32 typeof value === 'boolean' ? value : fallback
33
34// What the store hands back may be old, hand-edited or from another version.
35export function parsePrefs(raw: unknown, names: readonly string[]): Prefs {
36 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
37 const skin = saved.skin
38
39 return {
40 skin:
41 typeof skin === 'string' && (skin === 'off' || names.includes(skin))
42 ? skin
43 : DEFAULT_PREFS.skin,
44 icons: saved.icons === 'ascii' ? 'ascii' : 'unicode',
45 rail: flagOr(saved.rail, DEFAULT_PREFS.rail),
46 tables: flagOr(saved.tables, DEFAULT_PREFS.tables),
47 shimmer: flagOr(saved.shimmer, DEFAULT_PREFS.shimmer),
48 band: flagOr(saved.band, DEFAULT_PREFS.band),
49 clipOutput: flagOr(saved.clipOutput, DEFAULT_PREFS.clipOutput),
50 }
51}
52
53const changed = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'toast' })
54
55const refused = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'row' })
56
57const onOff = (on: boolean): string => (on ? 'on' : 'off')
58
59export const listing = (current: Prefs, names: readonly string[]): string =>
60 [
61 ...names.map(name => `${name === current.skin ? '●' : '○'} ${name}`),
62 `${current.skin === 'off' ? '●' : '○'} off`,
63 [
64 `icons ${current.icons}`,
65 `rail ${onOff(current.rail)}`,
66 `tables ${onOff(current.tables)}`,
67 `shimmer ${onOff(current.shimmer)}`,
68 `band ${onOff(current.band)}`,
69 `clip ${onOff(current.clipOutput)}`,
70 ].join(' · '),
71 ].join('\n')
72
73const isToggle = (word: string): word is ToggleWord => Object.hasOwn(TOGGLES, word)
74
75// Every argument but none: with none, /skin opens the settings pane instead.
76export function runSkinCommand(args: string, current: Prefs, names: readonly string[]): Outcome {
77 const [head = '', value] = args.trim().toLowerCase().split(/\s+/)
78
79 if (isToggle(head)) {
80 const field = TOGGLES[head]
81
82 return value === 'on' || value === 'off'
83 ? changed({ ...current, [field]: value === 'on' }, `${head} ${value}`)
84 : refused(current, `usage: /skin ${head} on|off`)
85 }
86
87 switch (head) {
88 case 'list':
89 return refused(current, listing(current, names))
90 // `default` and `stock` are what people reach for when they want Claude Code's own look.
91 case 'off':
92 case 'default':
93 case 'stock':
94 return changed({ ...current, skin: 'off' }, 'skins off')
95 case 'on':
96 return current.skin === 'off'
97 ? changed({ ...current, skin: DEFAULT_PREFS.skin }, `skin: ${DEFAULT_PREFS.skin}`)
98 : changed(current, `skin: ${current.skin}`)
99 case 'icons':
100 return value === 'unicode' || value === 'ascii'
101 ? changed({ ...current, icons: value }, `icons: ${value}`)
102 : refused(current, 'usage: /skin icons unicode|ascii')
103 default:
104 return names.includes(head)
105 ? changed({ ...current, skin: head }, `skin: ${head}`)
106 : refused(current, `unknown skin "${head}". /skin list shows them`)
107 }
108}
109hooks/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 } 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, tables, shimmer, band, clip (booleans) 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: { type: 'boolean' },
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: flag(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 211 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
13const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
14const FENCE = /^\s*(```|~~~)\s*([\w+#.-]*)/
15
16// Inline emphasis and code ticks read as noise in a padded cell.
17const clean = (cell: string): string =>
18 cell.replace(/\*\*|__|`/g, '').replace(/\\\|/g, '|').trim()
19
20export function cellsOf(line: string): string[] {
21 const inner = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
22
23 return inner.split(/(?<!\\)\|/).map(clean)
24}
25
26const alignOf = (cell: string): Align => {
27 const spec = cell.trim()
28
29 if (spec.startsWith(':') && spec.endsWith(':')) {
30 return 'center'
31 }
32
33 return spec.endsWith(':') ? 'right' : 'left'
34}
35
36const fit = (cells: string[], width: number): string[] =>
37 Array.from({ length: width }, (_, i) => cells[i] ?? '')
38
39export function splitReply(markdown: string): Segment[] {
40 const lines = markdown.split('\n')
41 const segments: Segment[] = []
42 let text: string[] = []
43 let inFence = false
44
45 const flush = () => {
46 if (text.join('\n').trim() !== '') {
47 segments.push({ kind: 'text', text: text.join('\n') })
48 }
49
50 text = []
51 }
52
53 for (let i = 0; i < lines.length; i++) {
54 const line = lines[i] ?? ''
55 const next = lines[i + 1] ?? ''
56
57 const fence = FENCE.exec(line)
58
59 // A closed fence becomes a code segment; one still streaming stays text.
60 if (fence !== null && !inFence) {
61 const close = lines.findIndex((other, j) => j > i && FENCE.test(other) && other.trim().replace(/[`~]/g, '') === '')
62
63 if (close !== -1) {
64 flush()
65 segments.push({
66 kind: 'code',
67 lang: (fence[2] ?? '').toLowerCase(),
68 code: lines.slice(i + 1, close).join('\n'),
69 raw: lines.slice(i, close + 1).join('\n'),
70 })
71 i = close
72 continue
73 }
74
75 inFence = true
76 } else if (fence !== null) {
77 inFence = false
78 }
79
80 const header = cellsOf(line)
81 const isTable =
82 !inFence &&
83 line.includes('|') &&
84 SEPARATOR.test(next) &&
85 header.length > 1 &&
86 cellsOf(next).length === header.length
87
88 if (!isTable) {
89 text.push(line)
90 continue
91 }
92
93 flush()
94
95 const rows: string[][] = []
96 i += 2
97
98 while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim() !== '') {
99 rows.push(fit(cellsOf(lines[i] ?? ''), header.length))
100 i++
101 }
102
103 i--
104 segments.push({ kind: 'table', header, align: cellsOf(next).map(alignOf), rows })
105 }
106
107 flush()
108
109 return segments
110}
111
112// Terminal cells a character takes: wide East Asian characters and emoji take two,
113// combining marks, zero-width joiners and skin-tone modifiers none.
114const WIDE: readonly (readonly [number, number])[] = [
115 [0x1100, 0x115f],
116 [0x2e80, 0x303e],
117 [0x3041, 0x33ff],
118 [0x3400, 0x4dbf],
119 [0x4e00, 0x9fff],
120 [0xa000, 0xa4cf],
121 [0xa960, 0xa97f],
122 [0xac00, 0xd7a3],
123 [0xf900, 0xfaff],
124 [0xfe10, 0xfe19],
125 [0xfe30, 0xfe6f],
126 [0xff00, 0xff60],
127 [0xffe0, 0xffe6],
128 [0x1f300, 0x1f64f],
129 [0x1f680, 0x1f6ff],
130 [0x1f900, 0x1f9ff],
131 [0x1fa70, 0x1faff],
132 [0x20000, 0x3fffd],
133]
134
135const charWidth = (char: string): number => {
136 const code = char.codePointAt(0) ?? 0
137
138 if (/\p{Mn}|\p{Me}|\u200d|[\ufe00-\ufe0f]|\p{Emoji_Modifier}/u.test(char)) {
139 return 0
140 }
141
142 return WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
143}
144
145export const widthOf = (text: string): number => [...text].reduce((sum, char) => sum + charWidth(char), 0)
146
147// `text` cut to `width` cells, marked with an ellipsis where it lost text.
148const cutTo = (text: string, width: number): string => {
149 if (widthOf(text) <= width) {
150 return text
151 }
152
153 let kept = ''
154 let used = 0
155
156 for (const char of text) {
157 const next = charWidth(char)
158
159 if (used + next > width - 1) {
160 break
161 }
162
163 kept += char
164 used += next
165 }
166
167 return `${kept}…`
168}
169
170// Natural column widths, narrowed from the widest down until the table fits.
171export function columnWidths(table: Table, maxWidth: number, gap: number): number[] {
172 const widths = table.header.map((cell, col) =>
173 Math.max(widthOf(cell), ...table.rows.map(row => widthOf(row[col] ?? ''))),
174 )
175 const budget = maxWidth - gap * (widths.length - 1)
176
177 while (widths.reduce((sum, width) => sum + width, 0) > budget) {
178 const widest = widths.indexOf(Math.max(...widths))
179
180 if ((widths[widest] ?? 0) <= 3) {
181 break
182 }
183
184 widths[widest] = (widths[widest] ?? 0) - 1
185 }
186
187 return widths
188}
189
190// A cell cut to its column, marked with an ellipsis where it lost text.
191export function cutCell(text: string, width: number): string {
192 return cutTo(text, width)
193}
194
195export function padCell(text: string, width: number, align: Align): string {
196 const cut = cutTo(text, width)
197 const room = width - widthOf(cut)
198
199 if (align === 'right') {
200 return ' '.repeat(room) + cut
201 }
202
203 if (align === 'center') {
204 const left = Math.floor(room / 2)
205
206 return ' '.repeat(left) + cut + ' '.repeat(room - left)
207 }
208
209 return cut + ' '.repeat(room)
210}
211hooks/rows.tsx 560 lines1import type { ElementTable, RenderSurface } from 'claude-code'
2
3import type { Prefs, TurnStats } from '../types'
4import { formatDuration, formatMs } from './format'
5import { columnWidths, cutCell } 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 } 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}
37
38export type Call = {
39 tool: string
40 input: unknown
41 isRunning: boolean
42 isErrored: boolean
43 isInterrupted: boolean
44}
45
46// What a tool row shows at its right edge.
47export type Meta = { ms?: number; added?: number; removed?: number }
48
49const KIND_LABEL: Readonly<Record<Kind, string>> = {
50 read: 'Read',
51 write: 'Edit',
52 run: 'Run',
53 search: 'Search',
54 web: 'Web',
55 mcp: 'MCP',
56 other: 'Other',
57}
58
59const CELL_PAD = 1
60
61// The worst thing that happened to any of the calls, else how far along they are.
62function status({ skin, icons }: Look, calls: readonly Call[]) {
63 if (calls.some(call => call.isInterrupted)) {
64 return { glyph: icons.interrupted, color: skin.palette.warn }
65 }
66
67 if (calls.some(call => call.isErrored)) {
68 return { glyph: icons.err, color: skin.palette.err }
69 }
70
71 if (calls.some(call => call.isRunning)) {
72 return { glyph: icons.running, color: skin.palette.muted }
73 }
74
75 return { glyph: icons.ok, color: skin.palette.ok }
76}
77
78// The connector above a node, which ties a turn's calls into one line down the left.
79function railLine(look: Look) {
80 const { Text } = look.ui
81
82 return <Text color={look.skin.palette.user}>{look.icons.rail}</Text>
83}
84
85function node(look: Look, calls: readonly Call[]) {
86 const { Text } = look.ui
87 const { glyph, color } = status(look, calls)
88 const branch = look.prefs.rail && look.surface === 'terminal' ? look.icons.branch : ''
89
90 return (
91 <Text color={color}>
92 {`${glyph}`}
93 <Text color={look.skin.palette.muted}>{`${branch} `}</Text>
94 </Text>
95 )
96}
97
98function metaSpans(look: Look, meta: Meta) {
99 const { Text } = look.ui
100 const { palette } = look.skin
101 const spans = []
102
103 if (meta.added !== undefined && meta.removed !== undefined && meta.added + meta.removed > 0) {
104 spans.push(<Text color={palette.ok}>{`+${meta.added}`}</Text>)
105 spans.push(<Text color={palette.err}>{` −${meta.removed}`}</Text>)
106 }
107
108 if (meta.ms !== undefined) {
109 spans.push(<Text color={palette.muted}>{`${spans.length > 0 ? ' ' : ''}${formatMs(meta.ms)}`}</Text>)
110 }
111
112 return spans
113}
114
115// A line with its facts flush right on the terminal and trailing it on the desktop,
116// which lays a row out its own way.
117function withMeta(look: Look, main: ReturnType<Ui['Text']>, meta: Meta) {
118 const { Box, Text } = look.ui
119 const spans = metaSpans(look, meta)
120
121 if (spans.length === 0) {
122 return main
123 }
124
125 if (look.surface !== 'terminal') {
126 return (
127 <Text wrap="truncate-end">
128 {main}
129 {' '}
130 {spans}
131 </Text>
132 )
133 }
134
135 return (
136 <Box flexDirection="row">
137 <Box flexGrow={1} flexShrink={1}>
138 {main}
139 </Box>
140 <Box flexShrink={0} paddingLeft={2}>
141 <Text>{spans}</Text>
142 </Box>
143 </Box>
144 )
145}
146
147function stack(look: Look, line: ReturnType<Ui['Text']>) {
148 const { Box } = look.ui
149
150 // Box-drawing rails need a monospace font, which only the terminal draws mod text in.
151 return look.prefs.rail && look.surface === 'terminal' ? (
152 <Box flexDirection="column">
153 {railLine(look)}
154 {line}
155 </Box>
156 ) : (
157 line
158 )
159}
160
161// On a surface with vector icons, the icon leads the row in place of the status glyph.
162function iconRow(look: Look, Svg: SvgElement, kind: Kind, calls: readonly Call[], line: ReturnType<Ui['Text']>) {
163 const { Box } = look.ui
164 const { color } = status(look, calls)
165 const source = toolIcon(kind, color, calls.some(call => call.isRunning))
166
167 return (
168 <Box flexDirection="row" columnGap={1} alignItems="center">
169 <Svg source={source} alt={kind} width={16} height={16} />
170 {line}
171 </Box>
172 )
173}
174
175export function toolRow(look: Look, call: Call, kind: Kind, target: string, meta: Meta) {
176 const { Text } = look.ui
177 const { palette } = look.skin
178
179 if (look.svg !== undefined) {
180 const line = (
181 <Text wrap="truncate-end">
182 <Text color={palette[kind]} bold>
183 {toolLabel(call.tool)}
184 </Text>
185 <Text color={call.isErrored ? palette.err : palette.muted}>{` ${target}`}</Text>
186 </Text>
187 )
188
189 return iconRow(look, look.svg, kind, [call], withMeta(look, line, meta))
190 }
191
192 const main = (
193 <Text wrap="truncate-end">
194 {node(look, [call])}
195 <Text color={palette[kind]}>{toolLabel(call.tool)}</Text>
196 <Text color={call.isErrored ? palette.err : palette.muted}>{` ${target}`}</Text>
197 </Text>
198 )
199
200 return stack(look, withMeta(look, main, meta))
201}
202
203// A run of reads and searches on one node: `●─ Read 3 · Search 2`.
204export function groupRow(look: Look, calls: readonly Call[]) {
205 const { Text } = look.ui
206 const { palette } = look.skin
207 const counts = new Map<Kind, number>()
208
209 for (const call of calls) {
210 const kind = kindOf(call.tool) ?? 'other'
211 counts.set(kind, (counts.get(kind) ?? 0) + 1)
212 }
213
214 const parts = [...counts].flatMap(([kind, count], i) => [
215 ...(i === 0 ? [] : [<Text color={palette.muted}>{' · '}</Text>]),
216 <Text color={palette[kind]}>{KIND_LABEL[kind]}</Text>,
217 <Text color={palette.muted}>{` ${count}`}</Text>,
218 ])
219
220 if (look.svg !== undefined) {
221 const lead = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'other'
222
223 return iconRow(look, look.svg, lead, calls, <Text wrap="truncate-end">{parts}</Text>)
224 }
225
226 return stack(
227 look,
228 <Text wrap="truncate-end">
229 {node(look, calls)}
230 {parts}
231 </Text>,
232 )
233}
234
235// The desktop's spinner row: an animated icon for what the turn is doing, beside the
236// step the desktop names (`Creating notes.md`).
237export function desktopSpinnerRow(look: Look, Svg: SvgElement, mode: SpinnerMode, text: string) {
238 const { Box, Text } = look.ui
239
240 return (
241 <Box flexDirection="row" columnGap={1} alignItems="center">
242 <Svg source={spinnerIcon(mode, look.skin.palette.user)} alt={mode} width={20} height={20} />
243 <Text color={look.skin.palette.muted}>{text}</Text>
244 </Box>
245 )
246}
247
248// Your message in a rounded outline sized to what you typed, so it stands apart from
249// replies without taking the full width. Images it carried follow below it, drawn by
250// Claude Code.
251export function promptRow(look: Look, text: string, images?: ReturnType<Ui['Text']>) {
252 const { Box, Text } = look.ui
253
254 return (
255 <Box flexDirection="column" alignItems="flex-start" marginY={1}>
256 <Box borderStyle="round" borderColor={look.skin.palette.muted} paddingX={1} flexShrink={1}>
257 <Text color={look.skin.palette.fg}>{text}</Text>
258 </Box>
259 {images ?? ''}
260 </Box>
261 )
262}
263
264const JUSTIFY = { left: 'flex-start', right: 'flex-end', center: 'center' } as const
265
266// The share of the reported width a table may take at its natural size. Past it, the
267// table spans its container and splits it between columns in proportion: the desktop
268// app reports the window's width, wider than the transcript column a reply sits in.
269const NATURAL_SHARE = 0.55
270
271// A grid of cells, so columns line up in the terminal's monospace and in the desktop
272// app's proportional font alike. `control`, such as a Copy button, sits on the frame's
273// top border at the right, as ╭──── Copy ─╮.
274export function tableRows(look: Look, table: Table, maxWidth: number, control?: ReturnType<Ui['Button']>) {
275 const { Box, Text } = look.ui
276 const { palette } = look.skin
277 const widths = columnWidths(table, maxWidth - 4, CELL_PAD * 2)
278 const natural = widths.reduce((sum, width) => sum + width + CELL_PAD * 2, 2)
279 const isFluid = natural > maxWidth * NATURAL_SHARE
280 const size = (i: number) =>
281 isFluid
282 ? { width: 0, flexGrow: Math.max(1, widths[i] ?? 1), flexShrink: 1 }
283 : { width: (widths[i] ?? 0) + CELL_PAD * 2, flexShrink: 0 }
284 const row = (cells: readonly string[], isHeader: boolean, band: string | undefined) => (
285 <Box flexDirection="row" {...(band === undefined ? {} : { backgroundColor: band })}>
286 {cells.map((cell, i) => (
287 <Box {...size(i)} paddingX={CELL_PAD} justifyContent={JUSTIFY[table.align[i] ?? 'left']}>
288 <Text color={palette.fg} bold={isHeader} wrap="truncate-end">
289 {cutCell(cell, widths[i] ?? 0)}
290 </Text>
291 </Box>
292 ))}
293 </Box>
294 )
295
296 return (
297 <Box
298 flexDirection="column"
299 marginY={1}
300 borderStyle="round"
301 borderColor={palette.muted}
302 {...(isFluid ? { width: '100%' } : { alignSelf: 'flex-start' as const })}
303 >
304 {row(table.header, true, palette.surface)}
305 {table.rows.map((cells, i) => row(cells, false, i % 2 === 1 ? palette.zebra : undefined))}
306 {control === undefined ? (
307 ''
308 ) : (
309 <Box position="absolute" top={-1} right={1}>
310 {control}
311 </Box>
312 )}
313 </Box>
314 )
315}
316
317// A card drawn as an image, with its Copy button laid over the top-right corner the card
318// left free. The image cannot be pressed, so the button is a real one on top of it; the
319// box hugs the image so the corner is the card's, not the column's.
320function cardWithCopy(look: Look, Svg: SvgElement, built: { source: string; alt: string; width: number; height: number }, key: string, text: string) {
321 const { Box, Button } = look.ui
322 const copy = look.copy
323
324 return (
325 <Box marginY={1} alignSelf="flex-start">
326 <Svg source={built.source} alt={built.alt} width={built.width} height={built.height} />
327 {copy === undefined ? (
328 ''
329 ) : (
330 <Box position="absolute" top={1} right={3}>
331 <Button key={key} label="Copy" plain dimColor onPress={() => copy(text)} />
332 </Box>
333 )}
334 </Box>
335 )
336}
337
338function tableCard(look: Look, table: Table, Svg: SvgElement, columns: number, key: string) {
339 const card = tableSvg(table, look.skin.palette, cardWidth(columns), look.copy !== undefined)
340
341 return cardWithCopy(look, Svg, card, key, tableMarkdown(table))
342}
343
344// A table as markdown again, for the clipboard.
345const tableMarkdown = (table: Table): string =>
346 [table.header, table.header.map(() => '---'), ...table.rows].map(cells => `| ${cells.join(' | ')} |`).join('\n')
347
348// A Copy button padded to sit on a border line; nothing where nothing can copy.
349function copyButton(look: Look, key: string, text: string) {
350 const { Button } = look.ui
351 const copy = look.copy
352
353 return copy === undefined ? undefined : <Button key={key} label=" Copy " plain dimColor onPress={() => copy(text)} />
354}
355
356// A small Copy button under a card or block, flush right; nothing where nothing can copy.
357export function copyRow(look: Look, key: string, text: string) {
358 const { Box, Button } = look.ui
359 const copy = look.copy
360
361 if (copy === undefined) {
362 return ''
363 }
364
365 return (
366 <Box flexDirection="row" justifyContent="flex-end">
367 <Button key={key} label="Copy" plain dimColor onPress={() => copy(text)} />
368 </Box>
369 )
370}
371
372export function replyRows(look: Look, segments: readonly Segment[], maxWidth: number, Svg?: SvgElement) {
373 const { Box, Markdown } = look.ui
374
375 return (
376 <Box flexDirection="column">
377 {segments.map((segment, i) => {
378 if (segment.kind === 'text') {
379 return <Markdown text={segment.text} />
380 }
381
382 if (segment.kind === 'code') {
383 return Svg === undefined ? (
384 <Box flexDirection="column">
385 <Markdown text={segment.raw} />
386 {copyRow(look, `copy-${i}`, segment.code)}
387 </Box>
388 ) : (
389 codeCard(look, segment.lang, segment.code, Svg, maxWidth, `copy-${i}`)
390 )
391 }
392
393 return Svg === undefined ? (
394 tableRows(look, segment, maxWidth, copyButton(look, `copy-${i}`, tableMarkdown(segment)))
395 ) : (
396 tableCard(look, segment, Svg, maxWidth, `copy-${i}`)
397 )
398 })}
399 </Box>
400 )
401}
402
403// The word with a band of light sweeping through it, one letter a frame.
404export function spinnerRow(look: Look, word: string, frame: number, elapsedMs: number) {
405 const { Text } = look.ui
406 const { palette } = look.skin
407 const letters = [...word]
408 const head = (frame % (letters.length + 8)) - 4
409 const shade = (i: number): string => {
410 const distance = Math.abs(i - head)
411
412 return distance === 0 ? palette.fg : distance === 1 ? palette.user : palette.muted
413 }
414 const runs: { color: string; text: string }[] = []
415
416 for (const [i, letter] of letters.entries()) {
417 const color = shade(i)
418 const last = runs.at(-1)
419
420 if (last?.color === color) {
421 last.text += letter
422 } else {
423 runs.push({ color, text: letter })
424 }
425 }
426
427 return (
428 <Text>
429 <Text color={palette.user}>{`${look.icons.frames[frame % look.icons.frames.length] ?? ''} `}</Text>
430 {runs.map(run => (
431 <Text color={run.color}>{run.text}</Text>
432 ))}
433 <Text color={palette.muted}>{`… ${formatDuration(elapsedMs)}`}</Text>
434 </Text>
435 )
436}
437
438export function footerRow(look: Look, word: string, durationMs: number, stats: TurnStats | undefined) {
439 const { Text } = look.ui
440 const { palette } = look.skin
441 const facts = [
442 ...(stats === undefined || stats.tools === 0 ? [] : [`${stats.tools} tool${stats.tools === 1 ? '' : 's'}`]),
443 ]
444 const hasDiff = stats !== undefined && stats.added + stats.removed > 0
445
446 return (
447 <Text color={palette.muted}>
448 <Text color={palette.user}>{`${look.icons.done} `}</Text>
449 {`${word} in ${formatDuration(durationMs)}`}
450 {facts.length > 0 ? ` · ${facts.join(' · ')}` : ''}
451 {hasDiff ? ' · ' : ''}
452 {hasDiff ? <Text color={palette.ok}>{`+${stats.added}`}</Text> : ''}
453 {hasDiff ? <Text color={palette.err}>{` −${stats.removed}`}</Text> : ''}
454 </Text>
455 )
456}
457
458// A band above Claude Code's own question dialog: the topics it asks about, in the skin.
459export function askBand(look: Look, headers: readonly string[]) {
460 const { Text } = look.ui
461 const { palette } = look.skin
462
463 return (
464 <Text color={palette.muted}>
465 <Text color={palette.user}>{`${look.icons.prompt} `}</Text>
466 {headers.map((header, i) => (
467 <Text color={i === 0 ? palette.fg : palette.muted} bold={i === 0}>
468 {`${i === 0 ? '' : ' · '}${header}`}
469 </Text>
470 ))}
471 </Text>
472 )
473}
474
475// A vector card in a reply or under a tool row, a line's breath above and below it.
476function card(look: Look, Svg: SvgElement, built: { source: string; alt: string }) {
477 const { Box } = look.ui
478
479 return (
480 <Box marginY={1}>
481 <Svg source={built.source} alt={built.alt} />
482 </Box>
483 )
484}
485
486export function codeCard(look: Look, lang: string, code: string, Svg: SvgElement, columns: number, key = 'copy-code') {
487 return cardWithCopy(look, Svg, codeSvg(code, lang, look.skin.palette, cardWidth(columns), look.copy !== undefined), key, code)
488}
489
490export function diffCard(look: Look, Svg: SvgElement, input: DiffInput, shownPath: string, columns: number) {
491 return card(look, Svg, diffSvg(input, shownPath, look.skin.palette, cardWidth(columns)))
492}
493
494export function terminalCard(look: Look, Svg: SvgElement, output: ShellOutput, isErrored: boolean, columns: number) {
495 const text = [output.stdout, output.stderr].filter(part => part.trim() !== '').join('\n')
496 const withCopy = text === '' ? { ...look, copy: undefined } : look
497
498 return cardWithCopy(withCopy, Svg, terminalSvg(output, isErrored, look.skin.palette, cardWidth(columns), withCopy.copy !== undefined), 'copy-output', text)
499}
500
501// From this full, the band suggests compacting and makes it the main action.
502export const COMPACT_NUDGE = 70
503
504export const COMPACT_HOTKEY = '0'
505
506function meterView(look: Look, meters: readonly Meter[]) {
507 const { Box, Text } = look.ui
508 const { palette } = look.skin
509
510 if (look.svg !== undefined) {
511 const Svg = look.svg
512 const built = usageSvg(meters, palette)
513
514 return <Svg source={built.source} alt={built.alt} width={built.width} height={built.height} />
515 }
516
517 return (
518 <Box flexDirection="row" columnGap={3}>
519 {usageLine(meters).map(meter => (
520 <Text color={palette.muted}>
521 <Text color={palette.user}>{meter.bar}</Text>
522 {` ${meter.percent}% ${meter.label}`}
523 </Text>
524 ))}
525 </Box>
526 )
527}
528
529// The band above the prompt: the meters, and a Compact button that becomes the main
530// action, with a word on why, once the context is full enough to be worth it.
531export function usageBand(look: Look, meters: readonly Meter[], canCompact: boolean, compact: () => void) {
532 const { Box, Text, Button } = look.ui
533 const { palette } = look.skin
534 const context = meters.find(meter => meter.label === 'context')?.percent ?? 0
535 const isNudge = context >= COMPACT_NUDGE
536
537 return (
538 // The right edge stays clear: the band draws its own collapse mark ([-]) there.
539 <Box flexDirection="row" alignItems="center" columnGap={2} paddingRight={5}>
540 {meterView(look, meters)}
541 <Box flexGrow={1} />
542 {canCompact && isNudge ? <Text color={palette.warn}>{`Context is ${context}% full`}</Text> : ''}
543 {canCompact ? (
544 // A digit hotkey: typed alone into an empty prompt it presses the band's button, which
545 // is the only way in where the terminal reports no clicks. `plain` shows it: `0: Compact`.
546 <Button
547 key="compact"
548 label={isNudge ? 'Compact now' : 'Compact'}
549 hotkey={COMPACT_HOTKEY}
550 plain
551 {...(isNudge ? { variant: 'primary' as const } : { dimColor: true })}
552 onPress={compact}
553 />
554 ) : (
555 ''
556 )}
557 </Box>
558 )
559}
560hooks/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 145 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 icons: () => void
22 edit: (slot: SkinSlot) => void
23 paint: (hex: string) => void
24}
25
26const SAMPLE: Table = {
27 kind: 'table',
28 header: ['Route', 'Limit', 'Window'],
29 align: ['left', 'right', 'left'],
30 rows: [
31 ['/chat', '60', '1 min'],
32 ['/upload', '10', '1 min'],
33 ['/search', '120', '1 min'],
34 ],
35}
36
37const done = { isRunning: false, isErrored: false, isInterrupted: false }
38
39// A turn as the skin would draw it, built from the same rows the transcript uses.
40function preview(look: Look, width: number) {
41 const { Box } = look.ui
42 const word = look.skin.done[0] ?? 'Done'
43
44 return (
45 <Box flexDirection="column" borderStyle="round" borderColor={look.skin.palette.muted} paddingX={1}>
46 {promptRow(look, 'add rate limiting to the hub api')}
47 {toolRow(look, { tool: 'Bash', input: {}, ...done }, 'run', 'pnpm test --filter hub', { ms: 2140 })}
48 {toolRow(look, { tool: 'Edit', input: {}, ...done }, 'write', 'apps/hub/src/server.ts', {
49 ms: 380,
50 added: 18,
51 removed: 3,
52 })}
53 {groupRow(look, [
54 { tool: 'Read', input: {}, ...done },
55 { tool: 'Read', input: {}, ...done },
56 { tool: 'Grep', input: {}, ...done },
57 ])}
58 {tableRows(look, SAMPLE, Math.max(20, width - 4))}
59 {footerRow(look, word, 41_000, { tools: 6, added: 18, removed: 3 })}
60 </Box>
61 )
62}
63
64const mark = (on: boolean): string => (on ? '●' : '○')
65
66export function settingsPane(look: Look, ui: PaneUi, model: SettingsModel, actions: SettingsActions) {
67 const { Box, Text, Button, Input, Select } = ui
68 const { palette } = look.skin
69 const { prefs } = look
70 const toggle = (word: ToggleWord, hotkey: string, on: boolean) => (
71 <Button
72 key={`toggle-${word}`}
73 label={`${word} ${mark(on)}`}
74 hotkey={hotkey}
75 plain
76 dimColor={!on}
77 onPress={() => actions.toggle(word)}
78 />
79 )
80
81 return (
82 <Box flexDirection="column" rowGap={1}>
83 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
84 <Text color={palette.muted}>Skin</Text>
85 {model.names.map((name, i) => (
86 <Button
87 key={`skin-${name}`}
88 label={name === prefs.skin ? `${name} ●` : name}
89 plain
90 dimColor={name !== prefs.skin}
91 onPress={() => actions.pick(name)}
92 {...(i < 9 ? { hotkey: String(i + 1) } : {})}
93 />
94 ))}
95 </Box>
96
97 {preview(look, model.width)}
98
99 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
100 <Text color={palette.muted}>Look</Text>
101 {toggle('rail', 'r', prefs.rail)}
102 {toggle('tables', 't', prefs.tables)}
103 {toggle('shimmer', 's', prefs.shimmer)}
104 {toggle('band', 'b', prefs.band)}
105 {toggle('clip', 'c', prefs.clipOutput)}
106 <Button key="icons" label={`icons ${prefs.icons}`} hotkey="i" plain onPress={() => actions.icons()} />
107 </Box>
108
109 <Box flexDirection="column">
110 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
111 {SLOTS.map(slot => (
112 <Text>
113 <Text backgroundColor={palette[slot]}>{' '}</Text>
114 <Text color={slot === model.editing ? palette.fg : palette.muted} bold={slot === model.editing}>
115 {` ${slot}`}
116 </Text>
117 </Text>
118 ))}
119 </Box>
120 <Box flexDirection="row" columnGap={2} marginTop={1}>
121 <Select
122 key="slot"
123 label="Colour"
124 value={model.editing}
125 options={SLOTS.map(slot => ({ value: slot, label: slot }))}
126 onSelect={(value: string) => actions.edit(value as SkinSlot)}
127 />
128 <Input
129 key="hex"
130 label={palette[model.editing]}
131 placeholder="#rrggbb"
132 value=""
133 submitLabel="paint"
134 onSubmit={(value: string) => actions.paint(value.trim())}
135 />
136 </Box>
137 </Box>
138
139 <Text color={palette.muted}>
140 Or ask Claude: <Text color={palette.fg}>“make me a skin that feels like a sunset”</Text>. Esc closes.
141 </Text>
142 </Box>
143 )
144}
145hooks/skin.ts 57 lines1import type { SkinSlot } from '../types'
2
3// Shapes shared by the skins and the row builders.
4
5// What a tool call is, by colour. `other` only shows in group rows.
6export type Kind = 'read' | 'write' | 'run' | 'search' | 'web' | 'mcp' | 'other'
7
8// The kinds a skin draws a tool row for; the rest keep Claude Code's own row.
9export type ToolKind = Exclude<Kind, 'other'>
10
11export type Slot = SkinSlot
12
13export type Palette = Readonly<Record<Slot, string>>
14
15export type Skin = {
16 readonly name: string
17 readonly label: string
18 readonly palette: Palette
19 // Gerunds for the spinner, and past-tense words for the turn footer.
20 readonly spinner: readonly string[]
21 readonly done: readonly string[]
22 // Its own palette for a light background; without one, light.ts derives it.
23 readonly light?: Palette
24}
25
26export type IconSet = 'unicode' | 'ascii'
27
28export type Icons = Readonly<
29 Record<'running' | 'ok' | 'err' | 'interrupted' | 'done' | 'rail' | 'branch' | 'prompt', string>
30> & { readonly frames: readonly string[] }
31
32// Single-cell glyphs with no emoji presentation; each status has its own shape, not only a colour.
33export const ICONS: Readonly<Record<IconSet, Icons>> = {
34 unicode: {
35 running: '○',
36 ok: '●',
37 err: '✕',
38 interrupted: '◌',
39 done: '◆',
40 rail: '┃',
41 branch: '─',
42 prompt: '▍',
43 frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
44 },
45 ascii: {
46 running: 'o',
47 ok: '*',
48 err: 'x',
49 interrupted: '-',
50 done: '>',
51 rail: '|',
52 branch: '-',
53 prompt: '>',
54 frames: ['|', '/', '-', '\\'],
55 },
56}
57