SLOPSHOPPER

skins

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

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

Draws

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

Skins

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로 바꿔요.
  • Claude에게 "분위기에 맞는 스킨 만들어줘" 같은 말로 직접 스킨을 설계하게 할 수도 있어요 (내장 스킬 사용).

설정 (/config 또는 /plugin configure skins@k-mods)

설정기본값설명
status_linesauto스피너(작업 중 줄)와 턴 종료 줄을 누가 그릴지. auto는 k-mods의 status-ko가 켜져 있으면 그쪽에 맡기고, 아니면 이 mod가 그려요. skins는 항상 이 mod가, engine은 항상 Claude Code 기본(또는 다른 mod)이 그리게 해요.

/skin 명령으로 바꾸는 나머지 설정(스킨 선택, 레일, 표, 반짝임, 상단 띠, 출력 자르기)은 세션 안에 저장되는 값이라 /config가 아니라 /skin 또는 설정 패널에서 바꿔요.

어떻게 동작하나요

Claude Code가 화면 각 부분(도구 줄, 표, 스피너, 턴 종료 줄, 내 메시지 등)을 그릴 때마다 이 mod에 먼저 물어봐요. 이 mod는 고른 스킨의 색과 단어로 그 부분을 대신 그려서 돌려주고, 끄면 원래 Claude Code가 그리던 그대로 돌아가요. 네트워크도, 모델 호출도 쓰지 않고, 화면만 바꿔요.

권한 (이 mod가 내 컴퓨터에서 하는 일)

  • 화면에 그릴 내용만 만들어요. 파일을 읽거나 쓰지 않고, 외부로 아무것도 보내지 않아요.
  • 고른 스킨과 설정은 이 플러그인의 저장 공간($.store)에만 남아요.

한계

  • 좁은 터미널(약 60칸 이하)에서는 표·카드가 더 심하게 줄어들어요. 그래도 한 줄이 넘치진 않아요.
  • /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

Source 28 files
hooks/register.tsx 612 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3
4import type { CustomSkin, Prefs, SkinSlot, TurnStats, UsageSnap } from '../types'
5import { DEFAULT_PREFS, parsePrefs, runSkinCommand, 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}
612
hooks/command.ts 122 lines
1import type { Prefs } from '../types'
2
3export const DEFAULT_PREFS: Prefs = {
4  skin: 'noir',
5  icons: 'unicode',
6  rail: true,
7  tables: true,
8  shimmer: true,
9  band: true,
10  clipOutput: false,
11}
12
13// The on/off settings, by the word /skin and the settings pane use for each.
14export const TOGGLES = {
15  rail: 'rail',
16  tables: 'tables',
17  shimmer: 'shimmer',
18  band: 'band',
19  clip: 'clipOutput',
20} as const satisfies Record<string, keyof Prefs>
21
22export type ToggleWord = keyof typeof TOGGLES
23
24export type Outcome = {
25  prefs: Prefs
26  message: string
27  // `row` prints a transcript line (listings, mistakes); `toast` confirms a change quietly.
28  channel: 'row' | 'toast'
29}
30
31const flagOr = (value: unknown, fallback: boolean): boolean =>
32  typeof value === 'boolean' ? value : fallback
33
34// What the store hands back may be old, hand-edited or from another version.
35export function parsePrefs(raw: unknown, names: readonly string[]): Prefs {
36  const saved = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
37  const skin = saved.skin
38
39  return {
40    skin:
41      typeof skin === 'string' && (skin === 'off' || names.includes(skin))
42        ? skin
43        : DEFAULT_PREFS.skin,
44    icons: saved.icons === 'ascii' ? 'ascii' : 'unicode',
45    rail: flagOr(saved.rail, DEFAULT_PREFS.rail),
46    tables: flagOr(saved.tables, DEFAULT_PREFS.tables),
47    shimmer: flagOr(saved.shimmer, DEFAULT_PREFS.shimmer),
48    band: flagOr(saved.band, DEFAULT_PREFS.band),
49    clipOutput: flagOr(saved.clipOutput, DEFAULT_PREFS.clipOutput),
50  }
51}
52
53const changed = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'toast' })
54
55const refused = (prefs: Prefs, message: string): Outcome => ({ prefs, message, channel: 'row' })
56
57const onOff = (on: boolean): string => (on ? 'on' : 'off')
58
59export const listing = (current: Prefs, names: readonly string[]): string =>
60  [
61    ...names.map(name => `${name === current.skin ? '●' : '○'} ${name}`),
62    `${current.skin === 'off' ? '●' : '○'} off`,
63    [
64      `icons ${current.icons}`,
65      `rail ${onOff(current.rail)}`,
66      `tables ${onOff(current.tables)}`,
67      `shimmer ${onOff(current.shimmer)}`,
68      `band ${onOff(current.band)}`,
69      `clip ${onOff(current.clipOutput)}`,
70    ].join(' · '),
71  ].join('\n')
72
73const isToggle = (word: string): word is ToggleWord => Object.hasOwn(TOGGLES, word)
74
75// 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}
122
hooks/custom.ts 185 lines
1import type { CustomSkin, SkinSlot } from '../types'
2import type { Skin } from './skin'
3import { SKINS } from './themes'
4
5export const SLOTS: readonly SkinSlot[] = [
6  'user',
7  'fg',
8  'muted',
9  'surface',
10  'zebra',
11  'ok',
12  'err',
13  'warn',
14  'read',
15  'write',
16  'run',
17  'search',
18  'web',
19  'mcp',
20  'other',
21]
22
23// What each slot paints, for the settings pane and the agent's tool.
24export const SLOT_HELP: Readonly<Record<SkinSlot, string>> = {
25  user: 'accent: the rail, your prompt marker, the spinner',
26  fg: 'body text',
27  muted: 'secondary text: commands, paths, timings',
28  surface: 'band behind a table header',
29  zebra: 'every other table row; keep it a step off the background',
30  ok: 'a finished call',
31  err: 'a failed call',
32  warn: 'an interrupted call',
33  read: 'Read',
34  write: 'Edit and Write',
35  run: 'Bash and PowerShell',
36  search: 'Glob and Grep',
37  web: 'WebFetch and WebSearch',
38  mcp: 'MCP tools',
39  other: 'anything else in a group',
40}
41
42export type Customs = Readonly<Record<string, CustomSkin>>
43
44const HEX = /^#[0-9a-f]{6}$/i
45const NAME = /^[a-z0-9][a-z0-9-]{0,31}$/
46const MAX_WORDS = 12
47const MAX_WORD = 24
48
49const builtIn = (name: string): Skin | undefined => SKINS.find(skin => skin.name === name)
50
51export const skinNames = (custom: Customs): string[] => [
52  ...SKINS.map(skin => skin.name),
53  ...Object.keys(custom).filter(name => builtIn(name) === undefined),
54]
55
56// A made skin is its base with the slots and words it changed laid over it.
57export function resolveSkin(name: string, custom: Customs): Skin | undefined {
58  const own = builtIn(name)
59
60  if (own !== undefined) {
61    return own
62  }
63
64  const made = custom[name]
65  const base = made === undefined ? undefined : (builtIn(made.base) ?? SKINS[0])
66
67  if (made === undefined || base === undefined) {
68    return undefined
69  }
70
71  return {
72    name: made.name,
73    label: made.label,
74    palette: { ...base.palette, ...made.palette },
75    spinner: made.spinner.length > 0 ? made.spinner : base.spinner,
76    done: made.done.length > 0 ? made.done : base.done,
77  }
78}
79
80export type Draft = {
81  name?: unknown
82  label?: unknown
83  base?: unknown
84  palette?: unknown
85  spinner?: unknown
86  done?: unknown
87}
88
89function words(value: unknown, field: string, fallback: string[]): string[] | string {
90  if (value === undefined) {
91    return fallback
92  }
93
94  const valid =
95    Array.isArray(value) &&
96    value.length <= MAX_WORDS &&
97    value.every(word => typeof word === 'string' && word.trim() !== '' && word.length <= MAX_WORD)
98
99  return valid
100    ? (value as string[]).map(word => word.trim())
101    : `${field} must be up to ${MAX_WORDS} words of 1-${MAX_WORD} characters`
102}
103
104function palette(value: unknown, fallback: CustomSkin['palette']): CustomSkin['palette'] | string {
105  if (value === undefined) {
106    return fallback
107  }
108
109  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
110    return 'palette must be an object of slot: "#rrggbb"'
111  }
112
113  const entries = Object.entries(value as Record<string, unknown>)
114  const bad = entries.filter(
115    ([slot, hex]) => !SLOTS.includes(slot as SkinSlot) || typeof hex !== 'string' || !HEX.test(hex),
116  )
117
118  if (bad.length > 0) {
119    return `bad palette entries: ${bad.map(([slot]) => slot).join(', ')}. Slots: ${SLOTS.join(', ')}; values #rrggbb`
120  }
121
122  return { ...fallback, ...Object.fromEntries(entries.map(([slot, hex]) => [slot, (hex as string).toLowerCase()])) }
123}
124
125// Builds a made skin from what the agent or the pane sent, or says what is wrong with it.
126export function buildCustom(draft: Draft, existing: CustomSkin | undefined): CustomSkin | string {
127  const name = typeof draft.name === 'string' ? draft.name.trim().toLowerCase() : ''
128
129  if (!NAME.test(name)) {
130    return 'name must be 1-32 lowercase letters, digits or hyphens'
131  }
132
133  if (builtIn(name) !== undefined || name === 'off') {
134    return `"${name}" is taken by a built-in skin; pick another name`
135  }
136
137  const base = typeof draft.base === 'string' ? draft.base : (existing?.base ?? SKINS[0]?.name ?? '')
138
139  if (builtIn(base) === undefined) {
140    return `base must be one of ${SKINS.map(skin => skin.name).join(', ')}`
141  }
142
143  const label = typeof draft.label === 'string' && draft.label.trim() !== '' ? draft.label.trim().slice(0, 32) : (existing?.label ?? name)
144  const colours = palette(draft.palette, existing?.palette ?? {})
145  const spinner = words(draft.spinner, 'spinner', existing?.spinner ?? [])
146  const done = words(draft.done, 'done', existing?.done ?? [])
147
148  for (const result of [colours, spinner, done]) {
149    if (typeof result === 'string') {
150      return result
151    }
152  }
153
154  return {
155    name,
156    label,
157    base,
158    palette: colours as CustomSkin['palette'],
159    spinner: spinner as string[],
160    done: done as string[],
161  }
162}
163
164// One slot changed from the settings pane: a built-in skin forks into `my-<name>`.
165export function withSlot(
166  current: string,
167  custom: Customs,
168  slot: SkinSlot,
169  hex: string,
170): { name: string; skin: CustomSkin } | string {
171  if (!HEX.test(hex)) {
172    return 'use a colour like #7aa2f7'
173  }
174
175  const made = custom[current]
176  const name = made === undefined ? `my-${current}` : current
177  const base = made?.base ?? current
178  const skin = buildCustom(
179    { name, base, label: made?.label ?? `My ${current}`, palette: { [slot]: hex } },
180    custom[name],
181  )
182
183  return typeof skin === 'string' ? skin : { name, skin }
184}
185
hooks/light.ts 47 lines
1import type { Palette, Skin } from './skin'
2
3// Every skin works on a light background too. A skin may carry its own light palette;
4// the rest are derived: body text goes near-black, bands go near-white, and each colour
5// is deepened until it reads on white.
6
7const channels = (hex: string): [number, number, number] => [
8  parseInt(hex.slice(1, 3), 16),
9  parseInt(hex.slice(3, 5), 16),
10  parseInt(hex.slice(5, 7), 16),
11]
12
13const toHex = (rgb: readonly number[]): string =>
14  `#${rgb.map(value => Math.round(Math.max(0, Math.min(255, value))).toString(16).padStart(2, '0')).join('')}`
15
16// `amount` of the way from `hex` to black.
17export const deepen = (hex: string, amount: number): string =>
18  toHex(channels(hex).map(value => value * (1 - amount)))
19
20export function toLight(palette: Palette): Palette {
21  const deep = (hex: string) => deepen(hex, 0.45)
22
23  return {
24    read: deep(palette.read),
25    write: deep(palette.write),
26    run: deep(palette.run),
27    search: deep(palette.search),
28    web: deep(palette.web),
29    mcp: deep(palette.mcp),
30    other: deep(palette.other),
31    user: deep(palette.user),
32    fg: '#1f1f1f',
33    muted: '#6b6b6b',
34    surface: '#ffffff',
35    zebra: '#f2f2f2',
36    ok: deepen(palette.ok, 0.5),
37    err: deepen(palette.err, 0.25),
38    warn: deepen(palette.warn, 0.5),
39  }
40}
41
42export const forTheme = (skin: Skin, isLight: boolean): Skin =>
43  isLight ? { ...skin, palette: skin.light ?? toLight(skin.palette) } : skin
44
45// Claude Code's theme names say light or dark in them: `light`, `light-daltonized`, ...
46export const isLightTheme = (value: unknown): boolean => typeof value === 'string' && value.includes('light')
47
hooks/designer.ts 135 lines
1import type { CustomSkin, Prefs } from '../types'
2import { DEFAULT_PREFS } from './command'
3import { buildCustom, resolveSkin, SLOT_HELP, skinNames } from './custom'
4import { SKINS } from './themes'
5
6// The tool the person's own agent calls to design a skin with them.
7export const DESIGN_TOOL = {
8  name: 'design',
9  description: [
10    'Design, change and apply skins for the skins mod, which restyles this Claude Code transcript:',
11    'tool rows on a timeline rail, tables, the spinner, the turn footer. Changes show at once.',
12    'Call {"action":"show"} first: it returns the current skin, every slot with what it paints, and the names in use.',
13    '"save" makes or updates a skin: name (lowercase, hyphens), base (a built-in skin it starts from),',
14    'palette ({slot: "#rrggbb"}, only the slots to change), spinner (gerunds, like "Brewing"),',
15    'done (past tense, like "Brewed"), apply (default true).',
16    '"apply" switches to a skin by name, "delete" removes a made one,',
17    '"settings" switches rail, tables, shimmer, band, clip (booleans) and icons ("unicode" or "ascii").',
18    'Good skins keep fg and muted readable on a dark background, and surface and zebra one small step off it.',
19  ].join(' '),
20  inputSchema: {
21    type: 'object',
22    properties: {
23      action: { type: 'string', enum: ['show', 'save', 'apply', 'delete', 'settings'] },
24      name: { type: 'string' },
25      label: { type: 'string' },
26      base: { type: 'string', enum: SKINS.map(skin => skin.name) },
27      palette: { type: 'object', additionalProperties: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' } },
28      spinner: { type: 'array', items: { type: 'string' }, maxItems: 12 },
29      done: { type: 'array', items: { type: 'string' }, maxItems: 12 },
30      apply: { type: 'boolean' },
31      settings: {
32        type: 'object',
33        properties: {
34          rail: { type: 'boolean' },
35          tables: { type: 'boolean' },
36          shimmer: { type: 'boolean' },
37          band: { type: 'boolean' },
38          clip: { type: 'boolean' },
39          icons: { type: 'string', enum: ['unicode', 'ascii'] },
40        },
41      },
42    },
43    required: ['action'],
44  },
45} as const
46
47export type DesignState = { prefs: Prefs; custom: Record<string, CustomSkin> }
48
49export type DesignOutcome = { text: string; state: DesignState; isError: boolean }
50
51type Input = Record<string, unknown>
52
53const fail = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: true })
54
55const ok = (state: DesignState, text: string): DesignOutcome => ({ text, state, isError: false })
56
57function show(state: DesignState): DesignOutcome {
58  const skin = resolveSkin(state.prefs.skin, state.custom)
59
60  return ok(
61    state,
62    JSON.stringify({
63      current: state.prefs.skin,
64      palette: skin?.palette ?? null,
65      spinner: skin?.spinner ?? [],
66      done: skin?.done ?? [],
67      builtIn: SKINS.map(one => one.name),
68      made: Object.keys(state.custom),
69      slots: SLOT_HELP,
70      settings: state.prefs,
71    }),
72  )
73}
74
75function save(input: Input, state: DesignState): DesignOutcome {
76  const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
77  const skin = buildCustom(input, state.custom[name])
78
79  if (typeof skin === 'string') {
80    return fail(state, skin)
81  }
82
83  const custom = { ...state.custom, [skin.name]: skin }
84  const prefs = input.apply === false ? state.prefs : { ...state.prefs, skin: skin.name }
85
86  return ok({ prefs, custom }, `saved ${skin.name}${input.apply === false ? '' : ' and applied it'}`)
87}
88
89function settings(input: Input, state: DesignState): DesignOutcome {
90  const asked = (typeof input.settings === 'object' && input.settings !== null ? input.settings : {}) as Input
91  const flag = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
92  const icons = asked.icons === 'ascii' || asked.icons === 'unicode' ? asked.icons : state.prefs.icons
93  const prefs: Prefs = {
94    ...state.prefs,
95    rail: flag(asked.rail, state.prefs.rail),
96    tables: flag(asked.tables, state.prefs.tables),
97    shimmer: flag(asked.shimmer, state.prefs.shimmer),
98    band: flag(asked.band, state.prefs.band),
99    clipOutput: flag(asked.clip, state.prefs.clipOutput),
100    icons,
101  }
102
103  return ok({ ...state, prefs }, `settings: ${JSON.stringify(prefs)}`)
104}
105
106export function runDesign(raw: unknown, state: DesignState): DesignOutcome {
107  const input = (typeof raw === 'object' && raw !== null ? raw : {}) as Input
108  const name = typeof input.name === 'string' ? input.name.trim().toLowerCase() : ''
109
110  switch (input.action) {
111    case 'show':
112      return show(state)
113    case 'save':
114      return save(input, state)
115    case 'apply':
116      return skinNames(state.custom).includes(name)
117        ? ok({ ...state, prefs: { ...state.prefs, skin: name } }, `applied ${name}`)
118        : fail(state, `no skin named "${name}"; show lists them`)
119    case 'delete': {
120      if (state.custom[name] === undefined) {
121        return fail(state, `"${name}" is not a made skin; built-in skins cannot be deleted`)
122      }
123
124      const custom = Object.fromEntries(Object.entries(state.custom).filter(([key]) => key !== name))
125      const prefs = state.prefs.skin === name ? { ...state.prefs, skin: DEFAULT_PREFS.skin } : state.prefs
126
127      return ok({ prefs, custom }, `deleted ${name}`)
128    }
129    case 'settings':
130      return settings(input, state)
131    default:
132      return fail(state, 'action must be show, save, apply, delete or settings')
133  }
134}
135
hooks/format.ts 85 lines
1// Plain string helpers behind the row builders.
2
3export const oneLine = (text: string): string =>
4  (text.split('\n', 1)[0] ?? '').replace(/\s+/g, ' ').trim()
5
6const slashed = (path: string): string => path.replace(/\\/g, '/')
7
8// A path under the session's directory shows relative to it; any other stays as given.
9export function shortenPath(path: string, cwd: string): string {
10  const full = slashed(path)
11  const root = slashed(cwd).replace(/\/+$/, '')
12
13  if (root === '' || !full.toLowerCase().startsWith(`${root.toLowerCase()}/`)) {
14    return path
15  }
16
17  return full.slice(root.length + 1)
18}
19
20export function formatDuration(ms: number): string {
21  const seconds = Math.round(ms / 1000)
22
23  if (seconds < 1) {
24    return '<1s'
25  }
26
27  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`
28}
29
30// A tool call's own time: `340ms`, `2.1s`, `1m 4s`.
31export function formatMs(ms: number): string {
32  if (ms < 1000) {
33    return `${Math.round(ms)}ms`
34  }
35
36  return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms)
37}
38
39// Lines added and removed, read from an Edit or Write record's patch.
40export function diffstat(output: unknown): { added: number; removed: number } | null {
41  const patch = (output as { structuredPatch?: unknown } | null)?.structuredPatch
42
43  if (!Array.isArray(patch)) {
44    return null
45  }
46
47  const lines = patch.flatMap(hunk => {
48    const hunkLines = (hunk as { lines?: unknown } | null)?.lines
49
50    return Array.isArray(hunkLines) ? hunkLines.filter(line => typeof line === 'string') : []
51  }) as string[]
52
53  return {
54    added: lines.filter(line => line.startsWith('+')).length,
55    removed: lines.filter(line => line.startsWith('-')).length,
56  }
57}
58
59// The same seed always picks the same item, so a row keeps its word when it redraws.
60export function pick<T>(items: readonly T[], seed: string): T | undefined {
61  let hash = 5381
62
63  for (let i = 0; i < seed.length; i++) {
64    hash = (hash * 33 + seed.charCodeAt(i)) >>> 0
65  }
66
67  return items[hash % items.length]
68}
69
70// Keeps the first `head` and last `tail` lines of a long text and names how many it hid.
71export function clipLines(text: string, head: number, tail: number): string {
72  const lines = text.split('\n')
73  const hidden = lines.length - head - tail
74
75  if (hidden <= 1) {
76    return text
77  }
78
79  return [
80    ...lines.slice(0, head),
81    `… ${hidden} lines hidden`,
82    ...lines.slice(lines.length - tail),
83  ].join('\n')
84}
85
hooks/markdown.ts 187 lines
1// Finds the tables and fenced code in a reply so they can be drawn as cards; the rest
2// stays markdown.
3
4export type Align = 'left' | 'right' | 'center'
5
6export type Table = { kind: 'table'; header: string[]; align: Align[]; rows: string[][] }
7
8// `raw` is the fence as written, for the surfaces that keep Claude Code's own drawing.
9export type Code = { kind: 'code'; lang: string; code: string; raw: string }
10
11export type Segment = { kind: 'text'; text: string } | Table | Code
12
13const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
14const FENCE = /^\s*(```|~~~)\s*([\w+#.-]*)/
15
16// Inline emphasis and code ticks read as noise in a padded cell.
17const clean = (cell: string): string =>
18  cell.replace(/\*\*|__|`/g, '').replace(/\\\|/g, '|').trim()
19
20export function cellsOf(line: string): string[] {
21  const inner = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
22
23  return inner.split(/(?<!\\)\|/).map(clean)
24}
25
26const alignOf = (cell: string): Align => {
27  const spec = cell.trim()
28
29  if (spec.startsWith(':') && spec.endsWith(':')) {
30    return 'center'
31  }
32
33  return spec.endsWith(':') ? 'right' : 'left'
34}
35
36const fit = (cells: string[], width: number): string[] =>
37  Array.from({ length: width }, (_, i) => cells[i] ?? '')
38
39export function splitReply(markdown: string): Segment[] {
40  const lines = markdown.split('\n')
41  const segments: Segment[] = []
42  let text: string[] = []
43  let inFence = false
44
45  const flush = () => {
46    if (text.join('\n').trim() !== '') {
47      segments.push({ kind: 'text', text: text.join('\n') })
48    }
49
50    text = []
51  }
52
53  for (let i = 0; i < lines.length; i++) {
54    const line = lines[i] ?? ''
55    const next = lines[i + 1] ?? ''
56
57    const fence = FENCE.exec(line)
58
59    // A closed fence becomes a code segment; one still streaming stays text.
60    if (fence !== null && !inFence) {
61      const close = lines.findIndex((other, j) => j > i && FENCE.test(other) && other.trim().replace(/[`~]/g, '') === '')
62
63      if (close !== -1) {
64        flush()
65        segments.push({
66          kind: 'code',
67          lang: (fence[2] ?? '').toLowerCase(),
68          code: lines.slice(i + 1, close).join('\n'),
69          raw: lines.slice(i, close + 1).join('\n'),
70        })
71        i = close
72        continue
73      }
74
75      inFence = true
76    } else if (fence !== null) {
77      inFence = false
78    }
79
80    const header = cellsOf(line)
81    const isTable =
82      !inFence &&
83      line.includes('|') &&
84      SEPARATOR.test(next) &&
85      header.length > 1 &&
86      cellsOf(next).length === header.length
87
88    if (!isTable) {
89      text.push(line)
90      continue
91    }
92
93    flush()
94
95    const rows: string[][] = []
96    i += 2
97
98    while (i < lines.length && (lines[i] ?? '').includes('|') && (lines[i] ?? '').trim() !== '') {
99      rows.push(fit(cellsOf(lines[i] ?? ''), header.length))
100      i++
101    }
102
103    i--
104    segments.push({ kind: 'table', header, align: cellsOf(next).map(alignOf), rows })
105  }
106
107  flush()
108
109  return segments
110}
111
112// 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}
187
hooks/rows.tsx 618 lines
1import type { ElementTable, RenderSurface } from 'claude-code'
2
3import type { Prefs, TurnStats } from '../types'
4import { formatDuration, formatMs } from './format'
5import { columnWidths, cutCell } from './markdown'
6import type { Segment, Table } from './markdown'
7import type { Icons, Kind, Skin } from './skin'
8import { spinnerIcon, toolIcon } from './icons'
9import type { SpinnerMode } from './icons'
10import { codeSvg } from './svg-code'
11import { diffSvg } from './svg-diff'
12import type { DiffInput } from './svg-diff'
13import { cardWidth } from './svg-kit'
14import { tableSvg } from './svg-table'
15import { terminalSvg } from './svg-terminal'
16import type { ShellOutput } from './svg-terminal'
17import { usageLine, usageSvg } from './svg-usage'
18import type { Meter } from './svg-usage'
19import { kindOf, toolLabel } from './tools'
20
21export type Ui = Pick<ElementTable, 'Box' | 'Text' | 'Markdown' | 'Button'>
22
23// The vector element, on the surfaces that have one (the desktop app).
24export type SvgElement = ElementTable<'desktop'>['Svg']
25
26export type Look = {
27  ui: Ui
28  skin: Skin
29  icons: Icons
30  prefs: Prefs
31  surface: RenderSurface
32  // The vector element where the surface draws one: icons replace glyphs there.
33  svg?: SvgElement
34  // Puts text on the clipboard of the surface drawing; absent where nothing can copy.
35  copy?: (text: string) => void
36}
37
38export type Call = {
39  tool: string
40  input: unknown
41  isRunning: boolean
42  isErrored: boolean
43  isInterrupted: boolean
44}
45
46// What a tool row shows at its right edge.
47export type Meta = { ms?: number; added?: number; removed?: number }
48
49const KIND_LABEL: Readonly<Record<Kind, string>> = {
50  read: 'Read',
51  write: 'Edit',
52  run: 'Run',
53  search: 'Search',
54  web: 'Web',
55  mcp: 'MCP',
56  other: 'Other',
57}
58
59const CELL_PAD = 1
60
61// The worst thing that happened to any of the calls, else how far along they are.
62function status({ skin, icons }: Look, calls: readonly Call[]) {
63  if (calls.some(call => call.isInterrupted)) {
64    return { glyph: icons.interrupted, color: skin.palette.warn }
65  }
66
67  if (calls.some(call => call.isErrored)) {
68    return { glyph: icons.err, color: skin.palette.err }
69  }
70
71  if (calls.some(call => call.isRunning)) {
72    return { glyph: icons.running, color: skin.palette.muted }
73  }
74
75  return { glyph: icons.ok, color: skin.palette.ok }
76}
77
78// The connector above a node, which ties a turn's calls into one line down the left.
79function railLine(look: Look) {
80  const { Text } = look.ui
81
82  return <Text color={look.skin.palette.user}>{look.icons.rail}</Text>
83}
84
85function node(look: Look, calls: readonly Call[]) {
86  const { Text } = look.ui
87  const { glyph, color } = status(look, calls)
88  const branch = look.prefs.rail && look.surface === 'terminal' ? look.icons.branch : ''
89
90  return (
91    <Text color={color}>
92      {`${glyph}`}
93      <Text color={look.skin.palette.muted}>{`${branch} `}</Text>
94    </Text>
95  )
96}
97
98function metaSpans(look: Look, meta: Meta) {
99  const { Text } = look.ui
100  const { palette } = look.skin
101  const spans = []
102
103  if (meta.added !== undefined && meta.removed !== undefined && meta.added + meta.removed > 0) {
104    spans.push(<Text color={palette.ok}>{`+${meta.added}`}</Text>)
105    spans.push(<Text color={palette.err}>{` −${meta.removed}`}</Text>)
106  }
107
108  if (meta.ms !== undefined) {
109    spans.push(<Text color={palette.muted}>{`${spans.length > 0 ? '  ' : ''}${formatMs(meta.ms)}`}</Text>)
110  }
111
112  return spans
113}
114
115// A line with its facts flush right on the terminal and trailing it on the desktop,
116// which lays a row out its own way.
117function withMeta(look: Look, main: ReturnType<Ui['Text']>, meta: Meta) {
118  const { Box, Text } = look.ui
119  const spans = metaSpans(look, meta)
120
121  if (spans.length === 0) {
122    return main
123  }
124
125  if (look.surface !== 'terminal') {
126    return (
127      <Text wrap="truncate-end">
128        {main}
129        {'   '}
130        {spans}
131      </Text>
132    )
133  }
134
135  return (
136    <Box flexDirection="row">
137      <Box flexGrow={1} flexShrink={1}>
138        {main}
139      </Box>
140      <Box flexShrink={0} paddingLeft={2}>
141        <Text>{spans}</Text>
142      </Box>
143    </Box>
144  )
145}
146
147function stack(look: Look, line: ReturnType<Ui['Text']>) {
148  const { Box } = look.ui
149
150  // Box-drawing rails need a monospace font, which only the terminal draws mod text in.
151  return look.prefs.rail && look.surface === 'terminal' ? (
152    <Box flexDirection="column">
153      {railLine(look)}
154      {line}
155    </Box>
156  ) : (
157    line
158  )
159}
160
161// On a surface with vector icons, the icon leads the row in place of the status glyph.
162function iconRow(look: Look, Svg: SvgElement, kind: Kind, calls: readonly Call[], line: ReturnType<Ui['Text']>) {
163  const { Box } = look.ui
164  const { color } = status(look, calls)
165  const source = toolIcon(kind, color, calls.some(call => call.isRunning))
166
167  return (
168    <Box flexDirection="row" columnGap={1} alignItems="center">
169      <Svg source={source} alt={kind} width={16} height={16} />
170      {line}
171    </Box>
172  )
173}
174
175export function toolRow(look: Look, call: Call, kind: Kind, target: string, meta: Meta) {
176  const { Text } = look.ui
177  const { palette } = look.skin
178
179  if (look.svg !== undefined) {
180    const line = (
181      <Text wrap="truncate-end">
182        <Text color={palette[kind]} bold>
183          {toolLabel(call.tool)}
184        </Text>
185        <Text color={call.isErrored ? palette.err : palette.muted}>{`  ${target}`}</Text>
186      </Text>
187    )
188
189    return iconRow(look, look.svg, kind, [call], withMeta(look, line, meta))
190  }
191
192  const main = (
193    <Text wrap="truncate-end">
194      {node(look, [call])}
195      <Text color={palette[kind]}>{toolLabel(call.tool)}</Text>
196      <Text color={call.isErrored ? palette.err : palette.muted}>{`  ${target}`}</Text>
197    </Text>
198  )
199
200  return stack(look, withMeta(look, main, meta))
201}
202
203// A run of reads and searches on one node: `●─ Read 3 · Search 2`.
204export function groupRow(look: Look, calls: readonly Call[]) {
205  const { Text } = look.ui
206  const { palette } = look.skin
207  const counts = new Map<Kind, number>()
208
209  for (const call of calls) {
210    const kind = kindOf(call.tool) ?? 'other'
211    counts.set(kind, (counts.get(kind) ?? 0) + 1)
212  }
213
214  const parts = [...counts].flatMap(([kind, count], i) => [
215    ...(i === 0 ? [] : [<Text color={palette.muted}>{' · '}</Text>]),
216    <Text color={palette[kind]}>{KIND_LABEL[kind]}</Text>,
217    <Text color={palette.muted}>{` ${count}`}</Text>,
218  ])
219
220  if (look.svg !== undefined) {
221    const lead = [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'other'
222
223    return iconRow(look, look.svg, lead, calls, <Text wrap="truncate-end">{parts}</Text>)
224  }
225
226  return stack(
227    look,
228    <Text wrap="truncate-end">
229      {node(look, calls)}
230      {parts}
231    </Text>,
232  )
233}
234
235// The desktop's spinner row: an animated icon for what the turn is doing, beside the
236// step the desktop names (`Creating notes.md`).
237export function desktopSpinnerRow(look: Look, Svg: SvgElement, mode: SpinnerMode, text: string) {
238  const { Box, Text } = look.ui
239
240  return (
241    <Box flexDirection="row" columnGap={1} alignItems="center">
242      <Svg source={spinnerIcon(mode, look.skin.palette.user)} alt={mode} width={20} height={20} />
243      <Text color={look.skin.palette.muted}>{text}</Text>
244    </Box>
245  )
246}
247
248// Your message in 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}
618
hooks/gallery.tsx 104 lines
1import type { RenderElement } from 'claude-code'
2
3import type { Table } from './markdown'
4import {
5  askBand,
6  codeCard,
7  desktopSpinnerRow,
8  diffCard,
9  footerRow,
10  groupRow,
11  promptRow,
12  replyRows,
13  spinnerRow,
14  terminalCard,
15  toolRow,
16  usageBand,
17} from './rows'
18import type { Look } from './rows'
19
20// Every element the skin draws, numbered, from the same builders the transcript uses, so
21// a note on #4 is a note on the real thing.
22
23const done = { isRunning: false, isErrored: false, isInterrupted: false }
24
25const TABLE: Table = {
26  kind: 'table',
27  header: ['Skin', 'Accent', 'Time'],
28  align: ['left', 'left', 'right'],
29  rows: [
30    ['noir', '#f5f5f5', '41s'],
31    ['tokyo-night', '#7aa2f7', '2.1s'],
32    ['dracula', '#bd93f9', '340ms'],
33  ],
34}
35
36const CODE = ['// Rate limit per route, keyed by user', 'export function limit(route: string, perMinute = 60) {', '  return (used: number) => used < perMinute', '}'].join('\n')
37
38const DIFF = {
39  path: '/work/apps/hub/src/server.ts',
40  isNewFile: false,
41  hunks: [{ oldStart: 12, newStart: 12, lines: [' const app = fastify()', '-app.listen(7447)', '+app.register(rateLimit, { max: 60 })', '+app.listen(7447)'] }],
42}
43
44export function galleryPane(look: Look, columns: number) {
45  const { Box, Text } = look.ui
46  const { palette } = look.skin
47  const svg = look.svg
48  let number = 0
49  const section = (title: string, ...items: RenderElement[]) => {
50    number += 1
51
52    return (
53      <Box flexDirection="column" marginBottom={1}>
54        <Text color={palette.muted} bold>{`${number}  ${title}`}</Text>
55        {items}
56      </Box>
57    )
58  }
59  const width = Math.max(40, columns - 4)
60
61  return (
62    <Box flexDirection="column">
63      {section('Your prompt', promptRow(look, 'add rate limiting to the hub api'))}
64      {section(
65        'Tool rows: finished, running, failed',
66        toolRow(look, { tool: 'Bash', input: {}, ...done }, 'run', 'pnpm test --filter hub', { ms: 2140 }),
67        toolRow(look, { tool: 'Read', input: {}, ...done, isRunning: true }, 'read', 'docs/protocols.md', {}),
68        toolRow(look, { tool: 'Edit', input: {}, ...done, isErrored: true }, 'write', 'apps/hub/src/server.ts', {}),
69      )}
70      {section('Folded group of reads and searches', groupRow(look, [{ tool: 'Read', input: {}, ...done }, { tool: 'Read', input: {}, ...done }, { tool: 'Grep', input: {}, ...done }]))}
71      {svg === undefined
72        ? section('Edit diff card', <Text color={palette.muted}>Desktop only; the terminal keeps Claude Code's diff.</Text>)
73        : section('Edit diff card', diffCard(look, svg, DIFF, 'apps/hub/src/server.ts', width))}
74      {svg === undefined
75        ? section('Shell output card', <Text color={palette.muted}>Desktop only.</Text>)
76        : section(
77            'Shell output card: ok, failed',
78            terminalCard(look, svg, { stdout: 'Test Files  12 passed (12)\n     Tests  148 passed (148)', stderr: '', interrupted: false }, false, width),
79            terminalCard(look, svg, { stdout: '', stderr: 'error TS2322: Type string is not assignable to number', interrupted: false }, true, width),
80          )}
81      {svg === undefined
82        ? section('Code block card', <Text color={palette.muted}>Desktop only; the terminal keeps Claude Code's markdown.</Text>)
83        : section('Code block card', codeCard(look, 'ts', CODE, svg, width))}
84      {section('Table', replyRows(look, [TABLE], width, svg))}
85      {svg === undefined
86        ? section('Spinner', spinnerRow(look, look.skin.spinner[0] ?? 'Working', 3, 12_000))
87        : section(
88            'Spinner: thinking, tool running, writing, waiting',
89            desktopSpinnerRow(look, svg, 'thinking', 'Thinking'),
90            desktopSpinnerRow(look, svg, 'tool-use', 'Running pnpm test'),
91            desktopSpinnerRow(look, svg, 'responding', 'Writing the reply'),
92            desktopSpinnerRow(look, svg, 'requesting', 'Waiting for the model'),
93          )}
94      {section(
95        'Band above the prompt: normal, then nudging to compact',
96        usageBand(look, [{ label: 'context', percent: 42 }, { label: '5h', percent: 18 }], true, () => undefined),
97        usageBand(look, [{ label: 'context', percent: 85 }, { label: '5h', percent: 61 }], true, () => undefined),
98      )}
99      {section('Band above a question', askBand(look, ['Approach', 'Store']))}
100      {section('Turn footer (terminal)', footerRow(look, look.skin.done[0] ?? 'Done', 41_000, { tools: 6, added: 18, removed: 3 }))}
101    </Box>
102  )
103}
104
hooks/settings.tsx 145 lines
1import type { ElementTable } from 'claude-code'
2
3import type { SkinSlot } from '../types'
4import type { ToggleWord } from './command'
5import { SLOTS } from './custom'
6import type { Table } from './markdown'
7import { footerRow, groupRow, promptRow, tableRows, toolRow } from './rows'
8import type { Look } from './rows'
9
10export type PaneUi = Pick<ElementTable<'terminal' | 'desktop'>, 'Box' | 'Text' | 'Markdown' | 'Button' | 'Input' | 'Select'>
11
12export type SettingsModel = {
13  names: readonly string[]
14  editing: SkinSlot
15  width: number
16}
17
18export type SettingsActions = {
19  pick: (name: string) => void
20  toggle: (word: ToggleWord) => void
21  icons: () => void
22  edit: (slot: SkinSlot) => void
23  paint: (hex: string) => void
24}
25
26const SAMPLE: Table = {
27  kind: 'table',
28  header: ['Route', 'Limit', 'Window'],
29  align: ['left', 'right', 'left'],
30  rows: [
31    ['/chat', '60', '1 min'],
32    ['/upload', '10', '1 min'],
33    ['/search', '120', '1 min'],
34  ],
35}
36
37const done = { isRunning: false, isErrored: false, isInterrupted: false }
38
39// A turn as the skin would draw it, built from the same rows the transcript uses.
40function preview(look: Look, width: number) {
41  const { Box } = look.ui
42  const word = look.skin.done[0] ?? 'Done'
43
44  return (
45    <Box flexDirection="column" borderStyle="round" borderColor={look.skin.palette.muted} paddingX={1}>
46      {promptRow(look, 'add rate limiting to the hub api')}
47      {toolRow(look, { tool: 'Bash', input: {}, ...done }, 'run', 'pnpm test --filter hub', { ms: 2140 })}
48      {toolRow(look, { tool: 'Edit', input: {}, ...done }, 'write', 'apps/hub/src/server.ts', {
49        ms: 380,
50        added: 18,
51        removed: 3,
52      })}
53      {groupRow(look, [
54        { tool: 'Read', input: {}, ...done },
55        { tool: 'Read', input: {}, ...done },
56        { tool: 'Grep', input: {}, ...done },
57      ])}
58      {tableRows(look, SAMPLE, Math.max(20, width - 4))}
59      {footerRow(look, word, 41_000, { tools: 6, added: 18, removed: 3 })}
60    </Box>
61  )
62}
63
64const mark = (on: boolean): string => (on ? '●' : '○')
65
66export function settingsPane(look: Look, ui: PaneUi, model: SettingsModel, actions: SettingsActions) {
67  const { Box, Text, Button, Input, Select } = ui
68  const { palette } = look.skin
69  const { prefs } = look
70  const toggle = (word: ToggleWord, hotkey: string, on: boolean) => (
71    <Button
72      key={`toggle-${word}`}
73      label={`${word} ${mark(on)}`}
74      hotkey={hotkey}
75      plain
76      dimColor={!on}
77      onPress={() => actions.toggle(word)}
78    />
79  )
80
81  return (
82    <Box flexDirection="column" rowGap={1}>
83      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
84        <Text color={palette.muted}>Skin</Text>
85        {model.names.map((name, i) => (
86          <Button
87            key={`skin-${name}`}
88            label={name === prefs.skin ? `${name} ●` : name}
89            plain
90            dimColor={name !== prefs.skin}
91            onPress={() => actions.pick(name)}
92            {...(i < 9 ? { hotkey: String(i + 1) } : {})}
93          />
94        ))}
95      </Box>
96
97      {preview(look, model.width)}
98
99      <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
100        <Text color={palette.muted}>Look</Text>
101        {toggle('rail', 'r', prefs.rail)}
102        {toggle('tables', 't', prefs.tables)}
103        {toggle('shimmer', 's', prefs.shimmer)}
104        {toggle('band', 'b', prefs.band)}
105        {toggle('clip', 'c', prefs.clipOutput)}
106        <Button key="icons" label={`icons ${prefs.icons}`} hotkey="i" plain onPress={() => actions.icons()} />
107      </Box>
108
109      <Box flexDirection="column">
110        <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
111          {SLOTS.map(slot => (
112            <Text>
113              <Text backgroundColor={palette[slot]}>{'  '}</Text>
114              <Text color={slot === model.editing ? palette.fg : palette.muted} bold={slot === model.editing}>
115                {` ${slot}`}
116              </Text>
117            </Text>
118          ))}
119        </Box>
120        <Box flexDirection="row" columnGap={2} marginTop={1}>
121          <Select
122            key="slot"
123            label="Colour"
124            value={model.editing}
125            options={SLOTS.map(slot => ({ value: slot, label: slot }))}
126            onSelect={(value: string) => actions.edit(value as SkinSlot)}
127          />
128          <Input
129            key="hex"
130            label={palette[model.editing]}
131            placeholder="#rrggbb"
132            value=""
133            submitLabel="paint"
134            onSubmit={(value: string) => actions.paint(value.trim())}
135          />
136        </Box>
137      </Box>
138
139      <Text color={palette.muted}>
140        Or ask Claude: <Text color={palette.fg}>“make me a skin that feels like a sunset”</Text>. Esc closes.
141      </Text>
142    </Box>
143  )
144}
145
hooks/skin.ts 57 lines
1import type { SkinSlot } from '../types'
2
3// Shapes shared by the skins and the row builders.
4
5// What a tool call is, by colour. `other` only shows in group rows.
6export type Kind = 'read' | 'write' | 'run' | 'search' | 'web' | 'mcp' | 'other'
7
8// The kinds a skin draws a tool row for; the rest keep Claude Code's own row.
9export type ToolKind = Exclude<Kind, 'other'>
10
11export type Slot = SkinSlot
12
13export type Palette = Readonly<Record<Slot, string>>
14
15export type Skin = {
16  readonly name: string
17  readonly label: string
18  readonly palette: Palette
19  // Gerunds for the spinner, and past-tense words for the turn footer.
20  readonly spinner: readonly string[]
21  readonly done: readonly string[]
22  // Its own palette for a light background; without one, light.ts derives it.
23  readonly light?: Palette
24}
25
26export type IconSet = 'unicode' | 'ascii'
27
28export type Icons = Readonly<
29  Record<'running' | 'ok' | 'err' | 'interrupted' | 'done' | 'rail' | 'branch' | 'prompt', string>
30> & { readonly frames: readonly string[] }
31
32// Single-cell glyphs with no emoji presentation; each status has its own shape, not only a colour.
33export const ICONS: Readonly<Record<IconSet, Icons>> = {
34  unicode: {
35    running: '○',
36    ok: '●',
37    err: '✕',
38    interrupted: '◌',
39    done: '◆',
40    rail: '┃',
41    branch: '─',
42    prompt: '▍',
43    frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
44  },
45  ascii: {
46    running: 'o',
47    ok: '*',
48    err: 'x',
49    interrupted: '-',
50    done: '>',
51    rail: '|',
52    branch: '-',
53    prompt: '>',
54    frames: ['|', '/', '-', '\\'],
55  },
56}
57
hooks/svg-diff.ts 118 lines
1import 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