SLOPSHOPPER

skins

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

newpanebandspinnerrowsguard
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/skins
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skins
│ ┃ Look ✕ │ ┃ THEME 8 skins ╭─────────────────────────────────────────────────────────────────╮ │ ┃ ●●● [ tidemark ✓ ] ●●● 2: noir ●●● 3: toky │ fix the failing auth test and add an audit log call │ │ ┃ ╰─────────────────────────────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ │ │ ┃ │ ╭──────────────────────────────────────╮ │ │ ┃ │ │ add rate limiting to the hub api │ │ │ ┃ │ ╰──────────────────────────────────────╯ │ ⏺ Read(src/auth.ts) │ ┃ │ │ ⎿ Read 6 lines │ ┃ │ │ ┃ │ ┃ │ │ ●─ Edit src/auth.ts 20ms │ ┃ │ ┃ │ ⏺ Bash(bun test) │ ┃ │ ●─ Bash pnpm test 2.1s │ ⎿ 3 pass, 1 fail │ ┃ │ ┃ │ │ ┃ │ ●─ Edit src/server.ts +18 −3 380ms │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ │ │ ┃ │ ●─ Read 2 · Search 1 │ ◆ Done · 42s · 9 tools │ ┃ │ │ │ ┃ │ ╭──────────────────────────────────────╮ │ › /skin │ ┃ │ │ Route Limit Window │ │ │ ┃ │ │ /chat 60 1 min │ │ │ ┃ │ │ /upload 10 1 min │ │ │ ┃ │ │ /search 120 1 min │ │ │ ┃ │ │ │ │ │ ┃ │ ╰──────────────────────────────────────╯ │ ⠋ Weighing… <1s ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Look
THEME 8 skins ●●● [ tidemark ✓ ] ●●● 2: noir ●●● 3: tokyo-night ●●● 4: ╭──────────────────────────────────────────────────────────╮ │ │ │ ╭──────────────────────────────────────────────────────╮ │ │ │ add rate limiting to the hub api │ │ │ ╰──────────────────────────────────────────────────────╯ │ │ │ │ │ │ │ │ ┃ │ │ ●─ Bash pnpm test 2.1s │ │ ┃ │ │ ●─ Edit 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 · 41s · 6 tools · +18 −3 │ ╰──────────────────────────────────────────────────────────╯ OPTIONS r: ● Reply rail t: ● Table cards s: ● Shimmer b: ○ Usage COLORS pick a slot, type a hex user fg muted surface zebra ok er Color: user ▾ #a94a26: #rrggbb ⏎ paint Or ask Claude: “make a skin that feels like a sunset” g: Op
Pane · skins-gallery
1 Your prompt ╭──────────────────────────────────────────────────────────╮ │ add rate limiting to the hub api │ ╰──────────────────────────────────────────────────────────╯ 2 Tool rows: done, running, failed ┃ ●─ Bash pnpm test --filter hub 2.1s ┃ ○─ Read docs/protocols.md ┃ ✕─ Edit apps/hub/src/server.ts 3 Folded group of reads and searches ┃ ●─ Read 2 · Search 1 4 Edit diff card Desktop only; the terminal keeps Claude Code’s own diff. 5 Shell output card Desktop only. 6 Code block card Desktop only; the terminal keeps Claude Code’s own markdown. 7 Table ╭──────────────────────────────────────────────────────────╮ │ … … … │ │ … … … │ │ … … … │ │ … … … │ │ │ ╰──────────────────────────────────────────────────────────╯ 8 Spinner ⠸ Working… 12s 9 Band above the prompt: normal, then a nudge to compact ▰▰▰▱▱▱▱▱ 42% context ▰▱▱▱▱▱▱▱ 18% 5h 0: Compact ▰▰▰▰▰▰▰▱ 85% context ▰▰▰▰▰▱▱▱ 61% 5h Context 85% full 10 Band above a question ▍ Approach · Store 11 Turn footer (terminal) ◆ Done · 41s · 6 tools · +18 −3
Spinner
⠋ Weighing… <1s
Turn line
◆ Done · 42s · 9 tools
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Tool row
┃ ●─ Edit src/auth.ts 20ms
README

skins

Redraws Claude Code's transcript in a skin: tool calls with icons and timings, edits as diff cards, tables and code as cards, a themed spinner, and /skin to change it all live.

Install

/plugin marketplace add partalemre/MODZ
/plugin install skins@modz

Or in one step: /plugin install skins --marketplace partalemre/MODZ.

The MODZ panel (modz) shows its line here, under the name Look: the active skin and whether it is drawing light or dark, for example tidemark · dark.

What you see

SiteDesktop appTerminal
Tool callsA line icon per kind, a spinning ring while it runs, lines changed and time takenA node on the turn's rail with the same facts
EditsA diff card: file, +N -M, numbered changed lines in green and redClaude Code's own diff
Shell commandsA terminal card: status pill, output with stderr apart, long output folded, and a Copy buttonClaude Code's own output
Tables in repliesAn animated card with zebra rows and swatches for colours; Copy gives the markdownA cell grid with a header band and zebra rows, and the same Copy
Code blocks in repliesA card with the language, line numbers, highlighting and a Copy buttonClaude Code's own markdown, and a Copy button
SpinnerAn animated icon per phase: thinking, tool use, writing, waitingThe skin's word with a band of light through it
Above the prompt (off by default)Rings for context and each plan limit, a Compact button, and a nudge to compact from 70% contextBlock meters and the same button
Turn footernot drawn on the desktop appTime, tool count and lines changed
Your promptsA rounded outline sized to what you typedThe same
The question dialogA band naming its topics above Claude Code's own dialogThe same

Every card rises in row by row and respects reduced motion. A skin only changes what is drawn: the stored conversation, and what the model reads, are untouched. Agents, plan mode and the permission prompt keep Claude Code's own drawing.

There are eight skins, each with a light and a dark palette: tidemark (the default, in MODZ's own colours), noir, tokyo-night, dracula, nord, gruvbox, catppuccin and mono. On a light Claude Code theme every skin switches to its light palette. With the auto theme it follows your terminal's background (COLORFGBG) or, failing that, the system's light or dark mode.

Commands

  • /skin opens the settings pane (named Look): pick a skin, see a live preview, switch the rail, tables, shimmer, band and icons, and repaint any colour. Repainting a built-in skin saves it as your own my-<skin>.
  • /skin gallery shows every element the skin draws, numbered, to point at when you want one changed.
  • /skin <name>, /skin list, /skin off switch skins.
  • /skin rail|tables|shimmer|band|clip on|off switch a part on or off. /skin icons unicode|ascii picks the icon set.
  • /skin pin gives this folder a look of its own; /skin unpin returns it to the default; /skin share makes this folder's look the default for the rest. Made skins are shared by every folder.

Your choices are remembered across sessions.

Ask Claude. The mod gives Claude a design tool (mcp__skins__design) and a short guide, so "make me a skin that feels like a sunset" builds one and applies it while you watch.

Settings

There are no plugin options. Set SKINS_THEME=light or SKINS_THEME=dark in the env block of ~/.claude/settings.json to choose the palette yourself, for example when you launch with claude --settings '{"theme":"dark"}', which skins cannot see.

What it can reach

It draws and remembers. It reads the session's directory, your context and plan usage, Claude Code's theme setting and the SKINS_THEME and COLORFGBG variables, keeps its settings in the mod store, and registers one tool for Claude. It compacts only when you press Compact and copies only when you press Copy. With the auto theme it asks the system for its appearance (defaults read -g AppleInterfaceStyle on macOS, gsettings get org.gnome.desktop.interface color-scheme on GNOME). It makes no network calls and touches no files.

Uses no model tokens unless you ask Claude to design a skin, which is an ordinary turn.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Credits

Adapted from hellosverre/claude-skins, licensed MIT. The license file is in this folder.

Source 60 files
hooks/register.tsx 300 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Frozen, On, Register, RenderInput } from 'claude-code'
3
4import type { SkinSlot, UsageSnap } from '../types'
5import { DEFAULT_PREFS } from './command'
6import { DESIGN_TOOL, runDesign } from './designer'
7import { EMPTY_HUB, HUB_PANE, isOwnOpen } from './hub'
8import { lookOf } from './look'
9import { opening } from './moving-look'
10import { activeSkin, commit, load, syncHub } from './prefs-store'
11import type { PrefsHost } from './prefs-store'
12import { abovePromptView, askView } from './render-bands'
13import { FRAME_MS, replyView, spinnerView, turnDurationView, userMessageView } from './render-messages'
14import { galleryMotion, galleryView, settingsMotion, settingsTitle, settingsView } from './render-panes'
15import type { SettingsHost } from './render-panes'
16import { toolGroupView, toolResultView, toolUseView } from './render-tools'
17import { runSkin, skinCommand } from './skin-command'
18import { PROBE_MS, refreshTheme } from './theme'
19import type { ThemeHost } from './theme'
20import { usageSnap } from './svg-usage'
21import { type Live, NO_STATS, withTurn } from './turn-stats'
22import { onCalls } from './call-marks'
23import type { View } from './view'
24import { setLang } from './lang'
25import { isOffStage, shownOf } from './stage'
26import { say } from './words'
27
28const SETTINGS = 'skins-settings'
29const GALLERY = 'skins-gallery'
30const DESIGN_MATCH = /^mcp__skins__design$/
31// /compact starts on the next tick, in a dispatch of its own (see compact).
32const NEXT_TICK_MS = 1
33
34const prefsAtom = atom({ plugin: 'skins', key: 'prefs' } as const, DEFAULT_PREFS)
35const customAtom = atom({ plugin: 'skins', key: 'custom' } as const, {})
36const startedAtom = atom({ plugin: 'skins', key: 'startedAt' } as const, 0)
37const frameAtom = atom({ plugin: 'skins', key: 'frame' } as const, 0)
38const turnsAtom = atom({ plugin: 'skins', key: 'turns' } as const, {})
39const durationAtom = atom({ plugin: 'skins', key: 'duration' } as const, -1)
40const editingAtom = atom({ plugin: 'skins', key: 'editing' } as const, 'user' as SkinSlot)
41const lightAtom = atom({ plugin: 'skins', key: 'isLight' } as const, false)
42const imagesAtom = atom({ plugin: 'skins', key: 'images' } as const, false)
43const usageAtom = atom({ plugin: 'skins', key: 'usage' } as const, { context: null, limits: [] } as UsageSnap)
44const compactingAtom = atom({ plugin: 'skins', key: 'compacting' } as const, false)
45const pinnedAtom = atom({ plugin: 'skins', key: 'pinned' } as const, false)
46const hubAtom = atom({ plugin: 'skins', key: 'hub' } as const, EMPTY_HUB)
47
48// The prefs store's port (prefs-store.ts).
49function storeOf($: EngineInterface): PrefsHost {
50  return {
51    storeGet: key => $.store.get(key),
52    storeSet: (key, value) => $.store.set(key, value),
53    cwd: () => $.session.cwd(),
54    now: () => $.clock.now(),
55    prefs: () => read($, prefsAtom),
56    setPrefs: prefs => update($, prefsAtom, () => prefs),
57    custom: () => read($, customAtom),
58    setCustom: custom => update($, customAtom, () => custom),
59    isPinned: () => read($, pinnedAtom),
60    setPinned: isPinned => update($, pinnedAtom, () => isPinned),
61    isLight: () => read($, lightAtom),
62    hub: () => read($, hubAtom),
63    setHub: card => update($, hubAtom, () => card),
64  }
65}
66
67// The theme's port (theme.ts).
68function themeOf($: EngineInterface): ThemeHost {
69  return {
70    hints: async () => {
71      const rows = await $.config.list()
72
73      return { override: await $.env.get('SKINS_THEME'), theme: rows.find(row => row.key === 'theme')?.value, colorfgbg: await $.env.get('COLORFGBG') }
74    },
75    probe: argv => $.process.run(argv, { timeoutMs: PROBE_MS }),
76    setLight: isLight => update($, lightAtom, () => isLight),
77  }
78}
79
80async function refreshUsage($: EngineInterface): Promise<void> {
81  const snap = usageSnap(await $.session.usage())
82  await update($, usageAtom, () => snap)
83}
84
85// Opened by the person (/skin, or a press on its MODZ hub row) it asks for the keyboard: the hub
86// stays open, so only a focused pane comes in front of it. Opened otherwise it takes none, so
87// letters typed meant elsewhere never reach its hotkeys.
88async function openSettings($: EngineInterface, isAsked: boolean): Promise<void> {
89  const opened = await opening(settingsMotion, () => $.ui.open({ id: SETTINGS, title: settingsTitle(), closeOnEscape: true, ...(isAsked ? { focus: true } : {}) }))
90  if (isAsked && opened.isPlaced) await reportShown($)
91}
92
93// The settings, opened when asked, take a running hub's one panel, so the hub and every other pane step aside
94// (kit/stage.ts). A failed note of that is only logged: the pane is open either way.
95async function reportShown($: EngineInterface): Promise<void> {
96  try {
97    if ((await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
98    await $.state.set({ plugin: 'skins', key: 'shown' }, shownOf(HUB_PANE, await $.clock.now()))
99  } catch {
100    $.ui.log('skins: could not tell the hub the settings are shown', { to: 'debug' })
101  }
102}
103
104const openGallery = ($: EngineInterface) => opening(galleryMotion, () => $.ui.open({ id: GALLERY, title: say().galleryTitle, focus: true, closeOnEscape: true }))
105
106// Runs Claude Code's own /compact, so the person sees its usual progress and result.
107// Work a press starts is abandoned when the press ends, which cancels a compaction
108// still running, so a timer starts it in a dispatch of its own, which returns the run
109// so that dispatch lasts until the compaction ends. The button hides until then, so
110// repeated presses do not queue one /compact each.
111function compact($: EngineInterface): void {
112  void update($, compactingAtom, () => true)
113  $.clock.after(NEXT_TICK_MS, () =>
114    $.command
115      .run({ command: 'compact' })
116      .catch((error: unknown) => $.ui.toast(say().compactFailed(error instanceof Error ? error.message : String(error))))
117      .finally(() => update($, compactingAtom, () => false)),
118  )
119}
120
121// The render modules' port (view.ts), for the drawing `e`.
122function viewOf($: EngineInterface, e: Frozen<RenderInput>, live: Live): View {
123  const copy = (text: string) => void $.ui.copy({ text, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? say().copied : say().notCopied))
124
125  return {
126    active: () => activeSkin(storeOf($)),
127    ui: () => $.ui.resolve(e),
128    look: (active, isCopying = false) => lookOf({ ui: $.ui.resolve(e), active, surface: e.surface, ...(isCopying ? { copy } : {}) }),
129    cwd: () => $.session.cwd(),
130    now: () => $.clock.now(),
131    duration: () => read($, memberOf(durationAtom, e)),
132    hasImages: () => read($, memberOf(imagesAtom, e)),
133    frame: () => read($, frameAtom),
134    startedAt: () => read($, startedAtom),
135    turns: () => read($, turnsAtom),
136    usage: () => read($, usageAtom),
137    isCompacting: () => read($, compactingAtom),
138    compact: () => compact($),
139    drewAt: columns => {
140      live.lastColumns = columns
141    },
142  }
143}
144
145// The way back to the MODZ hub: this mod's own `home` value, which the hub hears. A press never throws.
146function goHome($: EngineInterface): void {
147  void $.clock.now()
148    .then(at => $.state.set({ plugin: 'skins', key: 'home' }, { at }))
149    .catch(() => $.ui.log('skins: could not go back to the hub', { to: 'debug' }))
150}
151
152// The settings pane's port (render-panes.tsx).
153function paneOf($: EngineInterface, e: Frozen<RenderInput>): SettingsHost {
154  return {
155    ...storeOf($),
156    ui: () => $.ui.resolve(e),
157    editing: () => read($, editingAtom),
158    isHubOn: async () => (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true,
159    ports: {
160      home: () => goHome($),
161      commit: state => void commit(storeOf($), state),
162      edit: slot => void update($, editingAtom, () => slot),
163      toast: text => $.ui.toast(text),
164      gallery: () => void openGallery($),
165    },
166  }
167}
168
169async function refreshLook($: EngineInterface): Promise<void> {
170  await load(storeOf($))
171  await refreshTheme(themeOf($))
172  await syncHub(storeOf($))
173}
174
175function onSession(on: On, live: Live): void {
176  on('session.start', async ($, e, next) => {
177    // The interface language (kit/lang.ts). Spelled out: the engine admits an env read by a literal name only.
178    setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
179    await $.command.register(skinCommand())
180    await $.tool.register({ name: DESIGN_TOOL.name, description: DESIGN_TOOL.description, inputSchema: DESIGN_TOOL.inputSchema })
181    await load(storeOf($))
182    await refreshUsage($)
183    await refreshTheme(themeOf($))
184    await syncHub(storeOf($))
185
186    // Only the spinner reads the frame, so a tick redraws the spinner and nothing else.
187    live.ticker?.cancel()
188    live.ticker = $.clock.every(FRAME_MS, () => {
189      if (live.isWorking) void update($, frameAtom, frame => frame + 1)
190    })
191
192    return next(e)
193  })
194
195  // /clear, /resume and /branch reset $.state to its defaults and skip session.start.
196  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
197    await refreshLook($)
198
199    return next(e)
200  })
201}
202
203function onTurnStart(on: On, live: Live): void {
204  on('turn.start', async ($, e, next) => {
205    live.stats = NO_STATS
206    live.isWorking = true
207    const now = await $.clock.now()
208    await update($, startedAtom, () => now)
209
210    return next(e)
211  })
212
213  on('config.set', async ($, e, next) => {
214    const result = await next(e)
215    if (e.key === 'theme') {
216      await refreshTheme(themeOf($))
217      await syncHub(storeOf($))
218    }
219
220    return result
221  })
222}
223
224function onTurnEnd(on: On, live: Live): void {
225  // A turn's end and a plan limit's move are when the band's numbers change.
226  on('session.measure', async ($, e, next) => {
227    await refreshUsage($)
228
229    return next(e)
230  })
231
232  on('turn.complete', async ($, e, next) => {
233    if (e.agentId === undefined) {
234      await refreshUsage($)
235      live.isWorking = false
236      const finished = live.stats
237      await update($, turnsAtom, turns => withTurn(turns, e.durationMs, finished))
238    }
239
240    return next(e)
241  })
242
243  onCalls(on, live)
244}
245
246function onCommands(on: On, live: Live): void {
247  on('tool.call', { tool: DESIGN_MATCH }, async ($, e) => {
248    const outcome = runDesign(e, { prefs: await read($, prefsAtom), custom: await read($, customAtom) })
249    if (outcome.isError) return { deny: outcome.text }
250    await commit(storeOf($), outcome.state)
251
252    return { result: outcome.text }
253  })
254
255  // The MODZ main panel asks for the settings by writing `{ pane: 'skins' }` to `modz.open`.
256  // modz's contract is not this mod's to import, so its address stays untyped here.
257  on('state.set', { plugin: 'modz', key: 'open' } as any, async ($: EngineInterface, e: any, next: any) => {
258    const done = await next(e)
259    if (isOwnOpen(e.value)) await openSettings($, true)
260
261    return done
262  }).catch(($: EngineInterface, e: any, next: any) => next(e))
263
264  // Another pane took the hub's one panel: the settings and the gallery step aside rather than wait as tabs.
265  on('state.set', { plugin: 'modz', key: 'stage' } as any, async ($: EngineInterface, e: any, next: any) => {
266    const done = await next(e)
267    if (isOffStage(e.value, HUB_PANE)) for (const id of [SETTINGS, GALLERY]) await $.ui.close({ id })
268
269    return done
270  }).catch(($: EngineInterface, e: any, next: any) => next(e))
271
272  on('command.run', { command: 'skin' }, ($, e) =>
273    runSkin({ ...storeOf($), openSettings: () => openSettings($, true), openGallery: () => openGallery($), toast: text => $.ui.toast(text), columns: () => live.lastColumns }, e.args),
274  )
275}
276
277function onRenders(on: On, live: Live): void {
278  on('ui.render', { component: 'Pane', requestId: SETTINGS }, ($, e) => settingsView(paneOf($, e), e))
279  on('ui.render', { component: 'Pane', requestId: GALLERY }, ($, e) => galleryView({ view: viewOf($, e, live), e }))
280  on('ui.render', { component: 'ToolUse' }, ($, e, next) => toolUseView({ view: viewOf($, e, live), e, pass: next }))
281  on('ui.render', { component: 'ToolGroup' }, ($, e, next) => toolGroupView({ view: viewOf($, e, live), e, pass: next }))
282  on('ui.render', { component: 'ToolResult' }, ($, e, next) => toolResultView({ view: viewOf($, e, live), e, pass: next }))
283  on('ui.render', { component: 'UserMessage' }, ($, e, next) => userMessageView({ view: viewOf($, e, live), e, pass: next }))
284  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => replyView({ view: viewOf($, e, live), e, pass: next }))
285  on('ui.render', { component: 'Spinner' }, ($, e, next) => spinnerView({ view: viewOf($, e, live), e, pass: next }))
286  on('ui.render', { component: 'TurnDuration' }, ($, e, next) => turnDurationView({ view: viewOf($, e, live), e, pass: next }))
287  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => abovePromptView({ view: viewOf($, e, live), e, pass: next }))
288  on('ui.render', { component: 'AskUserQuestion' }, ($, e, next) => askView({ view: viewOf($, e, live), e, pass: next }))
289}
290
291export const register: Register = on => {
292  const live: Live = { stats: NO_STATS, isWorking: false }
293
294  onSession(on, live)
295  onTurnStart(on, live)
296  onTurnEnd(on, live)
297  onCommands(on, live)
298  onRenders(on, live)
299}
300
hooks/command.ts 126 lines
1import type { Prefs } from '../types'
2import { say } from './words'
3
4export const DEFAULT_PREFS: Prefs = {
5  skin: 'tidemark',
6  icons: 'unicode',
7  rail: true,
8  tables: true,
9  shimmer: true,
10  // Off by default: another mod (hud-pro) shows usage. /skin band on brings it back.
11  band: false,
12  clipOutput: false,
13}
14
15// The on/off settings, by the word /skin and the settings pane use for each.
16export const TOGGLES = {
17  rail: 'rail',
18  tables: 'tables',
19  shimmer: 'shimmer',
20  band: 'band',
21  clip: 'clipOutput',
22} as const satisfies Record<string, keyof Prefs>
23
24export type ToggleWord = keyof typeof TOGGLES
25
26export type Outcome = {
27  prefs: Prefs
28  message: string
29  // `row` prints a transcript line (listings, mistakes); `toast` confirms a change quietly.
30  channel: 'row' | 'toast'
31}
32
33const flagOr = (value: unknown, fallback: boolean): boolean =>
34  typeof value === 'boolean' ? value : fallback
35
36// What the store hands back may be old, hand-edited or from another version.
37export function parsePrefs(raw: unknown, names: readonly string[]): Prefs {
38  const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
39  const skin = saved.skin
40
41  return {
42    skin:
43      typeof skin === 'string' && (skin === 'off' || names.includes(skin))
44        ? skin
45        : DEFAULT_PREFS.skin,
46    icons: saved.icons === 'ascii' ? 'ascii' : 'unicode',
47    rail: flagOr(saved.rail, DEFAULT_PREFS.rail),
48    tables: flagOr(saved.tables, DEFAULT_PREFS.tables),
49    shimmer: flagOr(saved.shimmer, DEFAULT_PREFS.shimmer),
50    band: flagOr(saved.band, DEFAULT_PREFS.band),
51    clipOutput: flagOr(saved.clipOutput, DEFAULT_PREFS.clipOutput),
52  }
53}
54
55const changed = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'toast' })
56
57const refused = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'row' })
58
59const onOff = (on: boolean): string => (on ? 'on' : 'off')
60
61export const listing = (current: Prefs, names: readonly string[]): string =>
62  [
63    ...names.map(name => `${name === current.skin ? '●' : '○'} ${name}`),
64    `${current.skin === 'off' ? '●' : '○'} off`,
65    [
66      `icons ${current.icons}`,
67      `rail ${onOff(current.rail)}`,
68      `tables ${onOff(current.tables)}`,
69      `shimmer ${onOff(current.shimmer)}`,
70      `band ${onOff(current.band)}`,
71      `clip ${onOff(current.clipOutput)}`,
72    ].join(' · '),
73  ].join('\n')
74
75const isToggle = (word: string): word is ToggleWord => Object.hasOwn(TOGGLES, word)
76
77// Every argument but none: with none, /skin opens the settings pane instead.
78export function runSkinCommand(args: string, current: Prefs, names: readonly string[]): Outcome {
79  const [head = '', value] = args.trim().toLowerCase().split(/\s+/)
80
81  if (isToggle(head)) {
82    const field = TOGGLES[head]
83
84    return value === 'on' || value === 'off'
85      ? changed({ ...current, [field]: value === 'on' }, `${head} ${value}`)
86      : refused(current, say().usage(head))
87  }
88
89  switch (head) {
90    case 'list':
91      return refused(current, listing(current, names))
92    // `default` and `stock` are what people reach for when they want Claude Code's own look.
93    case 'off':
94    case 'default':
95    case 'stock':
96      return changed({ ...current, skin: 'off' }, say().skinOff)
97    case 'on':
98      return current.skin === 'off'
99        ? changed({ ...current, skin: DEFAULT_PREFS.skin }, `skin: ${DEFAULT_PREFS.skin}`)
100        : changed(current, `skin: ${current.skin}`)
101    case 'icons':
102      return value === 'unicode' || value === 'ascii'
103        ? changed({ ...current, icons: value }, `icons: ${value}`)
104        : refused(current, say().iconsUsage)
105    default:
106      return names.includes(head)
107        ? changed({ ...current, skin: head }, `skin: ${head}`)
108        : refused(current, say().unknown(head))
109  }
110}
111
112export type RowContext = { args: string; isPinned: boolean; columns: number | undefined }
113
114// /skin list also says whether this folder is pinned, and the width the last reply was
115// drawn at, to tune table sizing.
116export function rowReply(message: string, { args, isPinned, columns }: RowContext): string {
117  if (args.trim() !== 'list') {
118    return message
119  }
120
121  const width = columns === undefined ? '' : `\n${say().replyWidth(columns)}`
122  const folder = isPinned ? say().folderPinned : say().folderFollows
123
124  return `${message}\n${folder}${width}`
125}
126
hooks/designer.ts 138 lines
1import type { CustomSkin, Prefs } from '../types'
2import { DEFAULT_PREFS } from './command'
3import { buildCustom, resolveSkin, SLOT_HELP, skinNames } from './custom'
4import { SKINS } from './themes'
5
6// The tool the person's own agent calls to design a skin with them.
7export const DESIGN_TOOL = {
8  name: 'design',
9  description: [
10    'Design, change and apply skins for the skins mod, which restyles this Claude Code transcript:',
11    'tool rows on a timeline rail, tables, the spinner, the turn footer. Changes show at once.',
12    'Call {"action":"show"} first: it returns the current skin, every slot with what it paints, and the names in use.',
13    '"save" makes or updates a skin: name (lowercase, hyphens), base (a built-in skin it starts from),',
14    'palette ({slot: "#rrggbb"}, only the slots to change), spinner (gerunds, like "Brewing"),',
15    'done (past tense, like "Brewed"), apply (default true).',
16    '"apply" switches to a skin by name, "delete" removes a made one,',
17    '"settings" switches rail, tables, shimmer, band, clip (booleans) and icons ("unicode" or "ascii").',
18    'Good skins keep fg and muted readable on a dark background, and surface and zebra one small step off it.',
19  ].join(' '),
20  inputSchema: {
21    type: 'object',
22    properties: {
23      action: { type: 'string', enum: ['show', 'save', 'apply', 'delete', 'settings'] },
24      name: { type: 'string' },
25      label: { type: 'string' },
26      base: { type: 'string', enum: SKINS.map(skin => skin.name) },
27      palette: { type: 'object', additionalProperties: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' } },
28      spinner: { type: 'array', items: { type: 'string' }, maxItems: 12 },
29      done: { type: 'array', items: { type: 'string' }, maxItems: 12 },
30      apply: { type: 'boolean' },
31      settings: {
32        type: 'object',
33        properties: {
34          rail: { type: 'boolean' },
35          tables: { type: 'boolean' },
36          shimmer: { type: 'boolean' },
37          band: { type: 'boolean' },
38          clip: { type: 'boolean' },
39          icons: { type: 'string', enum: ['unicode', 'ascii'] },
40        },
41      },
42    },
43    required: ['action'],
44  },
45} as const
46
47// The tool's name as the engine calls it, for telling its calls from the turn's own work.
48export const DESIGN = `mcp__skins__${DESIGN_TOOL.name}`
49
50export type DesignState = { prefs: Prefs; custom: Record<string, CustomSkin> }
51
52export type DesignOutcome = { text: string; state: DesignState; isError: boolean }
53
54type Input = Record<string, unknown>
55
56const fail = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: true })
57
58const ok = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: false })
59
60function show(state: DesignState): DesignOutcome {
61  const skin = resolveSkin(state.prefs.skin, state.custom)
62
63  return ok(
64    state,
65    JSON.stringify({
66      current: state.prefs.skin,
67      palette: skin?.palette ?? null,
68      spinner: skin?.spinner ?? [],
69      done: skin?.done ?? [],
70      builtIn: SKINS.map(one => one.name),
71      made: Object.keys(state.custom),
72      slots: SLOT_HELP,
73      settings: state.prefs,
74    }),
75  )
76}
77
78function save(input: Input, state: DesignState): DesignOutcome {
79  const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
80  const skin = buildCustom(input, state.custom[name])
81
82  if (typeof skin === 'string') {
83    return fail(state, skin)
84  }
85
86  const custom = { ...state.custom, [skin.name]: skin }
87  const prefs = input.apply === false ? state.prefs : { ...state.prefs, skin: skin.name }
88
89  return ok({ prefs, custom }, `saved ${skin.name}${input.apply === false ? '' : ' and applied it'}`)
90}
91
92function settings(input: Input, state: DesignState): DesignOutcome {
93  const asked = (typeof input.settings === 'object' && input.settings !== null ? input.settings : {}) as Input
94  const flag = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
95  const icons = asked.icons === 'ascii' || asked.icons === 'unicode' ? asked.icons : state.prefs.icons
96  const prefs: Prefs = {
97    ...state.prefs,
98    rail: flag(asked.rail, state.prefs.rail),
99    tables: flag(asked.tables, state.prefs.tables),
100    shimmer: flag(asked.shimmer, state.prefs.shimmer),
101    band: flag(asked.band, state.prefs.band),
102    clipOutput: flag(asked.clip, state.prefs.clipOutput),
103    icons,
104  }
105
106  return ok({ ...state, prefs }, `settings: ${JSON.stringify(prefs)}`)
107}
108
109export function runDesign(raw: unknown, state: DesignState): DesignOutcome {
110  const input = (typeof raw === 'object' && raw !== null ? raw : {}) as Input
111  const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
112
113  switch (input.action) {
114    case 'show':
115      return show(state)
116    case 'save':
117      return save(input, state)
118    case 'apply':
119      return skinNames(state.custom).includes(name)
120        ? ok({ ...state, prefs: { ...state.prefs, skin: name } }, `applied ${name}`)
121        : fail(state, `no skin named "${name}"; show lists them`)
122    case 'delete': {
123      if (state.custom[name] === undefined) {
124        return fail(state, `"${name}" is not a made skin; built-in skins cannot be deleted`)
125      }
126
127      const custom = Object.fromEntries(Object.entries(state.custom).filter(([key]) => key !== name))
128      const prefs = state.prefs.skin === name ? { ...state.prefs, skin: DEFAULT_PREFS.skin } : state.prefs
129
130      return ok({ prefs, custom }, `deleted ${name}`)
131    }
132    case 'settings':
133      return settings(input, state)
134    default:
135      return fail(state, 'action must be show, save, apply, delete or settings')
136  }
137}
138
hooks/hub.ts 23 lines
1import type { HubCard } from '../types'
2import { say } from './words'
3
4// The MODZ main panel's view of this mod (DESIGN.md §9): a one-line card under `hub` naming
5// the skin in use, and the open request the panel writes to `modz.open` when its row is pressed.
6
7export const HUB_PANE = 'skins'
8const DETAIL_MAX = 40
9const OFF = 'off'
10
11export const EMPTY_HUB: HubCard = { pane: HUB_PANE, detail: '', tone: 'idle', at: 0 }
12
13// "tidemark · dark": the skin and the background it is drawn for.
14export function hubDetail(skin: string, isLight: boolean): string {
15  const detail = skin === OFF ? say().skinOff : `${skin} · ${isLight ? say().light : say().dark}`
16
17  return detail.length > DETAIL_MAX ? `${detail.slice(0, DETAIL_MAX - 1)}…` : detail
18}
19
20// Whether a `modz.open` write asks for this mod's pane.
21export const isOwnOpen = (value: unknown): boolean =>
22  typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === HUB_PANE
23
hooks/look.ts 55 lines
1import type { ElementTable, RenderSurface } from 'claude-code'
2
3import type { CustomSkin, Prefs } from '../types'
4import { ICONS } from './skin'
5import type { Icons, Skin } from './skin'
6
7export type Ui = Pick<ElementTable, 'Box' | 'Text' | 'Markdown' | 'Button'>
8
9// The vector element, on the surfaces that have one (the desktop app).
10export type SvgElement = ElementTable<'desktop'>['Svg']
11
12export type Look = {
13  ui: Ui
14  skin: Skin
15  icons: Icons
16  prefs: Prefs
17  surface: RenderSurface
18  // The vector element where the surface draws one: icons replace glyphs there.
19  svg?: SvgElement
20  // Puts text on the clipboard of the surface drawing; absent where nothing can copy.
21  copy?: (text: string) => void
22}
23
24export type Call = {
25  tool: string
26  input: unknown
27  isRunning: boolean
28  isErrored: boolean
29  isInterrupted: boolean
30}
31
32// What a tool row shows at its right edge.
33export type Meta = { ms?: number; added?: number; removed?: number }
34
35export type Active = { prefs: Prefs; skin: Skin; custom: Record<string, CustomSkin> }
36
37export type LookSource = {
38  ui: Ui & { Svg?: SvgElement }
39  active: Active
40  surface: RenderSurface
41  copy?: (text: string) => void
42}
43
44// Every surface's element table names Svg, but the terminal draws it as nothing, so
45// vector icons are for the other surfaces only.
46export const lookOf = ({ ui, active, surface, copy }: LookSource): Look => ({
47  ui,
48  skin: active.skin,
49  icons: ICONS[active.prefs.icons],
50  prefs: active.prefs,
51  surface,
52  ...(surface !== 'terminal' && ui.Svg !== undefined ? { svg: ui.Svg } : {}),
53  ...(copy === undefined ? {} : { copy }),
54})
55
hooks/moving-look.tsx 39 lines
1import type { PaneMotion } from './kit/moving'
2import type { Look, SvgElement } from './look'
3
4// The settings and gallery panes draw with the very row and card builders the transcript uses; only the panes
5// move (kit/moving.ts), so each pane's draw hands those builders a look whose Svg draws interactive frames.
6
7// The box the markup gives itself: an interactive Svg is a frame, and a frame needs a box.
8const SIZE = (name: 'width' | 'height') => new RegExp(`^<svg\\b[^>]*?\\s${name}="([\\d.]+)"`)
9const sizeOf = (source: string, name: 'width' | 'height'): number | undefined => {
10  const found = SIZE(name).exec(source)
11
12  return found === null ? undefined : Number(found[1])
13}
14
15// A drawing is known by what it says and its place among those that say the same: the pane's samples keep their
16// order, so the third `read` icon is the third on every draw.
17function movingSvg(Svg: SvgElement, motion: PaneMotion): SvgElement {
18  const seen = new Map<string, number>()
19
20  return props => {
21    const nth = seen.get(props.alt) ?? 0
22    seen.set(props.alt, nth + 1)
23    const key = `${props.alt}#${nth}`
24    const boxed = { ...props, width: props.width ?? sizeOf(props.source, 'width'), height: props.height ?? sizeOf(props.source, 'height') }
25
26    return <Svg key={key} {...motion.pic(key, boxed)} />
27  }
28}
29
30// `look` for one draw of a pane: its Svg, where the surface has one, moving with the pane's motion.
31export const movingLook = (look: Look, motion: PaneMotion): Look => (look.svg === undefined ? look : { ...look, svg: movingSvg(look.svg, motion) })
32
33// Opens a pane anew: its drawings cascade in again.
34export function opening<T>(motion: PaneMotion, open: () => T): T {
35  motion.reset()
36
37  return open()
38}
39
hooks/prefs-store.ts 136 lines
1import type { CustomSkin, HubCard, Prefs } from '../types'
2import { parsePrefs } from './command'
3import { buildCustom, resolveSkin, skinNames } from './custom'
4import type { DesignState } from './designer'
5import { parseFolders, prefsFor, withFolder, withoutFolder } from './folders'
6import { HUB_PANE, hubDetail } from './hub'
7import { forTheme } from './light'
8import type { Active } from './look'
9import { say } from './words'
10import { BAND_OFF_KEY, bandOffWrites } from './migrate'
11
12// The engine's reach, bound in register.tsx: the engine follows `$` only into functions of
13// the hooks module's own file, so the prefs' load and save get this port instead.
14export type PrefsHost = {
15  storeGet(key: string): Promise<unknown>
16  storeSet(key: string, value: unknown): Promise<void>
17  cwd(): Promise<string>
18  now(): Promise<number>
19  prefs(): Promise<Prefs>
20  setPrefs(prefs: Prefs): Promise<unknown>
21  custom(): Promise<Record<string, CustomSkin>>
22  setCustom(custom: Record<string, CustomSkin>): Promise<unknown>
23  isPinned(): Promise<boolean>
24  setPinned(isPinned: boolean): Promise<unknown>
25  isLight(): Promise<boolean>
26  hub(): Promise<HubCard>
27  setHub(card: HubCard): Promise<unknown>
28}
29
30// The skin the prefs pick, drawn for the background in use; null while skins are off.
31export async function activeSkin(host: PrefsHost): Promise<Active | null> {
32  const prefs = await host.prefs()
33  const custom = await host.custom()
34  const skin = resolveSkin(prefs.skin, custom)
35
36  return skin === undefined ? null : { prefs, skin: forTheme(skin, await host.isLight()), custom }
37}
38
39// Made skins from the store, each checked again: the store may hold an older shape.
40function parseCustom(raw: unknown): Record<string, CustomSkin> {
41  const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
42
43  return Object.fromEntries(
44    Object.values(saved)
45      .map(draft => buildCustom(draft as Record<string, unknown>, undefined))
46      .filter((skin): skin is CustomSkin => typeof skin !== 'string')
47      .map(skin => [skin.name, skin]),
48  )
49}
50
51// Once: older versions stored the band on only because it was the default.
52async function migrateBandOff(host: PrefsHost): Promise<void> {
53  const writes = bandOffWrites(await host.storeGet(BAND_OFF_KEY), await host.storeGet('prefs'), await host.storeGet('folders'))
54
55  for (const [key, value] of writes) {
56    await host.storeSet(key, value)
57  }
58}
59
60export async function load(host: PrefsHost): Promise<void> {
61  await migrateBandOff(host)
62  const custom = parseCustom(await host.storeGet('custom'))
63  const names = skinNames(custom)
64  const folders = parseFolders(await host.storeGet('folders'), names)
65  const folder = await host.cwd()
66  const prefs = prefsFor(folder, folders, parsePrefs(await host.storeGet('prefs'), names))
67
68  await host.setCustom(custom)
69  await host.setPrefs(prefs)
70  await host.setPinned(Object.hasOwn(folders, folder))
71}
72
73// A pinned folder keeps its prefs to itself; every other folder shares the default.
74async function savePrefs(host: PrefsHost, prefs: Prefs): Promise<void> {
75  if (!(await host.isPinned())) {
76    await host.storeSet('prefs', prefs)
77
78    return
79  }
80
81  const names = skinNames(await host.custom())
82  const folders = parseFolders(await host.storeGet('folders'), names)
83  await host.storeSet('folders', withFolder(folders, await host.cwd(), prefs))
84}
85
86export async function commit(host: PrefsHost, state: DesignState): Promise<void> {
87  await host.setCustom(state.custom)
88  await host.setPrefs(state.prefs)
89  await host.storeSet('custom', state.custom)
90  await savePrefs(host, state.prefs)
91  await syncHub(host)
92}
93
94// The MODZ card, written whenever the skin or the theme may have changed; a same text is not
95// written again.
96export async function syncHub(host: PrefsHost): Promise<void> {
97  const detail = hubDetail((await host.prefs()).skin, await host.isLight())
98  if ((await host.hub()).detail === detail) return
99  const at = await host.now()
100  await host.setHub({ pane: HUB_PANE, detail, tone: 'idle', at })
101}
102
103const FOLDER_WORDS = new Set(['pin', 'unpin', 'share'])
104
105export const isFolderWord = (word: string): boolean => FOLDER_WORDS.has(word)
106
107// /skin pin, unpin and share: this folder's own look, or the default.
108export async function runFolderCommand(host: PrefsHost, word: string): Promise<string> {
109  const folder = await host.cwd()
110  const custom = await host.custom()
111  const names = skinNames(custom)
112  const folders = parseFolders(await host.storeGet('folders'), names)
113  const prefs = await host.prefs()
114
115  switch (word) {
116    case 'pin':
117      await host.storeSet('folders', withFolder(folders, folder, prefs))
118      await host.setPinned(true)
119
120      return say().pin
121    case 'unpin': {
122      const fallback = parsePrefs(await host.storeGet('prefs'), names)
123      await host.storeSet('folders', withoutFolder(folders, folder))
124      await host.setPinned(false)
125      await host.setPrefs(fallback)
126      await syncHub(host)
127
128      return say().unpin(fallback.skin)
129    }
130    default:
131      await host.storeSet('prefs', prefs)
132
133      return say().share(prefs.skin)
134  }
135}
136
hooks/render-bands.tsx 56 lines
1import type { RenderElement } from 'claude-code'
2
3import { headersOf } from './messages'
4import { usageBand } from './row-usage'
5import { askBand } from './rows'
6import { pinned } from './pin'
7import { metersOf } from './svg-usage'
8import type { Drawing } from './view'
9
10// The band above the prompt: context and plan limits. Another mod's drawing there,
11// and a survey, keep their place.
12export async function abovePromptView({ view, e, pass }: Drawing<'AbovePrompt'>): Promise<RenderElement> {
13  const active = await view.active()
14  const meters = metersOf(await view.usage())
15
16  if (active === null || !active.prefs.band || e.props.hasSurvey || meters.length === 0) {
17    return pass(e)
18  }
19
20  const look = view.look(active)
21  const { Box } = look.ui
22  const theirs = await pass(e)
23  // Compacting mid-turn would cut the turn's own context out from under it.
24  const isCompacting = await view.isCompacting()
25
26  return pinned(e, (
27    <Box flexDirection="column">
28      {usageBand(look, { meters, canCompact: !e.props.isWorking && !isCompacting, compact: view.compact })}
29      {theirs}
30    </Box>
31  ))
32}
33
34// Claude Code's own dialog stays whole: the skin only adds a band above it.
35export async function askView({ view, e, pass }: Drawing<'AskUserQuestion'>): Promise<RenderElement> {
36  const active = await view.active()
37  const theirs = await pass(e)
38
39  if (active === null) {
40    return theirs
41  }
42
43  const headers = headersOf(e.props.questions)
44  const look = view.look(active)
45  const { Box } = look.ui
46
47  return headers.length === 0 ? (
48    theirs
49  ) : (
50    <Box flexDirection="column">
51      {askBand(look, headers)}
52      {theirs}
53    </Box>
54  )
55}
56
hooks/render-messages.tsx 85 lines
1import type { RenderElement } from 'claude-code'
2
3import { replyRows } from './cards'
4import { pick } from './format'
5import { cardSegments, isOwnPrompt } from './messages'
6import { desktopSpinnerRow, footerRow, promptRow, spinnerRow } from './rows'
7import { pinned } from './pin'
8import { DEFAULT_COLUMNS } from './view'
9import type { Drawing } from './view'
10
11// How often the spinner steps while a turn works.
12export const FRAME_MS = 90
13
14export async function userMessageView({ view, e, pass }: Drawing<'UserMessage'>): Promise<RenderElement> {
15  const active = await view.active()
16
17  if (active === null || !isOwnPrompt(e.props)) {
18    return pass(e)
19  }
20
21  // Outside the terminal the outline's text cannot be selected, so it carries a Copy button.
22  const look = view.look(active, e.surface !== 'terminal')
23  const hasImages = await view.hasImages()
24
25  // Claude Code draws the images; the text is already in the outline above them.
26  return promptRow(look, e.props.text, hasImages ? await pass({ ...e, props: { ...e.props, text: '' } }) : undefined)
27}
28
29// A reply keeps Claude Code's own drawing unless it holds a table to draw.
30export async function replyView({ view, e, pass }: Drawing<'AssistantMessage'>): Promise<RenderElement> {
31  const active = await view.active()
32  const segments = active?.prefs.tables === true ? cardSegments(e.props.text) : null
33
34  if (active === null || segments === null) {
35    return pass(e)
36  }
37
38  view.drewAt(e.viewport?.columns)
39  // Every surface's table names Svg, but the terminal draws it as nothing.
40  const look = view.look(active, true)
41
42  return pinned(e, replyRows(look, { segments, maxWidth: e.viewport?.columns ?? DEFAULT_COLUMNS, Svg: look.svg }))
43}
44
45// The terminal's spinner gets the skin's word with a shimmer; the desktop's keeps its
46// word, which says what the step is doing, beside an animated icon.
47export async function spinnerView({ view, e, pass }: Drawing<'Spinner'>): Promise<RenderElement> {
48  const active = await view.active()
49
50  if (active === null) {
51    return pass(e)
52  }
53
54  if (e.surface !== 'terminal') {
55    const look = view.look(active)
56
57    return look.svg === undefined ? pass(e) : desktopSpinnerRow(look, { Svg: look.svg, mode: e.props.mode, text: e.props.message ?? e.props.word })
58  }
59
60  const word = pick(active.skin.spinner, e.props.word) ?? e.props.word
61
62  if (!active.prefs.shimmer || e.props.message !== null) {
63    return pass({ ...e, props: { ...e.props, word } })
64  }
65
66  const frame = await view.frame()
67  const startedAt = await view.startedAt()
68  const elapsedMs = startedAt === 0 ? 0 : (await view.now()) - startedAt
69
70  return spinnerRow(view.look(active), { word, frame, elapsedMs })
71}
72
73export async function turnDurationView({ view, e, pass }: Drawing<'TurnDuration'>): Promise<RenderElement> {
74  const active = await view.active()
75
76  if (active === null) {
77    return pass(e)
78  }
79
80  const turns = await view.turns()
81  const word = pick(active.skin.done, e.props.word) ?? e.props.word
82
83  return footerRow(view.look(active), { word, durationMs: e.props.durationMs, stats: turns[String(e.props.durationMs)] })
84}
85
hooks/render-panes.tsx 72 lines
1import type { Elements, Frozen, RenderElement, RenderInput, RenderSurface } from 'claude-code'
2
3import type { SkinSlot } from '../types'
4import { DEFAULT_PREFS } from './command'
5import { resolveSkin, skinNames } from './custom'
6import { galleryPane } from './gallery'
7import { paneMotion } from './kit/moving'
8import { forTheme } from './light'
9import { lookOf } from './look'
10import { movingLook } from './moving-look'
11import type { PrefsHost } from './prefs-store'
12import { settingsPane, swatchesOf } from './settings'
13import { settingsActions } from './settings-actions'
14import type { SettingsPorts } from './settings-actions'
15import { pinned } from './pin'
16import type { Drawing } from './view'
17import { say } from './words'
18
19// One motion per pane (kit/moving.ts), reset as it opens, so its drawings cascade in each time.
20export const settingsMotion = paneMotion()
21export const galleryMotion = paneMotion()
22
23// The settings pane's tab title, wherever it is opened from.
24export const settingsTitle = (): string => say().settingsTitle
25
26// Every element the skin draws, numbered, to point at when asking for a change.
27export async function galleryView({ view, e }: Omit<Drawing<'Pane'>, 'pass'>): Promise<RenderElement> {
28  const active = await view.active()
29
30  if (active === null) {
31    const { Text } = view.ui()
32
33    return <Text>{say().galleryEmpty}</Text>
34  }
35
36  const look = movingLook(view.look(active), galleryMotion)
37
38  return pinned(e, galleryPane(look, e.props.bodyColumns))
39}
40
41// The settings pane's reach, bound in register.tsx.
42export type SettingsHost = PrefsHost & {
43  ui(): Elements[RenderSurface]
44  editing(): Promise<SkinSlot>
45  isHubOn(): Promise<boolean>
46  ports: SettingsPorts
47}
48
49export async function settingsView(host: SettingsHost, e: Frozen<RenderInput<'Pane'>>): Promise<RenderElement> {
50  const prefs = await host.prefs()
51  const custom = await host.custom()
52  const editing = await host.editing()
53  // Drawn for the background the transcript is drawn for, as every other row is.
54  const base = resolveSkin(prefs.skin, custom) ?? resolveSkin(DEFAULT_PREFS.skin, custom)
55  const skin = base === undefined ? undefined : forTheme(base, await host.isLight())
56  const ui = host.ui()
57
58  if (skin === undefined || e.surface === 'mobile' || !('Input' in ui)) {
59    return <ui.Text>{say().settingsElsewhere}</ui.Text>
60  }
61
62  const look = movingLook(lookOf({ ui, active: { prefs, custom, skin }, surface: e.surface }), settingsMotion)
63  const isLight = await host.isLight()
64  const names = skinNames(custom)
65  const skins = names.flatMap(name => resolveSkin(name, custom) ?? []).map(one => forTheme(one, isLight))
66  const model = { names, editing, width: e.props.bodyColumns, swatches: swatchesOf(skins) }
67
68  const home = (await host.isHubOn()) ? host.ports.home : undefined
69
70  return pinned(e, settingsPane({ look, ui, model, home, actions: settingsActions({ prefs, custom, editing }, host.ports) }))
71}
72
hooks/render-tools.tsx 51 lines
1import type { RenderElement } from 'claude-code'
2
3import { groupRow, toolRow } from './row-tool'
4import { pinned } from './pin'
5import { clippedOutput, metaOf, resultCard } from './tool-result'
6import { kindOf, summarize } from './tools'
7import { DEFAULT_COLUMNS } from './view'
8import type { Drawing } from './view'
9
10export async function toolUseView({ view, e, pass }: Drawing<'ToolUse'>): Promise<RenderElement> {
11  const kind = kindOf(e.props.tool)
12  const active = await view.active()
13
14  if (kind === null || active === null) {
15    return pass(e)
16  }
17
18  const ms = await view.duration()
19  const target = summarize(e.props.tool, e.props.input, await view.cwd())
20
21  return toolRow(view.look(active), { call: e.props, kind, target, meta: metaOf(e.props, ms) })
22}
23
24export async function toolGroupView({ view, e, pass }: Drawing<'ToolGroup'>): Promise<RenderElement> {
25  const active = await view.active()
26
27  if (active === null || e.props.isExpanded) {
28    return pass(e)
29  }
30
31  return groupRow(view.look(active), e.props.calls)
32}
33
34export async function toolResultView({ view, e, pass }: Drawing<'ToolResult'>): Promise<RenderElement> {
35  const active = await view.active()
36  const look = active === null ? undefined : view.look(active, true)
37
38  // The desktop gets cards: a diff for an edit, a terminal for a shell command.
39  if (look?.svg !== undefined) {
40    const card = await resultCard(look, look.svg, { props: e.props, columns: e.viewport?.columns ?? DEFAULT_COLUMNS, cwd: view.cwd })
41
42    if (card !== null) {
43      return pinned(e, card)
44    }
45  }
46
47  const output = clippedOutput(e.props, active?.prefs.clipOutput === true)
48
49  return output === undefined ? pass(e) : pass({ ...e, props: { ...e.props, output } })
50}
51
hooks/skin-command.ts 60 lines
1import type { CommandSpec } from 'claude-code'
2
3import { rowReply, runSkinCommand } from './command'
4import { skinNames } from './custom'
5import { commit, isFolderWord, runFolderCommand } from './prefs-store'
6import type { PrefsHost } from './prefs-store'
7import { say } from './words'
8
9// What /skin registers as: the settings alone, or a skin, a folder word or a switch by name.
10export const skinCommand = (): CommandSpec => ({
11  name: 'skin',
12  description: say().command,
13  argumentHint: '[gallery | name | list | off | pin | unpin | share | rail | tables | shimmer | band | clip | icons]',
14  immediate: true,
15})
16
17// The /skin command's reach, bound in register.tsx: the prefs store, the panes it opens,
18// toasts, and the width the last reply was drawn at.
19export type SkinCommandHost = PrefsHost & {
20  openSettings(): Promise<void>
21  openGallery(): Promise<unknown>
22  toast(text: string): void
23  columns(): number | undefined
24}
25
26// /skin alone opens the settings, `gallery` the gallery; a folder word pins or shares.
27async function runSkinWord(host: SkinCommandHost, word: string): Promise<boolean> {
28  if (word === '') {
29    await host.openSettings()
30  } else if (word === 'gallery') {
31    await host.openGallery()
32  } else if (isFolderWord(word)) {
33    host.toast(await runFolderCommand(host, word))
34  }
35
36  return word === '' || word === 'gallery' || isFolderWord(word)
37}
38
39export async function runSkin(host: SkinCommandHost, args: string): Promise<{ text?: string }> {
40  if (await runSkinWord(host, args.trim().toLowerCase())) {
41    return {}
42  }
43
44  const current = await host.prefs()
45  const custom = await host.custom()
46  const outcome = runSkinCommand(args, current, skinNames(custom))
47
48  if (outcome.prefs !== current) {
49    await commit(host, { prefs: outcome.prefs, custom })
50  }
51
52  if (outcome.channel === 'row') {
53    return { text: rowReply(outcome.message, { args, isPinned: await host.isPinned(), columns: host.columns() }) }
54  }
55
56  host.toast(outcome.message)
57
58  return {}
59}
60