hellosverre/claude-skins의 k-mods 수정판. 한글 표 너비, 터미널 셸 출력 카드 등 한국어 환경에서 보이던 문제를 고쳤습니다. /skin으로 테마를 바꿉니다.

Claude Code 화면을 테마로 꾸며줘요. 도구 줄에 레일을 긋고, 표에 테두리를 두르고, 스피너와 턴 종료 줄에 스킨마다 다른 단어와 색을 입혀요.
(스크린샷 대신: 설치 뒤 /skin gallery를 열면 지금 스킨이 그리는 요소를 전부 한 번에 볼 수 있어요)
/plugin marketplace add SeongGwangJu/k-mods
/plugin install skins@k-mods
/skin. 스킨 설정 패널을 열어요. 목록에서 고르거나 색을 직접 칠할 수 있어요./skin <이름>. 바로 그 스킨으로 바꿔요 (nord, dracula, gruvbox, catppuccin, tokyo-night, noir, mono)./skin off 또는 /skin default. Claude Code 기본 화면으로 돌아가요./skin list. 쓸 수 있는 스킨과 현재 켜진 옵션을 보여줘요./skin rail|tables|shimmer|band|clip on|off. 부분 기능만 켜고 꺼요 (아래 설정 참고)./skin gallery. 지금 스킨이 그리는 요소를 전부 번호 붙여 보여줘요./skin icons unicode|ascii. 글꼴에 유니코드 기호가 없으면 ascii로 바꿔요./config 또는 /plugin configure skins@k-mods)| 설정 | 기본값 | 설명 |
|---|---|---|
status_lines | auto | 스피너(작업 중 줄)와 턴 종료 줄을 누가 그릴지. auto는 k-mods의 status-ko가 켜져 있으면 그쪽에 맡기고, 아니면 이 mod가 그려요. skins는 항상 이 mod가, engine은 항상 Claude Code 기본(또는 다른 mod)이 그리게 해요. |
/skin 명령으로 바꾸는 나머지 설정(스킨 선택, 레일, 표, 반짝임, 상단 띠, 출력 자르기)은 세션 안에 저장되는 값이라 /config가 아니라 /skin 또는 설정 패널에서 바꿔요.
Claude Code가 화면 각 부분(도구 줄, 표, 스피너, 턴 종료 줄, 내 메시지 등)을 그릴 때마다 이 mod에 먼저 물어봐요. 이 mod는 고른 스킨의 색과 단어로 그 부분을 대신 그려서 돌려주고, 끄면 원래 Claude Code가 그리던 그대로 돌아가요. 네트워크도, 모델 호출도 쓰지 않고, 화면만 바꿔요.
$.store)에만 남아요./skin gallery는 아직 새 터미널 셸 출력 카드를 미리 보여주지 않아요(기존 갤러리 구성을 그대로 뒀어요). 실제 Bash 명령을 실행하면 바로 보여요.status_lines는 $.settings.read()로 매 세션 시작 때 한 번만 status-ko 설치 여부를 확인해요. 세션 도중에 status-ko를 새로 설치·활성화하면 다음 세션부터 반영돼요.hellosverre/claude-skins (MIT, 커밋 2ee5b6a3e7e765a2aa107631a9d07105753e8bd5)의 k-mods 수정판입니다. 원작자 hellosverre에게 감사드립니다. 무엇을 고쳤는지는 CHANGES-KO.md에 전부 적어 뒀어요. 원본 라이선스 전문은 LICENSE에 그대로 있어요.
테스트한 Claude Code 버전: 2.1.291
hooks/register.tsx 612 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3
4import type { CustomSkin, Prefs, SkinSlot, TurnStats, UsageSnap } from '../types'
5import { DEFAULT_PREFS, parsePrefs, runSkinCommand, statusLinesMode, TOGGLES } from './command'
6import { buildCustom, resolveSkin, skinNames, withSlot } from './custom'
7import { forTheme, isLightTheme } from './light'
8import { DESIGN_TOOL, runDesign } from './designer'
9import type { DesignState } from './designer'
10import { clipLines, diffstat, pick } from './format'
11import { splitReply } from './markdown'
12import { askBand, desktopSpinnerRow, diffCard, footerRow, terminalCard, usageBand, groupRow, promptRow, replyRows, shellCard, spinnerRow, toolRow } from './rows'
13import type { Look, SvgElement, Ui } from './rows'
14import { galleryPane } from './gallery'
15import { settingsPane } from './settings'
16import { ICONS } from './skin'
17import type { Skin } from './skin'
18import { hunksOf } from './svg-diff'
19import { shellOutputOf } from './svg-terminal'
20import { limitLabel, metersOf } from './svg-usage'
21import { shortenPath } from './format'
22import { kindOf, summarize } from './tools'
23
24const SETTINGS = 'skins-settings'
25const GALLERY = 'skins-gallery'
26const DESIGN = `mcp__skins__${DESIGN_TOOL.name}`
27const DESIGN_MATCH = /^mcp__skins__design$/
28
29// Markdown takes at most 10000 characters a block; a longer prompt keeps Claude Code's
30// own drawing, which folds a big paste.
31const MAX_MARKDOWN = 9000
32const MAX_PROMPT = 4000
33
34const CLIP_HEAD = 8
35const CLIP_TAIL = 4
36const FRAME_MS = 90
37const KEPT_TURNS = 40
38
39const prefsAtom = atom({ plugin: 'skins', key: 'prefs' } as const, DEFAULT_PREFS)
40const customAtom = atom({ plugin: 'skins', key: 'custom' } as const, {})
41const startedAtom = atom({ plugin: 'skins', key: 'startedAt' } as const, 0)
42const frameAtom = atom({ plugin: 'skins', key: 'frame' } as const, 0)
43const turnsAtom = atom({ plugin: 'skins', key: 'turns' } as const, {})
44const durationAtom = atom({ plugin: 'skins', key: 'duration' } as const, -1)
45const editingAtom = atom({ plugin: 'skins', key: 'editing' } as const, 'user' as SkinSlot)
46const lightAtom = atom({ plugin: 'skins', key: 'isLight' } as const, false)
47const imagesAtom = atom({ plugin: 'skins', key: 'images' } as const, false)
48const usageAtom = atom({ plugin: 'skins', key: 'usage' } as const, { context: null, limits: [] } as UsageSnap)
49const compactingAtom = atom({ plugin: 'skins', key: 'compacting' } as const, false)
50
51const EDITS = new Set(['Edit', 'MultiEdit', 'Write'])
52
53// Only a person's own typing becomes a prompt row: a task notification or a peer's
54// message is not theirs to dress as theirs.
55const TYPED = new Set(['composer', 'bridge', 'sdk'])
56
57type Active = { prefs: Prefs; skin: Skin; custom: Record<string, CustomSkin> }
58
59const NO_STATS: TurnStats = { tools: 0, added: 0, removed: 0 }
60
61async function activeSkin($: EngineInterface): Promise<Active | null> {
62 const prefs = await read($, prefsAtom)
63 const custom = await read($, customAtom)
64 const skin = resolveSkin(prefs.skin, custom)
65
66 return skin === undefined ? null : { prefs, skin: forTheme(skin, await read($, lightAtom)), custom }
67}
68
69// Claude Code's own theme decides whether skins draw for a light or a dark background.
70async function refreshTheme($: EngineInterface): Promise<void> {
71 const rows = await $.config.list()
72 const isLight = isLightTheme(rows.find(row => row.key === 'theme')?.value)
73
74 await update($, lightAtom, () => isLight)
75}
76
77// k-mods: true when a plugin named "status-ko" is enabled, under any marketplace. Read
78// once at session start (like the theme above) rather than on every redraw, since
79// `$.settings.read` reads settings.json and a Spinner redraws every animation frame.
80async function isStatusKoEnabled($: EngineInterface): Promise<boolean> {
81 const settings = await $.settings.read()
82 const enabledPlugins = settings.enabledPlugins
83
84 if (typeof enabledPlugins !== 'object' || enabledPlugins === null) {
85 return false
86 }
87
88 return Object.entries(enabledPlugins as Record<string, unknown>).some(
89 ([key, value]) => key.startsWith('status-ko@') && value === true,
90 )
91}
92
93// Every surface's element table names Svg, but the terminal draws it as nothing, so
94// vector icons are for the other surfaces only.
95const lookOf = (
96 ui: Ui & { Svg?: SvgElement },
97 active: Active,
98 surface: RenderSurface,
99 copy?: (text: string) => void,
100): Look => ({
101 ui,
102 skin: active.skin,
103 icons: ICONS[active.prefs.icons],
104 prefs: active.prefs,
105 surface,
106 ...(surface !== 'terminal' && ui.Svg !== undefined ? { svg: ui.Svg } : {}),
107 ...(copy === undefined ? {} : { copy }),
108})
109
110// Made skins from the store, each checked again: the store may hold an older shape.
111function parseCustom(raw: unknown): Record<string, CustomSkin> {
112 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
113
114 return Object.fromEntries(
115 Object.values(saved)
116 .map(draft => buildCustom(draft as Record<string, unknown>, undefined))
117 .filter((skin): skin is CustomSkin => typeof skin !== 'string')
118 .map(skin => [skin.name, skin]),
119 )
120}
121
122async function load($: EngineInterface): Promise<void> {
123 const custom = parseCustom(await $.store.get('custom'))
124 const prefs = parsePrefs(await $.store.get('prefs'), skinNames(custom))
125
126 await update($, customAtom, () => custom)
127 await update($, prefsAtom, () => prefs)
128}
129
130async function refreshUsage($: EngineInterface): Promise<void> {
131 const usage = await $.session.usage()
132 const snap: UsageSnap = {
133 context: usage.context.percent ?? null,
134 limits: usage.rateLimits.map(limit => ({ label: limitLabel(limit.kind), percent: limit.percentUsed })),
135 }
136
137 await update($, usageAtom, () => snap)
138}
139
140async function commit($: EngineInterface, state: DesignState): Promise<void> {
141 await update($, customAtom, () => state.custom)
142 await update($, prefsAtom, () => state.prefs)
143 await $.store.set('custom', state.custom)
144 await $.store.set('prefs', state.prefs)
145}
146
147async function designState($: EngineInterface): Promise<DesignState> {
148 return { prefs: await read($, prefsAtom), custom: await read($, customAtom) }
149}
150
151export const register: Register = (on, options) => {
152 // What the turn on the main loop has done so far, for its footer.
153 let stats: TurnStats = NO_STATS
154 let isWorking = false
155 let ticker: Timer | undefined
156 // The width the last reply was drawn at, shown by /skin list to tune table sizing.
157 let lastColumns: number | undefined
158 // k-mods: whether status-ko is enabled this session. See isStatusKoEnabled above.
159 let statusKoEnabled = false
160
161 on('session.start', async ($, e, next) => {
162 await $.command.register({
163 name: 'skin',
164 description: 'Open the skin settings, or /skin <name | list | off>',
165 argumentHint: '[gallery | name | list | off | rail | tables | shimmer | band | clip | icons]',
166 immediate: true,
167 })
168 await $.tool.register({
169 name: DESIGN_TOOL.name,
170 description: DESIGN_TOOL.description,
171 inputSchema: DESIGN_TOOL.inputSchema,
172 })
173 await load($)
174 await refreshUsage($)
175 await refreshTheme($)
176 statusKoEnabled = await isStatusKoEnabled($)
177
178 // Only the spinner reads the frame, so a tick redraws the spinner and nothing else.
179 ticker?.cancel()
180 ticker = $.clock.every(FRAME_MS, () => {
181 if (isWorking) {
182 void update($, frameAtom, frame => frame + 1)
183 }
184 })
185
186 return next(e)
187 })
188
189 // /clear, /resume and /branch reset $.state to its defaults and skip session.start.
190 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
191 await load($)
192
193 return next(e)
194 })
195
196 on('turn.start', async ($, e, next) => {
197 stats = NO_STATS
198 isWorking = true
199 const now = await $.clock.now()
200 await update($, startedAtom, () => now)
201
202 return next(e)
203 })
204
205 on('config.set', async ($, e, next) => {
206 const result = await next(e)
207
208 if (e.key === 'theme') {
209 await refreshTheme($)
210 }
211
212 return result
213 })
214
215 // Notes which prompts carried images, so their row keeps Claude Code's drawing of them.
216 on('session.append', async ($, e, next) => {
217 const stored = await next(e)
218 const hasImage = e.door === 'prompt' && e.message.content.some(block => block.type === 'image')
219
220 if (hasImage && stored.uuid !== undefined) {
221 await update($, memberOf(imagesAtom, { requestId: stored.uuid }), () => true)
222 }
223
224 return stored
225 })
226
227 // A turn's end and a plan limit's move are when the band's numbers change.
228 on('session.measure', async ($, e, next) => {
229 await refreshUsage($)
230
231 return next(e)
232 })
233
234 on('turn.complete', async ($, e, next) => {
235 if (e.agentId === undefined) {
236 await refreshUsage($)
237 isWorking = false
238 const finished = stats
239 await update($, turnsAtom, turns =>
240 Object.fromEntries([...Object.entries(turns), [String(e.durationMs), finished]].slice(-KEPT_TURNS)),
241 )
242 }
243
244 return next(e)
245 })
246
247 // Times every call and counts the main loop's calls and changed lines.
248 on('tool.call', async ($, e, next) => {
249 const startedAt = await $.clock.now()
250 const ran = await next(e)
251 const ms = (await $.clock.now()) - startedAt
252
253 await update($, memberOf(durationAtom, { requestId: e.tool_use_id }), () => ms)
254
255 if (e.agentId === undefined && e.tool !== DESIGN && ran.deny === undefined) {
256 const diff = diffstat(ran.result)
257 stats = {
258 tools: stats.tools + 1,
259 added: stats.added + (diff?.added ?? 0),
260 removed: stats.removed + (diff?.removed ?? 0),
261 }
262 }
263
264 return ran
265 })
266
267 on('tool.call', { tool: DESIGN_MATCH }, async ($, e) => {
268 const outcome = runDesign(e, await designState($))
269
270 if (outcome.isError) {
271 return { deny: outcome.text }
272 }
273
274 await commit($, outcome.state)
275
276 return { result: outcome.text }
277 })
278
279 on('command.run', { command: 'skin' }, async ($, e) => {
280 if (e.args.trim() === '') {
281 await $.ui.open({ id: SETTINGS, title: 'Skins', focus: true, closeOnEscape: true })
282
283 return {}
284 }
285
286 if (e.args.trim().toLowerCase() === 'gallery') {
287 await $.ui.open({ id: GALLERY, title: 'Skin gallery', focus: true, closeOnEscape: true })
288
289 return {}
290 }
291
292 const current = await read($, prefsAtom)
293 const custom = await read($, customAtom)
294 const outcome = runSkinCommand(e.args, current, skinNames(custom))
295
296 if (outcome.prefs !== current) {
297 await commit($, { prefs: outcome.prefs, custom })
298 }
299
300 if (outcome.channel === 'row') {
301 const width = lastColumns === undefined ? '' : `
302reply width: ${lastColumns} columns`
303
304 return { text: e.args.trim() === 'list' ? outcome.message + width : outcome.message }
305 }
306
307 $.ui.toast(outcome.message)
308
309 return {}
310 })
311
312 on('ui.render', { component: 'Pane', requestId: SETTINGS }, async ($, e) => {
313 const prefs = await read($, prefsAtom)
314 const custom = await read($, customAtom)
315 const editing = await read($, editingAtom)
316 const skin = resolveSkin(prefs.skin, custom) ?? resolveSkin(DEFAULT_PREFS.skin, custom)
317 const ui = $.ui.resolve(e)
318
319 if (skin === undefined || e.surface === 'mobile' || !('Input' in ui)) {
320 return <ui.Text>Open the skin settings in the terminal or the desktop app.</ui.Text>
321 }
322
323 const look = lookOf(ui, { prefs, custom, skin }, e.surface)
324 const state = { prefs, custom }
325
326 return settingsPane(look, ui, { names: skinNames(custom), editing, width: e.props.bodyColumns }, {
327 pick: name => void commit($, { ...state, prefs: { ...prefs, skin: name } }),
328 toggle: word => void commit($, { ...state, prefs: { ...prefs, [TOGGLES[word]]: !prefs[TOGGLES[word]] } }),
329 icons: () =>
330 void commit($, { ...state, prefs: { ...prefs, icons: prefs.icons === 'unicode' ? 'ascii' : 'unicode' } }),
331 edit: slot => void update($, editingAtom, () => slot),
332 paint: hex => {
333 const made = withSlot(prefs.skin === 'off' ? DEFAULT_PREFS.skin : prefs.skin, custom, editing, hex)
334
335 if (typeof made === 'string') {
336 $.ui.toast(made)
337
338 return
339 }
340
341 void commit($, { prefs: { ...prefs, skin: made.name }, custom: { ...custom, [made.name]: made.skin } })
342 },
343 })
344 })
345
346 // Every element the skin draws, numbered, to point at when asking for a change.
347 on('ui.render', { component: 'Pane', requestId: GALLERY }, async ($, e) => {
348 const active = await activeSkin($)
349 const ui = $.ui.resolve(e)
350
351 if (active === null) {
352 return <ui.Text>The gallery shows a skin. Pick one with /skin first.</ui.Text>
353 }
354
355 return galleryPane(lookOf(ui, active, e.surface), e.props.bodyColumns)
356 })
357
358 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
359 const kind = kindOf(e.props.tool)
360 const active = await activeSkin($)
361
362 if (kind === null || active === null) {
363 return next(e)
364 }
365
366 const ms = await read($, memberOf(durationAtom, e))
367 const diff = diffstat(e.props.output)
368 const target = summarize(e.props.tool, e.props.input, await $.session.cwd())
369 const look = lookOf($.ui.resolve(e), active, e.surface)
370 const row = toolRow(look, e.props, kind, target, {
371 ...(ms >= 0 && !e.props.isRunning ? { ms } : {}),
372 ...(diff === null ? {} : diff),
373 })
374
375 // k-mods: a finished Bash call's output, as a shell card under the row. The
376 // terminal has no separate site that shows it (see rows.tsx's shellCard), so it's
377 // drawn here, from the same props the engine gives ToolUse once isRunning is false.
378 if (e.surface === 'terminal' && e.props.tool === 'Bash' && !e.props.isRunning) {
379 const shell = shellOutputOf(e.props.output)
380
381 if (shell !== null) {
382 const card = shellCard(look, clipLines(shell.stdout, CLIP_HEAD, CLIP_TAIL), clipLines(shell.stderr, CLIP_HEAD, CLIP_TAIL), e.props.isErrored)
383
384 if (card !== '') {
385 const { Box } = look.ui
386
387 return (
388 <Box flexDirection="column">
389 {row}
390 {card}
391 </Box>
392 )
393 }
394 }
395 }
396
397 return row
398 })
399
400 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
401 const active = await activeSkin($)
402
403 if (active === null || e.props.isExpanded) {
404 return next(e)
405 }
406
407 return groupRow(lookOf($.ui.resolve(e), active, e.surface), e.props.calls)
408 })
409
410 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
411 const active = await activeSkin($)
412 const output = e.props.output as { stdout?: unknown } | null
413 const copy = (text: string) => {
414 void $.ui.copy({ text, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
415 }
416 const look = active === null ? undefined : lookOf($.ui.resolve(e), active, e.surface, copy)
417 const columns = e.viewport?.columns ?? 100
418
419 // The desktop gets cards: a diff for an edit, a terminal for a shell command.
420 if (look?.svg !== undefined && EDITS.has(e.props.tool) && !e.props.isErrored) {
421 const diff = hunksOf(e.props.output)
422
423 if (diff !== null) {
424 return diffCard(look, look.svg, diff, shortenPath(diff.path, await $.session.cwd()), columns)
425 }
426 }
427
428 if (look?.svg !== undefined && e.props.tool === 'Bash') {
429 const shell = shellOutputOf(e.props.output)
430
431 if (shell !== null) {
432 return terminalCard(look, look.svg, shell, e.props.isErrored, columns)
433 }
434 }
435
436 if (!active?.prefs.clipOutput || e.props.tool !== 'Bash' || typeof output?.stdout !== 'string') {
437 return next(e)
438 }
439
440 const stdout = clipLines(output.stdout, CLIP_HEAD, CLIP_TAIL)
441
442 return stdout === output.stdout
443 ? next(e)
444 : next({ ...e, props: { ...e.props, output: { ...output, stdout } } })
445 })
446
447 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
448 const active = await activeSkin($)
449
450 if (active === null || !TYPED.has(e.props.origin.kind) || e.props.text.length > MAX_PROMPT) {
451 return next(e)
452 }
453
454 const look = lookOf($.ui.resolve(e), active, e.surface)
455 const hasImages = await read($, memberOf(imagesAtom, e))
456
457 // Claude Code draws the images; the text is already in the outline above them.
458 return promptRow(look, e.props.text, hasImages ? await next({ ...e, props: { ...e.props, text: '' } }) : undefined)
459 })
460
461 // A reply keeps Claude Code's own drawing unless it holds a table to draw.
462 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
463 const active = await activeSkin($)
464
465 const text = e.props.text
466
467 if (active === null || !active.prefs.tables || !/\||```|~~~/.test(text)) {
468 return next(e)
469 }
470
471 const segments = splitReply(text)
472 const fits = segments.every(segment => segment.kind === 'table' || (segment.kind === 'text' ? segment.text : segment.raw).length <= MAX_MARKDOWN)
473
474 if (!fits || !segments.some(segment => segment.kind !== 'text')) {
475 return next(e)
476 }
477
478 // Every surface's table names Svg, but the terminal draws it as nothing.
479 const ui = $.ui.resolve(e)
480 lastColumns = e.viewport?.columns
481 const copy = (copied: string) => {
482 void $.ui.copy({ text: copied, surface: e.surface }).then(result => $.ui.toast(result.isCopied ? 'Copied' : 'Could not copy here'))
483 }
484
485 return replyRows(lookOf(ui, active, e.surface, copy), segments, e.viewport?.columns ?? 100, e.surface !== 'terminal' && 'Svg' in ui ? ui.Svg : undefined)
486 })
487
488 // The terminal's spinner gets the skin's word with a shimmer; the desktop's keeps its
489 // word, which says what the step is doing, beside an animated icon.
490 //
491 // k-mods: `status_lines` decides who draws this line. `engine` always leaves it to
492 // Claude Code (or whichever mod draws after skins); `auto` (the default) does too,
493 // but only once status-ko is enabled, so the two mods don't both draw a spinner word;
494 // `skins` always keeps the behaviour above, status-ko or not.
495 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
496 const mode = statusLinesMode(options.status_lines)
497
498 if (mode === 'engine' || (mode === 'auto' && statusKoEnabled)) {
499 return next(e)
500 }
501
502 const active = await activeSkin($)
503
504 if (active === null) {
505 return next(e)
506 }
507
508 if (e.surface !== 'terminal') {
509 const look = lookOf($.ui.resolve(e), active, e.surface)
510
511 return look.svg === undefined
512 ? next(e)
513 : desktopSpinnerRow(look, look.svg, e.props.mode, e.props.message ?? e.props.word)
514 }
515
516 const word = pick(active.skin.spinner, e.props.word) ?? e.props.word
517
518 if (!active.prefs.shimmer || e.props.message !== null) {
519 return next({ ...e, props: { ...e.props, word } })
520 }
521
522 const frame = await read($, frameAtom)
523 const startedAt = await read($, startedAtom)
524 const elapsed = startedAt === 0 ? 0 : (await $.clock.now()) - startedAt
525
526 return spinnerRow(lookOf($.ui.resolve(e), active, e.surface), word, frame, elapsed)
527 })
528
529 // k-mods: same status_lines decision as the Spinner above.
530 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
531 const mode = statusLinesMode(options.status_lines)
532
533 if (mode === 'engine' || (mode === 'auto' && statusKoEnabled)) {
534 return next(e)
535 }
536
537 const active = await activeSkin($)
538
539 if (active === null) {
540 return next(e)
541 }
542
543 const turns = await read($, turnsAtom)
544 const word = pick(active.skin.done, e.props.word) ?? e.props.word
545
546 return footerRow(lookOf($.ui.resolve(e), active, e.surface), word, e.props.durationMs, turns[String(e.props.durationMs)])
547 })
548
549 // The band above the prompt: context and plan limits. Another mod's drawing there,
550 // and a survey, keep their place.
551 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
552 const active = await activeSkin($)
553 const meters = metersOf(await read($, usageAtom))
554
555 if (active === null || !active.prefs.band || e.props.hasSurvey || meters.length === 0) {
556 return next(e)
557 }
558
559 const look = lookOf($.ui.resolve(e), active, e.surface)
560 const { Box } = look.ui
561 const theirs = await next(e)
562 // Compacting mid-turn would cut the turn's own context out from under it.
563 // Runs Claude Code's own /compact, so the person sees its usual progress and result.
564 // Work a press starts is abandoned when the press ends, which cancels a compaction
565 // still running, so a timer starts it in a dispatch of its own, which returns the run
566 // so that dispatch lasts until the compaction ends. The button hides until then, so
567 // repeated presses do not queue one /compact each.
568 const isCompacting = await read($, compactingAtom)
569 const compact = () => {
570 void update($, compactingAtom, () => true)
571 $.clock.after(1, () =>
572 $.command
573 .run({ command: 'compact' })
574 .catch((error: unknown) => $.ui.toast(`Compacting failed: ${error instanceof Error ? error.message : String(error)}`))
575 .finally(() => update($, compactingAtom, () => false)),
576 )
577 }
578
579 return (
580 <Box flexDirection="column">
581 {usageBand(look, meters, !e.props.isWorking && !isCompacting, compact)}
582 {theirs}
583 </Box>
584 )
585 })
586
587 // Claude Code's own dialog stays whole: the skin only adds a band above it.
588 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
589 const active = await activeSkin($)
590 const theirs = await next(e)
591
592 if (active === null) {
593 return theirs
594 }
595
596 const headers = e.props.questions
597 .map(question => (question as { header?: unknown } | null)?.header)
598 .filter((header): header is string => typeof header === 'string' && header !== '')
599 const look = lookOf($.ui.resolve(e), active, e.surface)
600 const { Box } = look.ui
601
602 return headers.length === 0 ? (
603 theirs
604 ) : (
605 <Box flexDirection="column">
606 {askBand(look, headers)}
607 {theirs}
608 </Box>
609 )
610 })
611}
612hooks/command.ts 122 lines1import type { Prefs } from '../types'
2
3export const DEFAULT_PREFS: Prefs = {
4 skin: 'noir',
5 icons: 'unicode',
6 rail: true,
7 tables: true,
8 shimmer: true,
9 band: true,
10 clipOutput: false,
11}
12
13// The on/off settings, by the word /skin and the settings pane use for each.
14export const TOGGLES = {
15 rail: 'rail',
16 tables: 'tables',
17 shimmer: 'shimmer',
18 band: 'band',
19 clip: 'clipOutput',
20} as const satisfies Record<string, keyof Prefs>
21
22export type ToggleWord = keyof typeof TOGGLES
23
24export type Outcome = {
25 prefs: Prefs
26 message: string
27 // `row` prints a transcript line (listings, mistakes); `toast` confirms a change quietly.
28 channel: 'row' | 'toast'
29}
30
31const flagOr = (value: unknown, fallback: boolean): boolean =>
32 typeof value === 'boolean' ? value : fallback
33
34// What the store hands back may be old, hand-edited or from another version.
35export function parsePrefs(raw: unknown, names: readonly string[]): Prefs {
36 const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
37 const skin = saved.skin
38
39 return {
40 skin:
41 typeof skin === 'string' && (skin === 'off' || names.includes(skin))
42 ? skin
43 : DEFAULT_PREFS.skin,
44 icons: saved.icons === 'ascii' ? 'ascii' : 'unicode',
45 rail: flagOr(saved.rail, DEFAULT_PREFS.rail),
46 tables: flagOr(saved.tables, DEFAULT_PREFS.tables),
47 shimmer: flagOr(saved.shimmer, DEFAULT_PREFS.shimmer),
48 band: flagOr(saved.band, DEFAULT_PREFS.band),
49 clipOutput: flagOr(saved.clipOutput, DEFAULT_PREFS.clipOutput),
50 }
51}
52
53const changed = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'toast' })
54
55const refused = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'row' })
56
57const onOff = (on: boolean): string => (on ? 'on' : 'off')
58
59export const listing = (current: Prefs, names: readonly string[]): string =>
60 [
61 ...names.map(name => `${name === current.skin ? '●' : '○'} ${name}`),
62 `${current.skin === 'off' ? '●' : '○'} off`,
63 [
64 `icons ${current.icons}`,
65 `rail ${onOff(current.rail)}`,
66 `tables ${onOff(current.tables)}`,
67 `shimmer ${onOff(current.shimmer)}`,
68 `band ${onOff(current.band)}`,
69 `clip ${onOff(current.clipOutput)}`,
70 ].join(' · '),
71 ].join('\n')
72
73const isToggle = (word: string): word is ToggleWord => Object.hasOwn(TOGGLES, word)
74
75// k-mods: who draws the spinner word and the turn footer. `auto` hands them to
76// status-ko when it's enabled; `skins` and `engine` override that either way.
77// A userConfig value outside these three (or absent) reads as `auto`.
78export type StatusLinesMode = 'auto' | 'skins' | 'engine'
79
80const STATUS_LINES_MODES: readonly StatusLinesMode[] = ['auto', 'skins', 'engine']
81
82export function statusLinesMode(value: unknown): StatusLinesMode {
83 return typeof value === 'string' && (STATUS_LINES_MODES as readonly string[]).includes(value)
84 ? (value as StatusLinesMode)
85 : 'auto'
86}
87
88// Every argument but none: with none, /skin opens the settings pane instead.
89export function runSkinCommand(args: string, current: Prefs, names: readonly string[]): Outcome {
90 const [head = '', value] = args.trim().toLowerCase().split(/\s+/)
91
92 if (isToggle(head)) {
93 const field = TOGGLES[head]
94
95 return value === 'on' || value === 'off'
96 ? changed({ ...current, [field]: value === 'on' }, `${head} ${value}`)
97 : refused(current, `usage: /skin ${head} on|off`)
98 }
99
100 switch (head) {
101 case 'list':
102 return refused(current, listing(current, names))
103 // `default` and `stock` are what people reach for when they want Claude Code's own look.
104 case 'off':
105 case 'default':
106 case 'stock':
107 return changed({ ...current, skin: 'off' }, 'skins off')
108 case 'on':
109 return current.skin === 'off'
110 ? changed({ ...current, skin: DEFAULT_PREFS.skin }, `skin: ${DEFAULT_PREFS.skin}`)
111 : changed(current, `skin: ${current.skin}`)
112 case 'icons':
113 return value === 'unicode' || value === 'ascii'
114 ? changed({ ...current, icons: value }, `icons: ${value}`)
115 : refused(current, 'usage: /skin icons unicode|ascii')
116 default:
117 return names.includes(head)
118 ? changed({ ...current, skin: head }, `skin: ${head}`)
119 : refused(current, `unknown skin "${head}". /skin list shows them`)
120 }
121}
122hooks/custom.ts 185 lines1import type { CustomSkin, SkinSlot } from '../types'
2import type { Skin } from './skin'
3import { SKINS } from './themes'
4
5export const SLOTS: readonly SkinSlot[] = [
6 'user',
7 'fg',
8 'muted',
9 'surface',
10 'zebra',
11 'ok',
12 'err',
13 'warn',
14 'read',
15 'write',
16 'run',
17 'search',
18 'web',
19 'mcp',
20 'other',
21]
22
23// What each slot paints, for the settings pane and the agent's tool.
24export const SLOT_HELP: Readonly<Record<SkinSlot, string>> = {
25 user: 'accent: the rail, your prompt marker, the spinner',
26 fg: 'body text',
27 muted: 'secondary text: commands, paths, timings',
28 surface: 'band behind a table header',
29 zebra: 'every other table row; keep it a step off the background',
30 ok: 'a finished call',
31 err: 'a failed call',
32 warn: 'an interrupted call',
33 read: 'Read',
34 write: 'Edit and Write',
35 run: 'Bash and PowerShell',
36 search: 'Glob and Grep',
37 web: 'WebFetch and WebSearch',
38 mcp: 'MCP tools',
39 other: 'anything else in a group',
40}
41
42export type Customs = Readonly<Record<string, CustomSkin>>
43
44const HEX = /^#[0-9a-f]{6}$/i
45const NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
46const MAX_WORDS = 12
47const MAX_WORD = 24
48
49const builtIn = (name: string): Skin | undefined => SKINS.find(skin => skin.name === name)
50
51export const skinNames = (custom: Customs): string[] => [
52 ...SKINS.map(skin => skin.name),
53 ...Object.keys(custom).filter(name => builtIn(name) === undefined),
54]
55
56// A made skin is its base with the slots and words it changed laid over it.
57export function resolveSkin(name: string, custom: Customs): Skin | undefined {
58 const own = builtIn(name)
59
60 if (own !== undefined) {
61 return own
62 }
63
64 const made = custom[name]
65 const base = made === undefined ? undefined : (builtIn(made.base) ?? SKINS[0])
66
67 if (made === undefined || base === undefined) {
68 return undefined
69 }
70
71 return {
72 name: made.name,
73 label: made.label,
74 palette: { ...base.palette, ...made.palette },
75 spinner: made.spinner.length > 0 ? made.spinner : base.spinner,
76 done: made.done.length > 0 ? made.done : base.done,
77 }
78}
79
80export type Draft = {
81 name?: unknown
82 label?: unknown
83 base?: unknown
84 palette?: unknown
85 spinner?: unknown
86 done?: unknown
87}
88
89function words(value: unknown, field: string, fallback: string[]): string[] | string {
90 if (value === undefined) {
91 return fallback
92 }
93
94 const valid =
95 Array.isArray(value) &&
96 value.length <= MAX_WORDS &&
97 value.every(word => typeof word === 'string' && word.trim() !== '' && word.length <= MAX_WORD)
98
99 return valid
100 ? (value as string[]).map(word => word.trim())
101 : `${field} must be up to ${MAX_WORDS} words of 1-${MAX_WORD} characters`
102}
103
104function palette(value: unknown, fallback: CustomSkin['palette']): CustomSkin['palette'] | string {
105 if (value === undefined) {
106 return fallback
107 }
108
109 if (typeof value !== 'object' || value === null || Array.isArray(value)) {
110 return 'palette must be an object of slot: "#rrggbb"'
111 }
112
113 const entries = Object.entries(value as Record<string, unknown>)
114 const bad = entries.filter(
115 ([slot, hex]) => !SLOTS.includes(slot as SkinSlot) || typeof hex !== 'string' || !HEX.test(hex),
116 )
117
118 if (bad.length > 0) {
119 return `bad palette entries: ${bad.map(([slot]) => slot).join(', ')}. Slots: ${SLOTS.join(', ')}; values #rrggbb`
120 }
121
122 return { ...fallback, ...Object.fromEntries(entries.map(([slot, hex]) => [slot, (hex as string).toLowerCase()])) }
123}
124
125// Builds a made skin from what the agent or the pane sent, or says what is wrong with it.
126export function buildCustom(draft: Draft, existing: CustomSkin | undefined): CustomSkin | string {
127 const name = typeof draft.name === 'string' ? draft.name.trim().toLowerCase() : ''
128
129 if (!NAME.test(name)) {
130 return 'name must be 1-32 lowercase letters, digits or hyphens'
131 }
132
133 if (builtIn(name) !== undefined || name === 'off') {
134 return `"${name}" is taken by a built-in skin; pick another name`
135 }
136
137 const base = typeof draft.base === 'string' ? draft.base : (existing?.base ?? SKINS[0]?.name ?? '')
138
139 if (builtIn(base) === undefined) {
140 return `base must be one of ${SKINS.map(skin => skin.name).join(', ')}`
141 }
142
143 const label = typeof draft.label === 'string' && draft.label.trim() !== '' ? draft.label.trim().slice(0, 32) : (existing?.label ?? name)
144 const colours = palette(draft.palette, existing?.palette ?? {})
145 const spinner = words(draft.spinner, 'spinner', existing?.spinner ?? [])
146 const done = words(draft.done, 'done', existing?.done ?? [])
147
148 for (const result of [colours, spinner, done]) {
149 if (typeof result === 'string') {
150 return result
151 }
152 }
153
154 return {
155 name,
156 label,
157 base,
158 palette: colours as CustomSkin['palette'],
159 spinner: spinner as string[],
160 done: done as string[],
161 }
162}
163
164// One slot changed from the settings pane: a built-in skin forks into `my-<name>`.
165export function withSlot(
166 current: string,
167 custom: Customs,
168 slot: SkinSlot,
169 hex: string,
170): { name: string; skin: CustomSkin } | string {
171 if (!HEX.test(hex)) {
172 return 'use a colour like #7aa2f7'
173 }
174
175 const made = custom[current]
176 const name = made === undefined ? `my-${current}` : current
177 const base = made?.base ?? current
178 const skin = buildCustom(
179 { name, base, label: made?.label ?? `My ${current}`, palette: { [slot]: hex } },
180 custom[name],
181 )
182
183 return typeof skin === 'string' ? skin : { name, skin }
184}
185hooks/light.ts 47 lines1import type { Palette, Skin } from './skin'
2
3// Every skin works on a light background too. A skin may carry its own light palette;
4// the rest are derived: body text goes near-black, bands go near-white, and each colour
5// is deepened until it reads on white.
6
7const channels = (hex: string): [number, number, number] => [
8 parseInt(hex.slice(1, 3), 16),
9 parseInt(hex.slice(3, 5), 16),
10 parseInt(hex.slice(5, 7), 16),
11]
12
13const toHex = (rgb: readonly number[]): string =>
14 `#${rgb.map(value => Math.round(Math.max(0, Math.min(255, value))).toString(16).padStart(2, '0')).join('')}`
15
16// `amount` of the way from `hex` to black.
17export const deepen = (hex: string, amount: number): string =>
18 toHex(channels(hex).map(value => value * (1 - amount)))
19
20export function toLight(palette: Palette): Palette {
21 const deep = (hex: string) => deepen(hex, 0.45)
22
23 return {
24 read: deep(palette.read),
25 write: deep(palette.write),
26 run: deep(palette.run),
27 search: deep(palette.search),
28 web: deep(palette.web),
29 mcp: deep(palette.mcp),
30 other: deep(palette.other),
31 user: deep(palette.user),
32 fg: '#1f1f1f',
33 muted: '#6b6b6b',
34 surface: '#ffffff',
35 zebra: '#f2f2f2',
36 ok: deepen(palette.ok, 0.5),
37 err: deepen(palette.err, 0.25),
38 warn: deepen(palette.warn, 0.5),
39 }
40}
41
42export const forTheme = (skin: Skin, isLight: boolean): Skin =>
43 isLight ? { ...skin, palette: skin.light ?? toLight(skin.palette) } : skin
44
45// Claude Code's theme names say light or dark in them: `light`, `light-daltonized`, ...
46export const isLightTheme = (value: unknown): boolean => typeof value === 'string' && value.includes('light')
47hooks/designer.ts 135 lines1import type { CustomSkin, Prefs } from '../types'
2import { DEFAULT_PREFS } from './command'
3import { buildCustom, resolveSkin, SLOT_HELP, skinNames } from './custom'
4import { SKINS } from './themes'
5
6// The tool the person's own agent calls to design a skin with them.
7export const DESIGN_TOOL = {
8 name: 'design',
9 description: [
10 'Design, change and apply skins for the skins mod, which restyles this Claude Code transcript:',
11 'tool rows on a timeline rail, tables, the spinner, the turn footer. Changes show at once.',
12 'Call {"action":"show"} first: it returns the current skin, every slot with what it paints, and the names in use.',
13 '"save" makes or updates a skin: name (lowercase, hyphens), base (a built-in skin it starts from),',
14 'palette ({slot: "#rrggbb"}, only the slots to change), spinner (gerunds, like "Brewing"),',
15 'done (past tense, like "Brewed"), apply (default true).',
16 '"apply" switches to a skin by name, "delete" removes a made one,',
17 '"settings" switches rail, tables, shimmer, band, clip (booleans) and icons ("unicode" or "ascii").',
18 'Good skins keep fg and muted readable on a dark background, and surface and zebra one small step off it.',
19 ].join(' '),
20 inputSchema: {
21 type: 'object',
22 properties: {
23 action: { type: 'string', enum: ['show', 'save', 'apply', 'delete', 'settings'] },
24 name: { type: 'string' },
25 label: { type: 'string' },
26 base: { type: 'string', enum: SKINS.map(skin => skin.name) },
27 palette: { type: 'object', additionalProperties: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' } },
28 spinner: { type: 'array', items: { type: 'string' }, maxItems: 12 },
29 done: { type: 'array', items: { type: 'string' }, maxItems: 12 },
30 apply: { type: 'boolean' },
31 settings: {
32 type: 'object',
33 properties: {
34 rail: { type: 'boolean' },
35 tables: { type: 'boolean' },
36 shimmer: { type: 'boolean' },
37 band: { type: 'boolean' },
38 clip: { type: 'boolean' },
39 icons: { type: 'string', enum: ['unicode', 'ascii'] },
40 },
41 },
42 },
43 required: ['action'],
44 },
45} as const
46
47export type DesignState = { prefs: Prefs; custom: Record<string, CustomSkin> }
48
49export type DesignOutcome = { text: string; state: DesignState; isError: boolean }
50
51type Input = Record<string, unknown>
52
53const fail = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: true })
54
55const ok = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: false })
56
57function show(state: DesignState): DesignOutcome {
58 const skin = resolveSkin(state.prefs.skin, state.custom)
59
60 return ok(
61 state,
62 JSON.stringify({
63 current: state.prefs.skin,
64 palette: skin?.palette ?? null,
65 spinner: skin?.spinner ?? [],
66 done: skin?.done ?? [],
67 builtIn: SKINS.map(one => one.name),
68 made: Object.keys(state.custom),
69 slots: SLOT_HELP,
70 settings: state.prefs,
71 }),
72 )
73}
74
75function save(input: Input, state: DesignState): DesignOutcome {
76 const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
77 const skin = buildCustom(input, state.custom[name])
78
79 if (typeof skin === 'string') {
80 return fail(state, skin)
81 }
82
83 const custom = { ...state.custom, [skin.name]: skin }
84 const prefs = input.apply === false ? state.prefs : { ...state.prefs, skin: skin.name }
85
86 return ok({ prefs, custom }, `saved ${skin.name}${input.apply === false ? '' : ' and applied it'}`)
87}
88
89function settings(input: Input, state: DesignState): DesignOutcome {
90 const asked = (typeof input.settings === 'object' && input.settings !== null ? input.settings : {}) as Input
91 const flag = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
92 const icons = asked.icons === 'ascii' || asked.icons === 'unicode' ? asked.icons : state.prefs.icons
93 const prefs: Prefs = {
94 ...state.prefs,
95 rail: flag(asked.rail, state.prefs.rail),
96 tables: flag(asked.tables, state.prefs.tables),
97 shimmer: flag(asked.shimmer, state.prefs.shimmer),
98 band: flag(asked.band, state.prefs.band),
99 clipOutput: flag(asked.clip, state.prefs.clipOutput),
100 icons,
101 }
102
103 return ok({ ...state, prefs }, `settings: ${JSON.stringify(prefs)}`)
104}
105
106export function runDesign(raw: unknown, state: DesignState): DesignOutcome {
107 const input = (typeof raw === 'object' && raw !== null ? raw : {}) as Input
108 const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
109
110 switch (input.action) {
111 case 'show':
112 return show(state)
113 case 'save':
114 return save(input, state)
115 case 'apply':
116 return skinNames(state.custom).includes(name)
117 ? ok({ ...state, prefs: { ...state.prefs, skin: name } }, `applied ${name}`)
118 : fail(state, `no skin named "${name}"; show lists them`)
119 case 'delete': {
120 if (state.custom[name] === undefined) {
121 return fail(state, `"${name}" is not a made skin; built-in skins cannot be deleted`)
122 }
123
124 const custom = Object.fromEntries(Object.entries(state.custom).filter(([key]) => key !== name))
125 const prefs = state.prefs.skin === name ? { ...state.prefs, skin: DEFAULT_PREFS.skin } : state.prefs
126
127 return ok({ prefs, custom }, `deleted ${name}`)
128 }
129 case 'settings':
130 return settings(input, state)
131 default:
132 return fail(state, 'action must be show, save, apply, delete or settings')
133 }
134}
135hooks/format.ts 85 lines1// Plain string helpers behind the row builders.
2
3export const oneLine = (text: string): string =>
4 (text.split('\n', 1)[0] ?? '').replace(/\s+/g, ' ').trim()
5
6const slashed = (path: string): string => path.replace(/\\/g, '/')
7
8// A path under the session's directory shows relative to it; any other stays as given.
9export function shortenPath(path: string, cwd: string): string {
10 const full = slashed(path)
11 const root = slashed(cwd).replace(/\/+$/, '')
12
13 if (root === '' || !full.toLowerCase().startsWith(`${root.toLowerCase()}/`)) {
14 return path
15 }
16
17 return full.slice(root.length + 1)
18}
19
20export function formatDuration(ms: number): string {
21 const seconds = Math.round(ms / 1000)
22
23 if (seconds < 1) {
24 return '<1s'
25 }
26
27 return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
28}
29
30// A tool call's own time: `340ms`, `2.1s`, `1m 4s`.
31export function formatMs(ms: number): string {
32 if (ms < 1000) {
33 return `${Math.round(ms)}ms`
34 }
35
36 return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms)
37}
38
39// Lines added and removed, read from an Edit or Write record's patch.
40export function diffstat(output: unknown): { added: number; removed: number } | null {
41 const patch = (output as { structuredPatch?: unknown } | null)?.structuredPatch
42
43 if (!Array.isArray(patch)) {
44 return null
45 }
46
47 const lines = patch.flatMap(hunk => {
48 const hunkLines = (hunk as { lines?: unknown } | null)?.lines
49
50 return Array.isArray(hunkLines) ? hunkLines.filter(line => typeof line === 'string') : []
51 }) as string[]
52
53 return {
54 added: lines.filter(line => line.startsWith('+')).length,
55 removed: lines.filter(line => line.startsWith('-')).length,
56 }
57}
58
59// The same seed always picks the same item, so a row keeps its word when it redraws.
60export function pick<T>(items: readonly T[], seed: string): T | undefined {
61 let hash = 5381
62
63 for (let i = 0; i < seed.length; i++) {
64 hash = (hash * 33 + seed.charCodeAt(i)) >>> 0
65 }
66
67 return items[hash % items.length]
68}
69
70// Keeps the first `head` and last `tail` lines of a long text and names how many it hid.
71export function clipLines(text: string, head: number, tail: number): string {
72 const lines = text.split('\n')
73 const hidden = lines.length - head - tail
74
75 if (hidden <= 1) {
76 return text
77 }
78
79 return [
80 ...lines.slice(0, head),
81 `… ${hidden} lines hidden`,
82 ...lines.slice(lines.length - tail),
83 ].join('\n')
84}
85hooks/markdown.ts 187 lines1// Finds the tables and fenced code in a reply so they can be drawn as cards; the rest
2// stays markdown.
3
4export type Align = 'left' | 'right' | 'center'
5
6export type Table = { kind: 'table'; header: string[]; align: Align[]; rows: string[][] }
7
8// `raw` is the fence as written, for the surfaces that keep Claude Code's own drawing.
9export type Code = { kind: 'code'; lang: string; code: string; raw: string }
10
11export type Segment = { kind: 'text'; text: string } | Table | Code
12
13const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
14const FENCE = /^\s*(```|~~~)\s*([\w+#.-]*)/
15
16// Inline emphasis and code ticks read as noise in a padded cell.
17const clean = (cell: string): string =>
18 cell.replace(/\*\*|__|`/g, '').replace(/\\\|/g, '|').trim()
19
20export function cellsOf(line: string): string[] {
21 const inner = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
22
23 return inner.split(/(?<!\\)\|/).map(clean)
24}
25
26const alignOf = (cell: string): Align => {
27 const spec = cell.trim()
28
29 if (spec.startsWith(':') && spec.endsWith(':')) {
30 return 'center'
31 }
32
33 return spec.endsWith(':') ? 'right' : 'left'
34}
35
36const fit = (cells: string[], width: number): string[] =>
37 Array.from({ length: width }, (_, i) => cells[i] ?? '')
38
39export function splitReply(markdown: string): Segment[] {
40 const lines = markdown.split('\n')
41 const segments: Segment[] = []
42 let text: string[] = []
43 let inFence = false
44
45 const flush = () => {
46 if (text.join('\n').trim() !== '') {
47 segments.push({ kind: 'text', text: text.join('\n') })
48 }
49
50 text = []
51 }
52
53 for (let i = 0; i < lines.length; i++) {
54 const line = lines[i] ?? ''
55 const next = lines[i + 1] ?? ''
56
57 const fence = FENCE.exec(line)
58
59 // A closed fence becomes a code segment; one still streaming stays text.
60 if (fence !== null && !inFence) {
61 const close = lines.findIndex((other, j) => j > i && FENCE.test(other) && other.trim().replace(/[`~]/g, '') === '')
62
63 if (close !== -1) {
64 flush()
65 segments.push({
66 kind: 'code',
67 lang: (fence[2] ?? '').toLowerCase(),
68 code: lines.slice(i + 1, close).join('\n'),
69 raw: lines.slice(i, close + 1).join('\n'),
70 })
71 i = close
72 continue
73 }
74
75 inFence = true
76 } else if (fence !== null) {
77 inFence = false
78 }
79
80 const header = cellsOf(line)
81 const isTable =
82 !inFence &&
83 line.includes('|') &&
84 SEPARATOR.test(next) &&
85 header.length > 1 &&
86 cellsOf(next).length === header.length
87
88 if (!isTable) {
89 text.push(line)
90 continue
91 }
92
93 flush()
94
95 const rows: string[][] = []
96 i += 2
97
98 while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim() !== '') {
99 rows.push(fit(cellsOf(lines[i] ?? ''), header.length))
100 i++
101 }
102
103 i--
104 segments.push({ kind: 'table', header, align: cellsOf(next).map(alignOf), rows })
105 }
106
107 flush()
108
109 return segments
110}
111
112// k-mods: a column used to be sized by character count, which cuts a Hangul table
113// short, since the terminal draws each Hangul (and other wide) character in two
114// cells. `widthOf` counts terminal cells instead, so `columnWidths`, `cutCell` and
115// `padCell` size and cut a Korean table the way the terminal actually draws it.
116const isWide = (ch: string): boolean =>
117 /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/.test(ch)
118
119export const widthOf = (text: string): number =>
120 [...text].reduce((sum, ch) => sum + (isWide(ch) ? 2 : 1), 0)
121
122// Cuts to a cell width in terminal cells, not characters, so a cut never splits a
123// wide character's two cells. Marked with an ellipsis where it lost text.
124function cutToWidth(text: string, width: number): string {
125 if (widthOf(text) <= width) {
126 return text
127 }
128
129 let out = ''
130 let used = 0
131
132 for (const ch of text) {
133 const w = isWide(ch) ? 2 : 1
134
135 if (used + w > width - 1) {
136 break
137 }
138
139 out += ch
140 used += w
141 }
142
143 return `${out}…`
144}
145
146// Natural column widths, narrowed from the widest down until the table fits.
147export function columnWidths(table: Table, maxWidth: number, gap: number): number[] {
148 const widths = table.header.map((cell, col) =>
149 Math.max(widthOf(cell), ...table.rows.map(row => widthOf(row[col] ?? ''))),
150 )
151 const budget = maxWidth - gap * (widths.length - 1)
152
153 while (widths.reduce((sum, width) => sum + width, 0) > budget) {
154 const widest = widths.indexOf(Math.max(...widths))
155
156 if ((widths[widest] ?? 0) <= 3) {
157 break
158 }
159
160 widths[widest] = (widths[widest] ?? 0) - 1
161 }
162
163 return widths
164}
165
166// A cell cut to its column, marked with an ellipsis where it lost text.
167export function cutCell(text: string, width: number): string {
168 return cutToWidth(text, width)
169}
170
171export function padCell(text: string, width: number, align: Align): string {
172 const cut = cutToWidth(text, width)
173 const room = width - widthOf(cut)
174
175 if (align === 'right') {
176 return ' '.repeat(room) + cut
177 }
178
179 if (align === 'center') {
180 const left = Math.floor(room / 2)
181
182 return ' '.repeat(left) + cut + ' '.repeat(room - left)
183 }
184
185 return cut + ' '.repeat(room)
186}
187hooks/rows.tsx 618 lines1import type { ElementTable, RenderSurface } from 'claude-code'
2
3import type { Prefs, TurnStats } from '../types'
4import { formatDuration, formatMs } from './format'
5import { columnWidths, cutCell } from './markdown'
6import type { Segment, Table } from './markdown'
7import type { Icons, Kind, Skin } from './skin'
8import { spinnerIcon, toolIcon } from './icons'
9import type { SpinnerMode } from './icons'
10import { codeSvg } from './svg-code'
11import { diffSvg } from './svg-diff'
12import type { DiffInput } from './svg-diff'
13import { cardWidth } from './svg-kit'
14import { tableSvg } from './svg-table'
15import { terminalSvg } from './svg-terminal'
16import type { ShellOutput } from './svg-terminal'
17import { usageLine, usageSvg } from './svg-usage'
18import type { Meter } from './svg-usage'
19import { kindOf, toolLabel } from './tools'
20
21export type Ui = Pick<ElementTable, 'Box' | 'Text' | 'Markdown' | 'Button'>
22
23// The vector element, on the surfaces that have one (the desktop app).
24export type SvgElement = ElementTable<'desktop'>['Svg']
25
26export type Look = {
27 ui: Ui
28 skin: Skin
29 icons: Icons
30 prefs: Prefs
31 surface: RenderSurface
32 // The vector element where the surface draws one: icons replace glyphs there.
33 svg?: SvgElement
34 // Puts text on the clipboard of the surface drawing; absent where nothing can copy.
35 copy?: (text: string) => void
36}
37
38export type Call = {
39 tool: string
40 input: unknown
41 isRunning: boolean
42 isErrored: boolean
43 isInterrupted: boolean
44}
45
46// What a tool row shows at its right edge.
47export type Meta = { ms?: number; added?: number; removed?: number }
48
49const KIND_LABEL: Readonly<Record<Kind, string>> = {
50 read: 'Read',
51 write: 'Edit',
52 run: 'Run',
53 search: 'Search',
54 web: 'Web',
55 mcp: 'MCP',
56 other: 'Other',
57}
58
59const CELL_PAD = 1
60
61// The worst thing that happened to any of the calls, else how far along they are.
62function status({ skin, icons }: Look, calls: readonly Call[]) {
63 if (calls.some(call => call.isInterrupted)) {
64 return { glyph: icons.interrupted, color: skin.palette.warn }
65 }
66
67 if (calls.some(call => call.isErrored)) {
68 return { glyph: icons.err, color: skin.palette.err }
69 }
70
71 if (calls.some(call => call.isRunning)) {
72 return { glyph: icons.running, color: skin.palette.muted }
73 }
74
75 return { glyph: icons.ok, color: skin.palette.ok }
76}
77
78// The connector above a node, which ties a turn's calls into one line down the left.
79function railLine(look: Look) {
80 const { Text } = look.ui
81
82 return <Text color={look.skin.palette.user}>{look.icons.rail}</Text>
83}
84
85function node(look: Look, calls: readonly Call[]) {
86 const { Text } = look.ui
87 const { glyph, color } = status(look, calls)
88 const branch = look.prefs.rail && look.surface === 'terminal' ? look.icons.branch : ''
89
90 return (
91 <Text color={color}>
92 {`${glyph}`}
93 <Text color={look.skin.palette.muted}>{`${branch} `}</Text>
94 </Text>
95 )
96}
97
98function metaSpans(look: Look, meta: Meta) {
99 const { Text } = look.ui
100 const { palette } = look.skin
101 const spans = []
102
103 if (meta.added !== undefined && meta.removed !== undefined && meta.added + meta.removed > 0) {
104 spans.push(<Text color={palette.ok}>{`+${meta.added}`}</Text>)
105 spans.push(<Text color={palette.err}>{` −${meta.removed}`}</Text>)
106 }
107
108 if (meta.ms !== undefined) {
109 spans.push(<Text color={palette.muted}>{`${spans.length > 0 ? ' ' : ''}${formatMs(meta.ms)}`}</Text>)
110 }
111
112 return spans
113}
114
115// A line with its facts flush right on the terminal and trailing it on the desktop,
116// which lays a row out its own way.
117function withMeta(look: Look, main: ReturnType<Ui['Text']>, meta: Meta) {
118 const { Box, Text } = look.ui
119 const spans = metaSpans(look, meta)
120
121 if (spans.length === 0) {
122 return main
123 }
124
125 if (look.surface !== 'terminal') {
126 return (
127 <Text wrap="truncate-end">
128 {main}
129 {' '}
130 {spans}
131 </Text>
132 )
133 }
134
135 return (
136 <Box flexDirection="row">
137 <Box flexGrow={1} flexShrink={1}>
138 {main}
139 </Box>
140 <Box flexShrink={0} paddingLeft={2}>
141 <Text>{spans}</Text>
142 </Box>
143 </Box>
144 )
145}
146
147function stack(look: Look, line: ReturnType<Ui['Text']>) {
148 const { Box } = look.ui
149
150 // Box-drawing rails need a monospace font, which only the terminal draws mod text in.
151 return look.prefs.rail && look.surface === 'terminal' ? (
152 <Box flexDirection="column">
153 {railLine(look)}
154 {line}
155 </Box>
156 ) : (
157 line
158 )
159}
160
161// On a surface with vector icons, the icon leads the row in place of the status glyph.
162function iconRow(look: Look, Svg: SvgElement, kind: Kind, calls: readonly Call[], line: ReturnType<Ui['Text']>) {
163 const { Box } = look.ui
164 const { color } = status(look, calls)
165 const source = toolIcon(kind, color, calls.some(call => call.isRunning))
166
167 return (
168 <Box flexDirection="row" columnGap={1} alignItems="center">
169 <Svg source={source} alt={kind} width={16} height={16} />
170 {line}
171 </Box>
172 )
173}
174
175export function toolRow(look: Look, call: Call, kind: Kind, target: string, meta: Meta) {
176 const { Text } = look.ui
177 const { palette } = look.skin
178
179 if (look.svg !== undefined) {
180 const line = (
181 <Text wrap="truncate-end">
182 <Text color={palette[kind]} bold>
183 {toolLabel(call.tool)}
184 </Text>
185 <Text color={call.isErrored ? palette.err : palette.muted}>{` ${target}`}</Text>
186 </Text>
187 )
188
189 return iconRow(look, look.svg, kind, [call], withMeta(look, line, meta))
190 }
191
192 const main = (
193 <Text wrap="truncate-end">
194 {node(look, [call])}
195 <Text color={palette[kind]}>{toolLabel(call.tool)}</Text>
196 <Text color={call.isErrored ? palette.err : palette.muted}>{` ${target}`}</Text>
197 </Text>
198 )
199
200 return stack(look, withMeta(look, main, meta))
201}
202
203// A run of reads and searches on one node: `●─ Read 3 · Search 2`.
204export function groupRow(look: Look, calls: readonly Call[]) {
205 const { Text } = look.ui
206 const { palette } = look.skin
207 const counts = new Map<Kind, number>()
208
209 for (const call of calls) {
210 const kind = kindOf(call.tool) ?? 'other'
211 counts.set(kind, (counts.get(kind) ?? 0) + 1)
212 }
213
214 const parts = [...counts].flatMap(([kind, count], i) => [
215 ...(i === 0 ? [] : [<Text color={palette.muted}>{' · '}</Text>]),
216 <Text color={palette[kind]}>{KIND_LABEL[kind]}</Text>,
217 <Text color={palette.muted}>{` ${count}`}</Text>,
218 ])
219
220 if (look.svg !== undefined) {
221 const lead = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'other'
222
223 return iconRow(look, look.svg, lead, calls, <Text wrap="truncate-end">{parts}</Text>)
224 }
225
226 return stack(
227 look,
228 <Text wrap="truncate-end">
229 {node(look, calls)}
230 {parts}
231 </Text>,
232 )
233}
234
235// The desktop's spinner row: an animated icon for what the turn is doing, beside the
236// step the desktop names (`Creating notes.md`).
237export function desktopSpinnerRow(look: Look, Svg: SvgElement, mode: SpinnerMode, text: string) {
238 const { Box, Text } = look.ui
239
240 return (
241 <Box flexDirection="row" columnGap={1} alignItems="center">
242 <Svg source={spinnerIcon(mode, look.skin.palette.user)} alt={mode} width={20} height={20} />
243 <Text color={look.skin.palette.muted}>{text}</Text>
244 </Box>
245 )
246}
247
248// Your message in an outline sized to what you typed, so it stands apart from replies
249// without taking the full width. Images it carried follow below it, drawn by Claude Code.
250//
251// k-mods: the border, the ❯ marker and the bold text use the skin's own `user` accent
252// (the same slot the rail, the spinner and the footer already use) instead of a fixed
253// hex, so the emphasis is the right colour for every skin and for light backgrounds
254// too (each skin's `user` has a light variant, derived or declared in hooks/themes).
255export function promptRow(look: Look, text: string, images?: ReturnType<Ui['Text']>) {
256 const { Box, Text } = look.ui
257 const { palette } = look.skin
258
259 return (
260 <Box flexDirection="column" alignItems="flex-start" marginY={1}>
261 <Box borderStyle="bold" borderColor={palette.user} backgroundColor={palette.surface} paddingX={1} flexShrink={1}>
262 <Text color={palette.fg} bold>
263 <Text color={palette.user}>{'❯ '}</Text>
264 {text}
265 </Text>
266 </Box>
267 {images ?? ''}
268 </Box>
269 )
270}
271
272const JUSTIFY = { left: 'flex-start', right: 'flex-end', center: 'center' } as const
273
274// The share of the reported width a table may take at its natural size. Past it, the
275// table spans its container and splits it between columns in proportion: the desktop
276// app reports the window's width, wider than the transcript column a reply sits in.
277const NATURAL_SHARE = 0.55
278
279// A grid of cells, so columns line up in the terminal's monospace and in the desktop
280// app's proportional font alike. `control`, such as a Copy button, sits on the frame's
281// top border at the right, as ╭──── Copy ─╮.
282export function tableRows(look: Look, table: Table, maxWidth: number, control?: ReturnType<Ui['Button']>) {
283 const { Box, Text } = look.ui
284 const { palette } = look.skin
285 const widths = columnWidths(table, maxWidth - 4, CELL_PAD * 2)
286 const natural = widths.reduce((sum, width) => sum + width + CELL_PAD * 2, 2)
287 const isFluid = natural > maxWidth * NATURAL_SHARE
288 const size = (i: number) =>
289 isFluid
290 ? { width: 0, flexGrow: Math.max(1, widths[i] ?? 1), flexShrink: 1 }
291 : { width: (widths[i] ?? 0) + CELL_PAD * 2, flexShrink: 0 }
292 const row = (cells: readonly string[], isHeader: boolean, band: string | undefined) => (
293 <Box flexDirection="row" {...(band === undefined ? {} : { backgroundColor: band })}>
294 {cells.map((cell, i) => (
295 <Box {...size(i)} paddingX={CELL_PAD} justifyContent={JUSTIFY[table.align[i] ?? 'left']}>
296 <Text color={isHeader ? palette.user : palette.fg} bold={isHeader} wrap="truncate-end">
297 {cutCell(cell, widths[i] ?? 0)}
298 </Text>
299 </Box>
300 ))}
301 </Box>
302 )
303
304 // k-mods: no header band or zebra striping. Both were tuned on a dark terminal; the
305 // header's own `surface` band read as a near-white stripe on a light background or a
306 // grey terminal, and the zebra rows turned into stripes next to it. The header text's
307 // `user` colour (the skin's one accent, already used for the rail and the spinner) is
308 // emphasis enough, and it has a light-background variant for every skin.
309 return (
310 <Box
311 flexDirection="column"
312 marginY={1}
313 borderStyle="round"
314 borderColor={palette.muted}
315 {...(isFluid ? { width: '100%' } : { alignSelf: 'flex-start' as const })}
316 >
317 {row(table.header, true, undefined)}
318 {table.rows.map(cells => row(cells, false, undefined))}
319 {control === undefined ? (
320 ''
321 ) : (
322 <Box position="absolute" top={-1} right={1}>
323 {control}
324 </Box>
325 )}
326 </Box>
327 )
328}
329
330// A card drawn as an image, with its Copy button laid over the top-right corner the card
331// left free. The image cannot be pressed, so the button is a real one on top of it; the
332// box hugs the image so the corner is the card's, not the column's.
333function cardWithCopy(look: Look, Svg: SvgElement, built: { source: string; alt: string; width: number; height: number }, key: string, text: string) {
334 const { Box, Button } = look.ui
335 const copy = look.copy
336
337 return (
338 <Box marginY={1} alignSelf="flex-start">
339 <Svg source={built.source} alt={built.alt} width={built.width} height={built.height} />
340 {copy === undefined ? (
341 ''
342 ) : (
343 <Box position="absolute" top={1} right={3}>
344 <Button key={key} label="Copy" plain dimColor onPress={() => copy(text)} />
345 </Box>
346 )}
347 </Box>
348 )
349}
350
351function tableCard(look: Look, table: Table, Svg: SvgElement, columns: number, key: string) {
352 const card = tableSvg(table, look.skin.palette, cardWidth(columns), look.copy !== undefined)
353
354 return cardWithCopy(look, Svg, card, key, tableMarkdown(table))
355}
356
357// A table as markdown again, for the clipboard.
358const tableMarkdown = (table: Table): string =>
359 [table.header, table.header.map(() => '---'), ...table.rows].map(cells => `| ${cells.join(' | ')} |`).join('\n')
360
361// A Copy button padded to sit on a border line; nothing where nothing can copy.
362function copyButton(look: Look, key: string, text: string) {
363 const { Button } = look.ui
364 const copy = look.copy
365
366 return copy === undefined ? undefined : <Button key={key} label=" Copy " plain dimColor onPress={() => copy(text)} />
367}
368
369// A small Copy button under a card or block, flush right; nothing where nothing can copy.
370export function copyRow(look: Look, key: string, text: string) {
371 const { Box, Button } = look.ui
372 const copy = look.copy
373
374 if (copy === undefined) {
375 return ''
376 }
377
378 return (
379 <Box flexDirection="row" justifyContent="flex-end">
380 <Button key={key} label="Copy" plain dimColor onPress={() => copy(text)} />
381 </Box>
382 )
383}
384
385export function replyRows(look: Look, segments: readonly Segment[], maxWidth: number, Svg?: SvgElement) {
386 const { Box, Markdown } = look.ui
387
388 return (
389 <Box flexDirection="column">
390 {segments.map((segment, i) => {
391 if (segment.kind === 'text') {
392 return <Markdown text={segment.text} />
393 }
394
395 if (segment.kind === 'code') {
396 // k-mods: a rounded card in the terminal too, matching the table and the
397 // desktop's code card, instead of plain markdown with Copy floating under it.
398 // Copy sits inside the card at the bottom right, not on the top border: a
399 // position="absolute" overlay (as the table and desktop cards use) does not
400 // draw in the terminal, and the border can't be dragged into a selection
401 // without picking up its │ characters, so a button is the only way to copy.
402 return Svg === undefined ? (
403 <Box flexDirection="column" marginY={1} borderStyle="round" borderColor={look.skin.palette.muted} paddingX={1}>
404 <Markdown text={segment.raw} />
405 {look.copy === undefined ? (
406 ''
407 ) : (
408 <Box flexDirection="row" justifyContent="flex-end">
409 {copyButton(look, `copy-${i}`, segment.code)}
410 </Box>
411 )}
412 </Box>
413 ) : (
414 codeCard(look, segment.lang, segment.code, Svg, maxWidth, `copy-${i}`)
415 )
416 }
417
418 return Svg === undefined ? (
419 tableRows(look, segment, maxWidth, copyButton(look, `copy-${i}`, tableMarkdown(segment)))
420 ) : (
421 tableCard(look, segment, Svg, maxWidth, `copy-${i}`)
422 )
423 })}
424 </Box>
425 )
426}
427
428// The word with a band of light sweeping through it, one letter a frame.
429export function spinnerRow(look: Look, word: string, frame: number, elapsedMs: number) {
430 const { Text } = look.ui
431 const { palette } = look.skin
432 const letters = [...word]
433 const head = (frame % (letters.length + 8)) - 4
434 const shade = (i: number): string => {
435 const distance = Math.abs(i - head)
436
437 return distance === 0 ? palette.fg : distance === 1 ? palette.user : palette.muted
438 }
439 const runs: { color: string; text: string }[] = []
440
441 for (const [i, letter] of letters.entries()) {
442 const color = shade(i)
443 const last = runs.at(-1)
444
445 if (last?.color === color) {
446 last.text += letter
447 } else {
448 runs.push({ color, text: letter })
449 }
450 }
451
452 return (
453 <Text>
454 <Text color={palette.user}>{`${look.icons.frames[frame % look.icons.frames.length] ?? ''} `}</Text>
455 {runs.map(run => (
456 <Text color={run.color}>{run.text}</Text>
457 ))}
458 <Text color={palette.muted}>{`… ${formatDuration(elapsedMs)}`}</Text>
459 </Text>
460 )
461}
462
463export function footerRow(look: Look, word: string, durationMs: number, stats: TurnStats | undefined) {
464 const { Text } = look.ui
465 const { palette } = look.skin
466 const facts = [
467 ...(stats === undefined || stats.tools === 0 ? [] : [`${stats.tools} tool${stats.tools === 1 ? '' : 's'}`]),
468 ]
469 const hasDiff = stats !== undefined && stats.added + stats.removed > 0
470
471 return (
472 <Text color={palette.muted}>
473 <Text color={palette.user}>{`${look.icons.done} `}</Text>
474 {`${word} in ${formatDuration(durationMs)}`}
475 {facts.length > 0 ? ` · ${facts.join(' · ')}` : ''}
476 {hasDiff ? ' · ' : ''}
477 {hasDiff ? <Text color={palette.ok}>{`+${stats.added}`}</Text> : ''}
478 {hasDiff ? <Text color={palette.err}>{` −${stats.removed}`}</Text> : ''}
479 </Text>
480 )
481}
482
483// A band above Claude Code's own question dialog: the topics it asks about, in the skin.
484export function askBand(look: Look, headers: readonly string[]) {
485 const { Text } = look.ui
486 const { palette } = look.skin
487
488 return (
489 <Text color={palette.muted}>
490 <Text color={palette.user}>{`${look.icons.prompt} `}</Text>
491 {headers.map((header, i) => (
492 <Text color={i === 0 ? palette.fg : palette.muted} bold={i === 0}>
493 {`${i === 0 ? '' : ' · '}${header}`}
494 </Text>
495 ))}
496 </Text>
497 )
498}
499
500// A vector card in a reply or under a tool row, a line's breath above and below it.
501function card(look: Look, Svg: SvgElement, built: { source: string; alt: string }) {
502 const { Box } = look.ui
503
504 return (
505 <Box marginY={1}>
506 <Svg source={built.source} alt={built.alt} />
507 </Box>
508 )
509}
510
511export function codeCard(look: Look, lang: string, code: string, Svg: SvgElement, columns: number, key = 'copy-code') {
512 return cardWithCopy(look, Svg, codeSvg(code, lang, look.skin.palette, cardWidth(columns), look.copy !== undefined), key, code)
513}
514
515export function diffCard(look: Look, Svg: SvgElement, input: DiffInput, shownPath: string, columns: number) {
516 return card(look, Svg, diffSvg(input, shownPath, look.skin.palette, cardWidth(columns)))
517}
518
519export function terminalCard(look: Look, Svg: SvgElement, output: ShellOutput, isErrored: boolean, columns: number) {
520 const text = [output.stdout, output.stderr].filter(part => part.trim() !== '').join('\n')
521 const withCopy = text === '' ? { ...look, copy: undefined } : look
522
523 return cardWithCopy(withCopy, Svg, terminalSvg(output, isErrored, look.skin.palette, cardWidth(columns), withCopy.copy !== undefined), 'copy-output', text)
524}
525
526// k-mods: a terminal card for a shell command's output. The desktop gets `terminalCard`
527// above, drawn as an `Svg` vector; the terminal has no `Svg`, and without this card a
528// finished Bash call's stdout/stderr did not appear at all in the terminal (see
529// register.tsx's `ToolUse` hook, which draws this directly under the tool row from the
530// same props, rather than relying on the `ToolResult` site). A rounded border in the
531// skin's muted colour, or its error colour when the call failed.
532export function shellCard(look: Look, stdout: string, stderr: string, isErrored: boolean) {
533 const { Box, Text } = look.ui
534 const { palette } = look.skin
535 const lines = (text: string) => text.replace(/\n+$/, '').split('\n')
536 const out = stdout.trim() === '' ? [] : lines(stdout)
537 const err = stderr.trim() === '' ? [] : lines(stderr)
538
539 if (out.length === 0 && err.length === 0) {
540 return ''
541 }
542
543 return (
544 <Box flexDirection="column" marginLeft={2} borderStyle="round" borderColor={isErrored ? palette.err : palette.muted} paddingX={1}>
545 {out.map(line => (
546 <Text color={line.startsWith('… ') && line.endsWith('lines hidden') ? palette.muted : palette.fg} wrap="truncate-end">
547 {line === '' ? ' ' : line}
548 </Text>
549 ))}
550 {err.map(line => (
551 <Text color={palette.err} wrap="truncate-end">
552 {line === '' ? ' ' : line}
553 </Text>
554 ))}
555 </Box>
556 )
557}
558
559// From this full, the band suggests compacting and makes it the main action.
560export const COMPACT_NUDGE = 70
561
562export const COMPACT_HOTKEY = '0'
563
564function meterView(look: Look, meters: readonly Meter[]) {
565 const { Box, Text } = look.ui
566 const { palette } = look.skin
567
568 if (look.svg !== undefined) {
569 const Svg = look.svg
570 const built = usageSvg(meters, palette)
571
572 return <Svg source={built.source} alt={built.alt} width={built.width} height={built.height} />
573 }
574
575 return (
576 <Box flexDirection="row" columnGap={3}>
577 {usageLine(meters).map(meter => (
578 <Text color={palette.muted}>
579 <Text color={palette.user}>{meter.bar}</Text>
580 {` ${meter.percent}% ${meter.label}`}
581 </Text>
582 ))}
583 </Box>
584 )
585}
586
587// The band above the prompt: the meters, and a Compact button that becomes the main
588// action, with a word on why, once the context is full enough to be worth it.
589export function usageBand(look: Look, meters: readonly Meter[], canCompact: boolean, compact: () => void) {
590 const { Box, Text, Button } = look.ui
591 const { palette } = look.skin
592 const context = meters.find(meter => meter.label === 'context')?.percent ?? 0
593 const isNudge = context >= COMPACT_NUDGE
594
595 return (
596 // The right edge stays clear: the band draws its own collapse mark ([-]) there.
597 <Box flexDirection="row" alignItems="center" columnGap={2} paddingRight={5}>
598 {meterView(look, meters)}
599 <Box flexGrow={1} />
600 {canCompact && isNudge ? <Text color={palette.warn}>{`Context is ${context}% full`}</Text> : ''}
601 {canCompact ? (
602 // A digit hotkey: typed alone into an empty prompt it presses the band's button, which
603 // is the only way in where the terminal reports no clicks. `plain` shows it: `0: Compact`.
604 <Button
605 key="compact"
606 label={isNudge ? 'Compact now' : 'Compact'}
607 hotkey={COMPACT_HOTKEY}
608 plain
609 {...(isNudge ? { variant: 'primary' as const } : { dimColor: true })}
610 onPress={compact}
611 />
612 ) : (
613 ''
614 )}
615 </Box>
616 )
617}
618hooks/gallery.tsx 104 lines1import type { RenderElement } from 'claude-code'
2
3import type { Table } from './markdown'
4import {
5 askBand,
6 codeCard,
7 desktopSpinnerRow,
8 diffCard,
9 footerRow,
10 groupRow,
11 promptRow,
12 replyRows,
13 spinnerRow,
14 terminalCard,
15 toolRow,
16 usageBand,
17} from './rows'
18import type { Look } from './rows'
19
20// Every element the skin draws, numbered, from the same builders the transcript uses, so
21// a note on #4 is a note on the real thing.
22
23const done = { isRunning: false, isErrored: false, isInterrupted: false }
24
25const TABLE: Table = {
26 kind: 'table',
27 header: ['Skin', 'Accent', 'Time'],
28 align: ['left', 'left', 'right'],
29 rows: [
30 ['noir', '#f5f5f5', '41s'],
31 ['tokyo-night', '#7aa2f7', '2.1s'],
32 ['dracula', '#bd93f9', '340ms'],
33 ],
34}
35
36const CODE = ['// Rate limit per route, keyed by user', 'export function limit(route: string, perMinute = 60) {', ' return (used: number) => used < perMinute', '}'].join('\n')
37
38const DIFF = {
39 path: '/work/apps/hub/src/server.ts',
40 isNewFile: false,
41 hunks: [{ oldStart: 12, newStart: 12, lines: [' const app = fastify()', '-app.listen(7447)', '+app.register(rateLimit, { max: 60 })', '+app.listen(7447)'] }],
42}
43
44export function galleryPane(look: Look, columns: number) {
45 const { Box, Text } = look.ui
46 const { palette } = look.skin
47 const svg = look.svg
48 let number = 0
49 const section = (title: string, ...items: RenderElement[]) => {
50 number += 1
51
52 return (
53 <Box flexDirection="column" marginBottom={1}>
54 <Text color={palette.muted} bold>{`${number} ${title}`}</Text>
55 {items}
56 </Box>
57 )
58 }
59 const width = Math.max(40, columns - 4)
60
61 return (
62 <Box flexDirection="column">
63 {section('Your prompt', promptRow(look, 'add rate limiting to the hub api'))}
64 {section(
65 'Tool rows: finished, running, failed',
66 toolRow(look, { tool: 'Bash', input: {}, ...done }, 'run', 'pnpm test --filter hub', { ms: 2140 }),
67 toolRow(look, { tool: 'Read', input: {}, ...done, isRunning: true }, 'read', 'docs/protocols.md', {}),
68 toolRow(look, { tool: 'Edit', input: {}, ...done, isErrored: true }, 'write', 'apps/hub/src/server.ts', {}),
69 )}
70 {section('Folded group of reads and searches', groupRow(look, [{ tool: 'Read', input: {}, ...done }, { tool: 'Read', input: {}, ...done }, { tool: 'Grep', input: {}, ...done }]))}
71 {svg === undefined
72 ? section('Edit diff card', <Text color={palette.muted}>Desktop only; the terminal keeps Claude Code's diff.</Text>)
73 : section('Edit diff card', diffCard(look, svg, DIFF, 'apps/hub/src/server.ts', width))}
74 {svg === undefined
75 ? section('Shell output card', <Text color={palette.muted}>Desktop only.</Text>)
76 : section(
77 'Shell output card: ok, failed',
78 terminalCard(look, svg, { stdout: 'Test Files 12 passed (12)\n Tests 148 passed (148)', stderr: '', interrupted: false }, false, width),
79 terminalCard(look, svg, { stdout: '', stderr: 'error TS2322: Type string is not assignable to number', interrupted: false }, true, width),
80 )}
81 {svg === undefined
82 ? section('Code block card', <Text color={palette.muted}>Desktop only; the terminal keeps Claude Code's markdown.</Text>)
83 : section('Code block card', codeCard(look, 'ts', CODE, svg, width))}
84 {section('Table', replyRows(look, [TABLE], width, svg))}
85 {svg === undefined
86 ? section('Spinner', spinnerRow(look, look.skin.spinner[0] ?? 'Working', 3, 12_000))
87 : section(
88 'Spinner: thinking, tool running, writing, waiting',
89 desktopSpinnerRow(look, svg, 'thinking', 'Thinking'),
90 desktopSpinnerRow(look, svg, 'tool-use', 'Running pnpm test'),
91 desktopSpinnerRow(look, svg, 'responding', 'Writing the reply'),
92 desktopSpinnerRow(look, svg, 'requesting', 'Waiting for the model'),
93 )}
94 {section(
95 'Band above the prompt: normal, then nudging to compact',
96 usageBand(look, [{ label: 'context', percent: 42 }, { label: '5h', percent: 18 }], true, () => undefined),
97 usageBand(look, [{ label: 'context', percent: 85 }, { label: '5h', percent: 61 }], true, () => undefined),
98 )}
99 {section('Band above a question', askBand(look, ['Approach', 'Store']))}
100 {section('Turn footer (terminal)', footerRow(look, look.skin.done[0] ?? 'Done', 41_000, { tools: 6, added: 18, removed: 3 }))}
101 </Box>
102 )
103}
104hooks/settings.tsx 145 lines1import type { ElementTable } from 'claude-code'
2
3import type { SkinSlot } from '../types'
4import type { ToggleWord } from './command'
5import { SLOTS } from './custom'
6import type { Table } from './markdown'
7import { footerRow, groupRow, promptRow, tableRows, toolRow } from './rows'
8import type { Look } from './rows'
9
10export type PaneUi = Pick<ElementTable<'terminal' | 'desktop'>, 'Box' | 'Text' | 'Markdown' | 'Button' | 'Input' | 'Select'>
11
12export type SettingsModel = {
13 names: readonly string[]
14 editing: SkinSlot
15 width: number
16}
17
18export type SettingsActions = {
19 pick: (name: string) => void
20 toggle: (word: ToggleWord) => void
21 icons: () => void
22 edit: (slot: SkinSlot) => void
23 paint: (hex: string) => void
24}
25
26const SAMPLE: Table = {
27 kind: 'table',
28 header: ['Route', 'Limit', 'Window'],
29 align: ['left', 'right', 'left'],
30 rows: [
31 ['/chat', '60', '1 min'],
32 ['/upload', '10', '1 min'],
33 ['/search', '120', '1 min'],
34 ],
35}
36
37const done = { isRunning: false, isErrored: false, isInterrupted: false }
38
39// A turn as the skin would draw it, built from the same rows the transcript uses.
40function preview(look: Look, width: number) {
41 const { Box } = look.ui
42 const word = look.skin.done[0] ?? 'Done'
43
44 return (
45 <Box flexDirection="column" borderStyle="round" borderColor={look.skin.palette.muted} paddingX={1}>
46 {promptRow(look, 'add rate limiting to the hub api')}
47 {toolRow(look, { tool: 'Bash', input: {}, ...done }, 'run', 'pnpm test --filter hub', { ms: 2140 })}
48 {toolRow(look, { tool: 'Edit', input: {}, ...done }, 'write', 'apps/hub/src/server.ts', {
49 ms: 380,
50 added: 18,
51 removed: 3,
52 })}
53 {groupRow(look, [
54 { tool: 'Read', input: {}, ...done },
55 { tool: 'Read', input: {}, ...done },
56 { tool: 'Grep', input: {}, ...done },
57 ])}
58 {tableRows(look, SAMPLE, Math.max(20, width - 4))}
59 {footerRow(look, word, 41_000, { tools: 6, added: 18, removed: 3 })}
60 </Box>
61 )
62}
63
64const mark = (on: boolean): string => (on ? '●' : '○')
65
66export function settingsPane(look: Look, ui: PaneUi, model: SettingsModel, actions: SettingsActions) {
67 const { Box, Text, Button, Input, Select } = ui
68 const { palette } = look.skin
69 const { prefs } = look
70 const toggle = (word: ToggleWord, hotkey: string, on: boolean) => (
71 <Button
72 key={`toggle-${word}`}
73 label={`${word} ${mark(on)}`}
74 hotkey={hotkey}
75 plain
76 dimColor={!on}
77 onPress={() => actions.toggle(word)}
78 />
79 )
80
81 return (
82 <Box flexDirection="column" rowGap={1}>
83 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
84 <Text color={palette.muted}>Skin</Text>
85 {model.names.map((name, i) => (
86 <Button
87 key={`skin-${name}`}
88 label={name === prefs.skin ? `${name} ●` : name}
89 plain
90 dimColor={name !== prefs.skin}
91 onPress={() => actions.pick(name)}
92 {...(i < 9 ? { hotkey: String(i + 1) } : {})}
93 />
94 ))}
95 </Box>
96
97 {preview(look, model.width)}
98
99 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
100 <Text color={palette.muted}>Look</Text>
101 {toggle('rail', 'r', prefs.rail)}
102 {toggle('tables', 't', prefs.tables)}
103 {toggle('shimmer', 's', prefs.shimmer)}
104 {toggle('band', 'b', prefs.band)}
105 {toggle('clip', 'c', prefs.clipOutput)}
106 <Button key="icons" label={`icons ${prefs.icons}`} hotkey="i" plain onPress={() => actions.icons()} />
107 </Box>
108
109 <Box flexDirection="column">
110 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
111 {SLOTS.map(slot => (
112 <Text>
113 <Text backgroundColor={palette[slot]}>{' '}</Text>
114 <Text color={slot === model.editing ? palette.fg : palette.muted} bold={slot === model.editing}>
115 {` ${slot}`}
116 </Text>
117 </Text>
118 ))}
119 </Box>
120 <Box flexDirection="row" columnGap={2} marginTop={1}>
121 <Select
122 key="slot"
123 label="Colour"
124 value={model.editing}
125 options={SLOTS.map(slot => ({ value: slot, label: slot }))}
126 onSelect={(value: string) => actions.edit(value as SkinSlot)}
127 />
128 <Input
129 key="hex"
130 label={palette[model.editing]}
131 placeholder="#rrggbb"
132 value=""
133 submitLabel="paint"
134 onSubmit={(value: string) => actions.paint(value.trim())}
135 />
136 </Box>
137 </Box>
138
139 <Text color={palette.muted}>
140 Or ask Claude: <Text color={palette.fg}>“make me a skin that feels like a sunset”</Text>. Esc closes.
141 </Text>
142 </Box>
143 )
144}
145hooks/skin.ts 57 lines1import type { SkinSlot } from '../types'
2
3// Shapes shared by the skins and the row builders.
4
5// What a tool call is, by colour. `other` only shows in group rows.
6export type Kind = 'read' | 'write' | 'run' | 'search' | 'web' | 'mcp' | 'other'
7
8// The kinds a skin draws a tool row for; the rest keep Claude Code's own row.
9export type ToolKind = Exclude<Kind, 'other'>
10
11export type Slot = SkinSlot
12
13export type Palette = Readonly<Record<Slot, string>>
14
15export type Skin = {
16 readonly name: string
17 readonly label: string
18 readonly palette: Palette
19 // Gerunds for the spinner, and past-tense words for the turn footer.
20 readonly spinner: readonly string[]
21 readonly done: readonly string[]
22 // Its own palette for a light background; without one, light.ts derives it.
23 readonly light?: Palette
24}
25
26export type IconSet = 'unicode' | 'ascii'
27
28export type Icons = Readonly<
29 Record<'running' | 'ok' | 'err' | 'interrupted' | 'done' | 'rail' | 'branch' | 'prompt', string>
30> & { readonly frames: readonly string[] }
31
32// Single-cell glyphs with no emoji presentation; each status has its own shape, not only a colour.
33export const ICONS: Readonly<Record<IconSet, Icons>> = {
34 unicode: {
35 running: '○',
36 ok: '●',
37 err: '✕',
38 interrupted: '◌',
39 done: '◆',
40 rail: '┃',
41 branch: '─',
42 prompt: '▍',
43 frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
44 },
45 ascii: {
46 running: 'o',
47 ok: '*',
48 err: 'x',
49 interrupted: '-',
50 done: '>',
51 rail: '|',
52 branch: '-',
53 prompt: '>',
54 frames: ['|', '/', '-', '\\'],
55 },
56}
57hooks/svg-diff.ts 118 lines1import type { Palette } from './skin'
2import { escape, fitText, MONO, pill, riseDelay, strokeIcon, svgCard } from './svg-kit'
3
4// An edit as a card: the file, how many lines it added and removed, and the changed
5// lines with their numbers, green and red, rising in one after another.
6
7const HEADER_H = 44
8const LINE_H = 22
9const GAP_H = 20
10const CODE = 12.5
11const MAX_LINES = 30
12const PENCIL = '<path d="M4 20h4L18.5 9.5a2.83 2.83 0 0 0-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>'
13const NEW_FILE = '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"/><path d="M12 11v6M9 14h6"/>'
14
15export type Hunk = { oldStart: number; newStart: number; lines: readonly string[] }
16
17export type DiffInput = { path: string; hunks: readonly Hunk[]; isNewFile: boolean }
18
19type Line = { kind: 'add' | 'del' | 'ctx'; text: string; old?: number; new?: number } | { kind: 'gap'; at: number }
20
21// The patch's lines in order, numbered on each side, with a gap mark between hunks.
22export function diffLines(hunks: readonly Hunk[]): Line[] {
23 return hunks.flatMap((hunk, h) => {
24 let oldNo = hunk.oldStart
25 let newNo = hunk.newStart
26 const lines: Line[] = h === 0 ? [] : [{ kind: 'gap', at: hunk.newStart }]
27
28 for (const raw of hunk.lines) {
29 if (raw.startsWith('\\')) {
30 continue
31 }
32
33 const text = raw.slice(1).replace(/\t/g, ' ')
34
35 if (raw.startsWith('+')) {
36 lines.push({ kind: 'add', text, new: newNo++ })
37 } else if (raw.startsWith('-')) {
38 lines.push({ kind: 'del', text, old: oldNo++ })
39 } else {
40 lines.push({ kind: 'ctx', text, old: oldNo++, new: newNo++ })
41 }
42 }
43
44 return lines
45 })
46}
47
48// A new file's patch can arrive empty: its content stands in as added lines.
49export function hunksOf(output: unknown): DiffInput | null {
50 const record = (typeof output === 'object' && output !== null ? output : {}) as Record<string, unknown>
51 const path = typeof record.filePath === 'string' ? record.filePath : ''
52 const isNewFile = record.type === 'create'
53 const patch = Array.isArray(record.structuredPatch) ? (record.structuredPatch as Hunk[]) : []
54
55 if (path === '') {
56 return null
57 }
58
59 if (patch.length > 0) {
60 return { path, hunks: patch, isNewFile }
61 }
62
63 if (isNewFile && typeof record.content === 'string') {
64 return { path, hunks: [{ oldStart: 0, newStart: 1, lines: record.content.split('\n').map(line => `+${line}`) }], isNewFile }
65 }
66
67 return null
68}
69
70export function diffSvg(input: DiffInput, shownPath: string, palette: Palette, width: number): { source: string; height: number; alt: string } {
71 const all = diffLines(input.hunks)
72 const shown = all.slice(0, MAX_LINES)
73 const hidden = all.length - shown.length
74 const added = all.filter(line => line.kind === 'add').length
75 const removed = all.filter(line => line.kind === 'del').length
76 const codeX = 112
77 const codeWidth = width - codeX - 16
78
79 let y = HEADER_H
80 const rows = shown.map((line, i) => {
81 const top = y
82
83 if (line.kind === 'gap') {
84 y += GAP_H
85
86 return `<g ${riseDelay(i, 18)} class="rise"><text x="${codeX}" y="${top + 14}" font-size="11" style="fill:${palette.muted}">⋯ line ${line.at}</text></g>`
87 }
88
89 y += LINE_H
90
91 const tint = line.kind === 'add' ? palette.ok : line.kind === 'del' ? palette.err : undefined
92 const sign = line.kind === 'add' ? '+' : line.kind === 'del' ? '−' : ''
93 const band = tint === undefined ? '' : `<rect y="${top}" width="${width}" height="${LINE_H}" fill="${tint}" fill-opacity=".1"/><rect y="${top}" width="2" height="${LINE_H}" fill="${tint}"/>`
94 const number = (value: number | undefined, x: number) =>
95 value === undefined ? '' : `<text x="${x}" y="${top + 15}" text-anchor="end" font-family="${MONO}" font-size="11" style="fill:${palette.muted}">${value}</text>`
96
97 return `<g ${riseDelay(i, 18)} class="rise">${band}${number(line.old, 44)}${number(line.new, 80)}<text x="96" y="${top + 15}" font-family="${MONO}" font-size="${CODE}" style="fill:${tint ?? palette.muted}">${sign}</text><text x="${codeX}" y="${top + 15}" font-family="${MONO}" font-size="${CODE}" style="fill:${line.kind === 'ctx' ? palette.muted : palette.fg}" xml:space="preserve">${escape(fitText(line.text, codeWidth, true, CODE))}</text></g>`
98 })
99
100 const footer = hidden > 0 ? `<text x="${codeX}" y="${y + 18}" font-size="11.5" style="fill:${palette.muted}">${hidden} more line${hidden === 1 ? '' : 's'}</text>` : ''
101 const height = y + (hidden > 0 ? 30 : 8)
102 const tag = input.isNewFile ? pill(width - 16, 12, 'new file', palette.user, palette) : ''
103 const counts = `<text x="${width - (input.isNewFile ? 100 : 16)}" y="27" text-anchor="end" font-family="${MONO}" font-size="12.5"><tspan style="fill:${palette.ok}">+${added}</tspan><tspan style="fill:${palette.muted}"> </tspan><tspan style="fill:${palette.err}">−${removed}</tspan></text>`
104 const header = [
105 strokeIcon(input.isNewFile ? NEW_FILE : PENCIL, 16, 13, 18, palette.fg),
106 `<text x="44" y="27" font-family="${MONO}" font-size="13" style="fill:${palette.fg}">${escape(fitText(shownPath, width - 220, true, 13))}</text>`,
107 counts,
108 tag,
109 `<line x1="0" y1="${HEADER_H - 0.5}" x2="${width}" y2="${HEADER_H - 0.5}" stroke="${palette.muted}" stroke-opacity=".3"/>`,
110 ].join('')
111
112 return {
113 source: svgCard(width, height, palette, '', header + rows.join('') + footer),
114 height,
115 alt: `${shownPath}: +${added} −${removed}`,
116 }
117}
118