SLOPSHOPPER

skins

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

newpanebandspinnerrowsguard
v0.1.2+cockpit.2MITupdated 2026-10-10cGradying/claude-code-cockpit/skins
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skins
│ ┃ Skins ✕ › fix the failing auth test and add an audit log call │ ┃ Skin 1: noir 2: tokyo-night 3: dracula 4 │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ │ ╭──────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ add rate limiting to the hub api │ ⏺ Bash(bun test) │ ┃ │ ╰──────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ │ │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ │ ┃ │ ●─ Bash pnpm test --filter hub 2.1s ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ │ ┃ │ ●─ Edit apps/hub/src/server.ts +18 −3 3 › /skin │ ┃ │ ┃ │ ┃ │ ●─ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Skins
Skin 1: noir 2: tokyo-night 3: dracula 4: nord 5: gruvb ╭──────────────────────────────────────────────────────────╮ │ │ │ ╭──────────────────────────────────────────────────────╮ │ │ │ add rate limiting to the hub api │ │ │ ╰──────────────────────────────────────────────────────╯ │ │ │ │ │ │ ┃ │ │ ●─ Bash pnpm test --filter hub 2.1s │ │ ┃ │ │ ●─ Edit apps/hub/src/server.ts +18 −3 380ms │ │ ┃ │ │ ●─ Read 2 · Search 1 │ │ │ │ ╭──────────────────────────────────────────────────────╮ │ │ │ Route Limit Window │ │ │ │ /chat 60 1 min │ │ │ │ /upload 10 1 min │ │ │ │ /search 120 1 min │ │ │ │ │ │ │ ╰──────────────────────────────────────────────────────╯ │ │ │ │ ◆ Done in 41s · 6 tools · +18 −3 │ ╰──────────────────────────────────────────────────────────╯ Look r: rail ● t: tables on s: shimmer ● b: band ○ c: c user fg muted surface zebra ok er Colour: user ▾ #f5f5f5: #rrggbb ⏎ paint Or ask Claude: “make me a skin that feels like a sunset”. Esc closes.
Pane · skins-gallery
The gallery shows a skin. Pick one with /skin first.
README

claude-code-cockpit

A one-line band above the Claude Code prompt, a roomy Cockpit panel beside the conversation, and seven community mods, installed as one plugin. The band shows only what you glance at. The panel has everything else: prompt-cache countdown, context, plan limits with their reset times, session cost, your next tasks and project shortcuts. Everything that spends tokens or redraws your screen starts off.

 ◔ 42m   ctx 72.5k   Next  Write docs/specs/PLAN-2.md for the phase 2 slices        Continue   Details

The band is drawn with Claude's own text and buttons, so it takes the app's fonts and theme, truncates the task by itself, and cannot flash or resize. Details (or /cockpit) opens the panel as a tab in the right-hand side panel.

<img src="docs/images/band.png" alt="The band above the prompt in the Claude desktop app: working, context 704.8k at 70 percent, the next task, and a Details button" width="720">

<img src="docs/images/panel.png" alt="The Cockpit panel in the side panel: prompt cache with a green bar, context at 70 percent, plan limits with reset times, session cost, next up and shortcuts" width="320">

These are screenshots of the real desktop app, showing the dark theme.

What is inside

PluginWhat it doesDefault hereOrigin
cockpitThe band, the Cockpit panel, next-task list, Continue / What's next / Compact buttons, guards, and a cheat-sheet skillonthis repo (MIT)
cache-taxShows the rewrite price before a cold send; opt-in keep-warm for the prompt cachewarns, never drops; keep-warm offkaranb192/cache-tax
claude-reflect (reflect-mod)Notices corrections and offers to save them as rules in CLAUDE.mdpausedBayramAnnakov/claude-reflect
skinsRedraws the transcript: tool rows, diff cards, table and code cards, spinneroff, its own band offhellosverre/claude-skins
filetreeIDE-style file tree in the sidebar with git status and read/write activityopens with /filetree (does not dock by itself)data-goblin/claude-code-filetree
replay-theaterStep through the last turn's file edits, one diff at a timeonanthropics/claude-code-playground
blast-radiusHolds rm -r, git reset --hard, git clean, force push and migrations until you press Proceedonanthropics/claude-code-playground
savvy-progressProgress bar and agents panel for flows and subagentsidle until something reportsJohnnyVizz/claude-kit

The seven community mods are vendored here at pinned commits, with small patches (listed in each folder's SOURCE.md) so they start quiet and stack above each other instead of replacing each other's band. Their original licences are kept.

Install

Needs Claude Code 2.1.287 or later.

/plugin marketplace add cGradying/claude-code-cockpit
/plugin install cockpit@janvin-mods

or from a terminal:

claude plugin marketplace add cGradying/claude-code-cockpit
claude plugin install cockpit@janvin-mods --scope user

cockpit lists the seven mods as dependencies, so installing it installs them all. If a dependency does not install by itself, install it by name, for example claude plugin install skins@janvin-mods. Then restart Claude Code (or the desktop app) and open a new session.

To remove everything: /plugin, pick cockpit, uninstall, and do the same for each mod you no longer want. Each one is independent.

A quick tour

The band. One line above the prompt: the cache countdown (◔ 42m, green, then amber, then red), context, and the next task. The 5-hour and weekly limits join the line only once they pass 60%, and 9router down shows only if it was up and stopped. Buttons sit at the right; click them, or focus the band (ctrl+x then tab) and press the key.

ButtonKeyDoes
ContinuecSends "continue with the next task" for the first open line of .claude/NEXT.md, and asks Claude to check it off when done. Hidden while a turn runs.
DetailsdOpens the Cockpit panel.
CompactkAppears past 70% context. Runs /compact and tells it to keep the plan, open tasks, PRs, decisions and open bugs.

The Cockpit panel. One tab in the side panel with room for each number: prompt cache (time left, a bar, what happens if it lapses), context (tokens, percent, window size, Compact), plan limits (each with a bar and when it resets), this session (cost, 9router), your next five tasks (Continue, What's next?), and your project shortcuts. Bars are solid boxes sized in percent and follow the app's light or dark theme. Open it with Details or /cockpit.

Guards that run quietly. A shell command carrying a Claude or Anthropic co-author line is refused. A prompt containing a pasted API key is held once (press Enter again to send it anyway). Prompts about UI or design get a hidden nudge toward the impeccable skill if you have it.

Commands. /cockpit, /next, /keepwarm, /cache-tax, /reflect-pause, /reflect-queue, /skin, /filetree, /replay, /agents-info. Details in docs/COMMANDS.md; inside Claude Code, the cockpit:help skill prints the same cheat sheet.

Quiet by default

Three things in the bundle can cost tokens or change your screen, so they start off:

  • Keep-warm (cache-tax) sends a real model request about every 50 minutes while armed. It is off; you arm it with /keepwarm. The cold-send guard only warns by default: it shows the rewrite price and still sends your message.
  • Correction checking (claude-reflect) sends short prompts to Haiku. It starts paused; /reflect-pause turns it on. remember: <rule> works either way and needs no model.
  • Skins redraw the whole transcript. It starts off; /skin noir turns it on.

How to change each: docs/CONFIGURATION.md.

Documentation

Status and limits

  • Written for Claude Code's mods feature, which is early access and may change between releases.
  • The band and panel were built by looking at the real desktop app, and are tested with Claude Code's plugin test kit on the terminal and desktop surfaces. They have been run in the desktop app's Code tab; the terminal layout is covered by tests but has had less eyes on it. Please open an issue with a screenshot if something looks wrong.
  • The file tree opens with /filetree (set autoOpen to on to dock it at session start). It needs a wide window, and upstream says it does not load in WSL sessions of the desktop app.
  • Cache timings assume the one-hour prompt cache. With a five-minute cache the countdown will be wrong.

Credits and licences

The code in cockpit/ is MIT, Copyright (c) 2026 cGradying (see LICENSE). Each vendored mod keeps its own licence file and authorship: the two Anthropic mods are Apache-2.0, the rest are MIT. See THIRD_PARTY_NOTICES.md. Thanks to the authors of all seven.

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