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

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.
/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.
| Site | Desktop app | Terminal |
|---|---|---|
| Tool calls | A line icon per kind, a spinning ring while it runs, lines changed and time taken | A node on the turn's rail with the same facts |
| Edits | A diff card: file, +N -M, numbered changed lines in green and red | Claude Code's own diff |
| Shell commands | A terminal card: status pill, output with stderr apart, long output folded, and a Copy button | Claude Code's own output |
| Tables in replies | An animated card with zebra rows and swatches for colours; Copy gives the markdown | A cell grid with a header band and zebra rows, and the same Copy |
| Code blocks in replies | A card with the language, line numbers, highlighting and a Copy button | Claude Code's own markdown, and a Copy button |
| Spinner | An animated icon per phase: thinking, tool use, writing, waiting | The skin's word with a band of light through it |
| Above the prompt (off by default) | Rings for context and each plan limit, a Compact button, and a nudge to compact from 70% context | Block meters and the same button |
| Turn footer | not drawn on the desktop app | Time, tool count and lines changed |
| Your prompts | A rounded outline sized to what you typed | The same |
| The question dialog | A band naming its topics above Claude Code's own dialog | The same |
Every card rises in row by row and respects reduced motion. A skin only changes what is drawn: the stored conversation, and what the model reads, are untouched. Agents, plan mode and the permission prompt keep Claude Code's own drawing.
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.
/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.
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.
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.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
Adapted from hellosverre/claude-skins, licensed MIT. The license file is in this folder.
hooks/register.tsx 300 lines1import { 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}
300hooks/command.ts 126 lines1import 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}
126hooks/designer.ts 138 lines1import type { CustomSkin, Prefs } from '../types'
2import { DEFAULT_PREFS } from './command'
3import { buildCustom, resolveSkin, SLOT_HELP, skinNames } from './custom'
4import { SKINS } from './themes'
5
6// The tool the person's own agent calls to design a skin with them.
7export const DESIGN_TOOL = {
8 name: 'design',
9 description: [
10 'Design, change and apply skins for the skins mod, which restyles this Claude Code transcript:',
11 'tool rows on a timeline rail, tables, the spinner, the turn footer. Changes show at once.',
12 'Call {"action":"show"} first: it returns the current skin, every slot with what it paints, and the names in use.',
13 '"save" makes or updates a skin: name (lowercase, hyphens), base (a built-in skin it starts from),',
14 'palette ({slot: "#rrggbb"}, only the slots to change), spinner (gerunds, like "Brewing"),',
15 'done (past tense, like "Brewed"), apply (default true).',
16 '"apply" switches to a skin by name, "delete" removes a made one,',
17 '"settings" switches rail, tables, shimmer, band, clip (booleans) and icons ("unicode" or "ascii").',
18 'Good skins keep fg and muted readable on a dark background, and surface and zebra one small step off it.',
19 ].join(' '),
20 inputSchema: {
21 type: 'object',
22 properties: {
23 action: { type: 'string', enum: ['show', 'save', 'apply', 'delete', 'settings'] },
24 name: { type: 'string' },
25 label: { type: 'string' },
26 base: { type: 'string', enum: SKINS.map(skin => skin.name) },
27 palette: { type: 'object', additionalProperties: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' } },
28 spinner: { type: 'array', items: { type: 'string' }, maxItems: 12 },
29 done: { type: 'array', items: { type: 'string' }, maxItems: 12 },
30 apply: { type: 'boolean' },
31 settings: {
32 type: 'object',
33 properties: {
34 rail: { type: 'boolean' },
35 tables: { type: 'boolean' },
36 shimmer: { type: 'boolean' },
37 band: { type: 'boolean' },
38 clip: { type: 'boolean' },
39 icons: { type: 'string', enum: ['unicode', 'ascii'] },
40 },
41 },
42 },
43 required: ['action'],
44 },
45} as const
46
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}
138hooks/hub.ts 23 lines1import 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
23hooks/look.ts 55 lines1import 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})
55hooks/moving-look.tsx 39 lines1import 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}
39hooks/prefs-store.ts 136 lines1import 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}
136hooks/render-bands.tsx 56 lines1import 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}
56hooks/render-messages.tsx 85 lines1import 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}
85hooks/render-panes.tsx 72 lines1import 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}
72hooks/render-tools.tsx 51 lines1import 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}
51hooks/skin-command.ts 60 lines1import 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