SLOPSHOPPER

skins

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

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

Draws

Band
▰▰▰▰▱▱▱▱ 49% context ▰▰▱▱▱▱▱▱ 31% 5h 0: Compact ⟨Claude Code's own drawing⟩
Pane · Skins
Skin 1: noir ● 2: tokyo-night 3: dracula 4: nord 5: gru ╭──────────────────────────────────────────────────────────╮ │ │ │ ╭──────────────────────────────────────────────────────╮ │ │ │ add rate limiting to the hub api │ │ │ ╰──────────────────────────────────────────────────────╯ │ │ │ │ │ │ ┃ │ │ ●─ Bash pnpm test --filter hub 2.1s │ │ ┃ │ │ ●─ Edit apps/hub/src/server.ts +18 −3 380ms │ │ ┃ │ │ ●─ Read 2 · Search 1 │ │ │ │ ╭──────────────────────────────────────────────────────╮ │ │ │ Route Limit Window │ │ │ │ /chat 60 1 min │ │ │ │ /upload 10 1 min │ │ │ │ /search 120 1 min │ │ │ │ │ │ │ ╰──────────────────────────────────────────────────────╯ │ │ │ │ ◆ Done in 41s · 6 tools · +18 −3 │ ╰──────────────────────────────────────────────────────────╯ Look r: rail ● t: tables ● s: shimmer ● b: band ● c: cl user fg muted surface zebra ok er Colour: user ▾ #f5f5f5: #rrggbb ⏎ paint Or ask Claude: “make me a skin that feels like a sunset”. Esc closes.
Pane · skins-gallery
1 Your prompt ╭──────────────────────────────────────────────────────────╮ │ add rate limiting to the hub api │ ╰──────────────────────────────────────────────────────────╯ 2 Tool rows: finished, running, failed ┃ ●─ Bash pnpm test --filter hub 2.1s ┃ ○─ Read docs/protocols.md ┃ ✕─ Edit apps/hub/src/server.ts 3 Folded group of reads and searches ┃ ●─ Read 2 · Search 1 4 Edit diff card Desktop only; the terminal keeps Claude Code's diff. 5 Shell output card Desktop only. 6 Code block card Desktop only; the terminal keeps Claude Code's markdown. 7 Table ╭──────────────────────────────────────────────────────────╮ │ … … … │ │ … … … │ │ … … … │ │ … … … │ │ │ ╰──────────────────────────────────────────────────────────╯ 8 Spinner ⠸ Thinking… 12s 9 Band above the prompt: normal, then nudging to compact ▰▰▰▱▱▱▱▱ 42% context ▰▱▱▱▱▱▱▱ 18% 5h 0: Compact ▰▰▰▰▰▰▰▱ 85% context ▰▰▰▰▰▱▱▱ 61% 5h Context is 85% ful 10 Band above a question ▍ Approach · Store 11 Turn footer (terminal) ◆ Done in 41s · 6 tools · +18 −3
Spinner
⠋ Working… <1s
Turn line
◆ Done in 42s · 9 tools
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Tool row
┃ ●─ Edit src/auth.ts 20ms
README

claude-skins

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.

Install

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

What it redraws

SiteDesktop appTerminal
Tool callsA line icon per kind, a spinning ring while it runs, lines changed and time takenA node on the turn's rail with the same facts
EditsA diff card: file, +N −M, numbered changed lines in green and redClaude Code's own diff
Shell commandsA terminal card: status pill, output with stderr apart, long output folded, and a Copy button for the outputClaude Code's own output
Tables in repliesAn animated card: header rule, zebra rows, swatches for colours, coloured diffs; Copy gives the markdownA cell grid with a header band and zebra rows, and the same Copy
Code blocks in repliesA card with the language, line numbers and highlighting, and a Copy buttonClaude Code's own markdown, and a Copy button
SpinnerAn animated icon per phase: thinking, tool use, writing, waitingThe skin's word with a band of light through it
Above the promptRings for context and each plan limit, a Compact button, and a nudge to compact from 70% contextBlock meters and the same button
Turn footer(not raised on desktop)Time, tool count and lines changed
Your promptsA rounded outline sized to what you typed; attached images stay below itThe same
The question dialogA band naming its topics above Claude Code's own dialogThe 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.

Make it yours

  • /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.
  • Ask your agent. The mod gives Claude a 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.

What it can reach

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 .

Add a built-in skin

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

Develop

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.

License

MIT

Source 29 files
hooks/register.tsx 639 lines
1import { 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}
639
hooks/command.ts 109 lines
1import 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}
109
hooks/custom.ts 185 lines
1import type { CustomSkin, SkinSlot } from '../types'
2import type { Skin } from './skin'
3import { SKINS } from './themes'
4
5export const SLOTS: readonly SkinSlot[] = [
6  'user',
7  'fg',
8  'muted',
9  'surface',
10  'zebra',
11  'ok',
12  'err',
13  'warn',
14  'read',
15  'write',
16  'run',
17  'search',
18  'web',
19  'mcp',
20  'other',
21]
22
23// What each slot paints, for the settings pane and the agent's tool.
24export const SLOT_HELP: Readonly<Record<SkinSlot, string>> = {
25  user: 'accent: the rail, your prompt marker, the spinner',
26  fg: 'body text',
27  muted: 'secondary text: commands, paths, timings',
28  surface: 'band behind a table header',
29  zebra: 'every other table row; keep it a step off the background',
30  ok: 'a finished call',
31  err: 'a failed call',
32  warn: 'an interrupted call',
33  read: 'Read',
34  write: 'Edit and Write',
35  run: 'Bash and PowerShell',
36  search: 'Glob and Grep',
37  web: 'WebFetch and WebSearch',
38  mcp: 'MCP tools',
39  other: 'anything else in a group',
40}
41
42export type Customs = Readonly<Record<string, CustomSkin>>
43
44const HEX = /^#[0-9a-f]{6}$/i
45const NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
46const MAX_WORDS = 12
47const MAX_WORD = 24
48
49const builtIn = (name: string): Skin | undefined => SKINS.find(skin => skin.name === name)
50
51export const skinNames = (custom: Customs): string[] => [
52  ...SKINS.map(skin => skin.name),
53  ...Object.keys(custom).filter(name => builtIn(name) === undefined),
54]
55
56// A made skin is its base with the slots and words it changed laid over it.
57export function resolveSkin(name: string, custom: Customs): Skin | undefined {
58  const own = builtIn(name)
59
60  if (own !== undefined) {
61    return own
62  }
63
64  const made = custom[name]
65  const base = made === undefined ? undefined : (builtIn(made.base) ?? SKINS[0])
66
67  if (made === undefined || base === undefined) {
68    return undefined
69  }
70
71  return {
72    name: made.name,
73    label: made.label,
74    palette: { ...base.palette, ...made.palette },
75    spinner: made.spinner.length > 0 ? made.spinner : base.spinner,
76    done: made.done.length > 0 ? made.done : base.done,
77  }
78}
79
80export type Draft = {
81  name?: unknown
82  label?: unknown
83  base?: unknown
84  palette?: unknown
85  spinner?: unknown
86  done?: unknown
87}
88
89function words(value: unknown, field: string, fallback: string[]): string[] | string {
90  if (value === undefined) {
91    return fallback
92  }
93
94  const valid =
95    Array.isArray(value) &&
96    value.length <= MAX_WORDS &&
97    value.every(word => typeof word === 'string' && word.trim() !== '' && word.length <= MAX_WORD)
98
99  return valid
100    ? (value as string[]).map(word => word.trim())
101    : `${field} must be up to ${MAX_WORDS} words of 1-${MAX_WORD} characters`
102}
103
104function palette(value: unknown, fallback: CustomSkin['palette']): CustomSkin['palette'] | string {
105  if (value === undefined) {
106    return fallback
107  }
108
109  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
110    return 'palette must be an object of slot: "#rrggbb"'
111  }
112
113  const entries = Object.entries(value as Record<string, unknown>)
114  const bad = entries.filter(
115    ([slot, hex]) => !SLOTS.includes(slot as SkinSlot) || typeof hex !== 'string' || !HEX.test(hex),
116  )
117
118  if (bad.length > 0) {
119    return `bad palette entries: ${bad.map(([slot]) => slot).join(', ')}. Slots: ${SLOTS.join(', ')}; values #rrggbb`
120  }
121
122  return { ...fallback, ...Object.fromEntries(entries.map(([slot, hex]) => [slot, (hex as string).toLowerCase()])) }
123}
124
125// Builds a made skin from what the agent or the pane sent, or says what is wrong with it.
126export function buildCustom(draft: Draft, existing: CustomSkin | undefined): CustomSkin | string {
127  const name = typeof draft.name === 'string' ? draft.name.trim().toLowerCase() : ''
128
129  if (!NAME.test(name)) {
130    return 'name must be 1-32 lowercase letters, digits or hyphens'
131  }
132
133  if (builtIn(name) !== undefined || name === 'off') {
134    return `"${name}" is taken by a built-in skin; pick another name`
135  }
136
137  const base = typeof draft.base === 'string' ? draft.base : (existing?.base ?? SKINS[0]?.name ?? '')
138
139  if (builtIn(base) === undefined) {
140    return `base must be one of ${SKINS.map(skin => skin.name).join(', ')}`
141  }
142
143  const label = typeof draft.label === 'string' && draft.label.trim() !== '' ? draft.label.trim().slice(0, 32) : (existing?.label ?? name)
144  const colours = palette(draft.palette, existing?.palette ?? {})
145  const spinner = words(draft.spinner, 'spinner', existing?.spinner ?? [])
146  const done = words(draft.done, 'done', existing?.done ?? [])
147
148  for (const result of [colours, spinner, done]) {
149    if (typeof result === 'string') {
150      return result
151    }
152  }
153
154  return {
155    name,
156    label,
157    base,
158    palette: colours as CustomSkin['palette'],
159    spinner: spinner as string[],
160    done: done as string[],
161  }
162}
163
164// One slot changed from the settings pane: a built-in skin forks into `my-<name>`.
165export function withSlot(
166  current: string,
167  custom: Customs,
168  slot: SkinSlot,
169  hex: string,
170): { name: string; skin: CustomSkin } | string {
171  if (!HEX.test(hex)) {
172    return 'use a colour like #7aa2f7'
173  }
174
175  const made = custom[current]
176  const name = made === undefined ? `my-${current}` : current
177  const base = made?.base ?? current
178  const skin = buildCustom(
179    { name, base, label: made?.label ?? `My ${current}`, palette: { [slot]: hex } },
180    custom[name],
181  )
182
183  return typeof skin === 'string' ? skin : { name, skin }
184}
185
hooks/light.ts 79 lines
1import 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)
79
hooks/folders.ts 21 lines
1import type { Prefs } from '../types'
2import { parsePrefs } from './command'
3
4// Folders that keep their own look, by the session's directory. A folder not named
5// here follows the default prefs.
6export type Folders = Record<string, Prefs>
7
8export function parseFolders(raw: unknown, names: readonly string[]): Folders {
9  const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
10
11  return Object.fromEntries(Object.entries(saved).map(([folder, prefs]) => [folder, parsePrefs(prefs, names)]))
12}
13
14export const prefsFor = (folder: string, folders: Folders, fallback: Prefs): Prefs =>
15  (Object.hasOwn(folders, folder) ? folders[folder] : undefined) ?? fallback
16
17export const withFolder = (folders: Folders, folder: string, prefs: Prefs): Folders => ({ ...folders, [folder]: prefs })
18
19export const withoutFolder = (folders: Folders, folder: string): Folders =>
20  Object.fromEntries(Object.entries(folders).filter(([name]) => name !== folder))
21
hooks/designer.ts 135 lines
1import 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}
135
hooks/format.ts 85 lines
1// Plain string helpers behind the row builders.
2
3export const oneLine = (text: string): string =>
4  (text.split('\n', 1)[0] ?? '').replace(/\s+/g, ' ').trim()
5
6const slashed = (path: string): string => path.replace(/\\/g, '/')
7
8// A path under the session's directory shows relative to it; any other stays as given.
9export function shortenPath(path: string, cwd: string): string {
10  const full = slashed(path)
11  const root = slashed(cwd).replace(/\/+$/, '')
12
13  if (root === '' || !full.toLowerCase().startsWith(`${root.toLowerCase()}/`)) {
14    return path
15  }
16
17  return full.slice(root.length + 1)
18}
19
20export function formatDuration(ms: number): string {
21  const seconds = Math.round(ms / 1000)
22
23  if (seconds < 1) {
24    return '<1s'
25  }
26
27  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
28}
29
30// A tool call's own time: `340ms`, `2.1s`, `1m 4s`.
31export function formatMs(ms: number): string {
32  if (ms < 1000) {
33    return `${Math.round(ms)}ms`
34  }
35
36  return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms)
37}
38
39// Lines added and removed, read from an Edit or Write record's patch.
40export function diffstat(output: unknown): { added: number; removed: number } | null {
41  const patch = (output as { structuredPatch?: unknown } | null)?.structuredPatch
42
43  if (!Array.isArray(patch)) {
44    return null
45  }
46
47  const lines = patch.flatMap(hunk => {
48    const hunkLines = (hunk as { lines?: unknown } | null)?.lines
49
50    return Array.isArray(hunkLines) ? hunkLines.filter(line => typeof line === 'string') : []
51  }) as string[]
52
53  return {
54    added: lines.filter(line => line.startsWith('+')).length,
55    removed: lines.filter(line => line.startsWith('-')).length,
56  }
57}
58
59// The same seed always picks the same item, so a row keeps its word when it redraws.
60export function pick<T>(items: readonly T[], seed: string): T | undefined {
61  let hash = 5381
62
63  for (let i = 0; i < seed.length; i++) {
64    hash = (hash * 33 + seed.charCodeAt(i)) >>> 0
65  }
66
67  return items[hash % items.length]
68}
69
70// Keeps the first `head` and last `tail` lines of a long text and names how many it hid.
71export function clipLines(text: string, head: number, tail: number): string {
72  const lines = text.split('\n')
73  const hidden = lines.length - head - tail
74
75  if (hidden <= 1) {
76    return text
77  }
78
79  return [
80    ...lines.slice(0, head),
81    `… ${hidden} lines hidden`,
82    ...lines.slice(lines.length - tail),
83  ].join('\n')
84}
85
hooks/markdown.ts 211 lines
1// Finds the tables and fenced code in a reply so they can be drawn as cards; the rest
2// stays markdown.
3
4export type Align = 'left' | 'right' | 'center'
5
6export type Table = { kind: 'table'; header: string[]; align: Align[]; rows: string[][] }
7
8// `raw` is the fence as written, for the surfaces that keep Claude Code's own drawing.
9export type Code = { kind: 'code'; lang: string; code: string; raw: string }
10
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}
211
hooks/rows.tsx 560 lines
1import 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}
560
hooks/gallery.tsx 104 lines
1import 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}
104
hooks/settings.tsx 145 lines
1import 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}
145
hooks/skin.ts 57 lines
1import 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