SLOPSHOPPER

crush-style

Crush CLI look for Claude Code: Charm palette, bordered tool rows, gradient spinner, session sidebar

newpanebandspinnerrowsguard
v0.1.0MITupdated 2026-10-03ccckfg/claude-crush/crush-style
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crush-style
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /crush ⎿ crush-style: Crush style off, here and in every new session. /crush on brings it back. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

crush-style · 开发说明

怎么安装、怎么用,见仓库根目录的 README。这里写给想读或改代码的人。

这是一个 Claude Code 的 mod:用函数 hook 写成的插件,在 Claude Code 2.1.287 上开发。mod 的 API 仍是早期版本,以本机 Claude Code 写出的类型声明为准(见下文"类型检查")。

每个界面元素由哪个 hook 画

Crush 的元素这里怎么做
用户消息的紫色竖条UserMessage:左侧 ▌(Charple),高度按换行后的行数算,中文按双宽处理。只改用户亲手输入的消息,任务通知等仍由引擎画
助手消息AssistantMessage:Markdown 缩进两列,去掉回复开头的圆点。超过 Markdown 元素上限(1 万字符)时交还引擎
回复末尾的 ◇ 模型 耗时TurnDuration
工具调用行ToolUse:状态图标(运行中是旋转的渐变点、✓、×、⊘)+ 工具名 + 参数;Edit/Write 行尾显示 +3 -1,被中断的显示 interrupted
工具名Read→View、WebFetch→Fetch、WebSearch→Search、Task→Agent、TodoWrite→To-Do;MCP 工具显示为 工具名 服务器 · 参数
Bash 输出块ToolResult:带 │ 边栏,最多 10 行,其余折叠成 … N more lines;出错时整块红色。其他工具的结果仍由引擎画
一次读多个文件时逐个列出ToolGroup:把 isExpanded 改成 true,每个调用变成独立的 ToolUse 行
渐变闪动的 "Thinking…"Spinner:单词上有一道流动的紫粉渐变,带已用时间
╱╱╱╱ 斜线分隔侧边栏标题和状态带两端
圆角边框的命令输出CommandOutput
信息栏Pane:会话标题、目录、分支、模型、上下文进度条、token、花费、5h/7d 额度、改动文件(+/-)、To-Do、工具调用统计
输入框上方的状态行AbovePrompt:模型、上下文、花费、To-Do 进度、分支、目录
输入时的高亮prompt.edit:开头的 /命令、!、@文件 上色

所有绘制都只在 terminal 表面生效,桌面端、VS Code、手机端一律交还引擎。

状态放在哪

  • $.state(会话内,热重载后保留):动画帧、本轮开始时间、改动文件、工具计数、To-Do、标题、分支、模型、用量。绘制时读取会自动订阅,写入时只重画读过它的那几行。
  • $.store(跨会话):总开关 enabled,以及用户手动关掉侧边栏的标记 sidebarDismissed。
  • 模块变量(重载后清零):home 目录、cwd、模型 id、动画计时器。

总开关放在 $.store 而不是 userConfig,原因是实测在 claude -p 会话里,$.config.list() 不列出任何插件配置行,$.config.set('<plugin>.<field>') 会直接报错。关闭时所有 hook 仍然注册、只是原样放行,并调用 $.ui.invalidate('ui.render') 让已画出的行重画;改动文件等统计照常记录。

动画计时器(120 ms 一帧)只在一轮对话进行中运行,turn.complete 时停止;热重载发生在一轮中间时,会从 $.state 里的开始时间恢复。

验证器的两条规矩

写 hook 时 claude plugin validate 会静态扫描源码,有两条规矩类型声明里看不出来:

  1. 接收 $ 的辅助函数必须是本文件顶层的函数声明(或绑定到函数的 const),不能写在 register 里面,也不能从别的文件导入。
  2. read($, x) / update($, x, fn) 的 x 必须是本文件里用 atom({ plugin, key } as const, 初始值) 声明的,不能从别的模块导入。

所以所有 hook、atom 和接收 $ 的函数都在 hooks/register.tsx;其他文件只放纯函数和返回元素树的绘制函数(它们接收 $.ui.resolve(e) 返回的元素表,不接收 $)。

文件

.claude-plugin/plugin.json   清单和配置项(userConfig)
hooks/hooks.json             指向 register.tsx
hooks/register.tsx           所有 hook、atom、命令
hooks/theme.tsx              配色、渐变、显示宽度、格式化
hooks/messages.tsx           用户/助手消息、Spinner、页脚、命令框
hooks/tools.tsx              工具行、输出块、各工具的名字和参数
hooks/sidebar.tsx            侧边栏和状态带
types/index.d.ts             $.state 的类型契约
tests/crush-style.test.tsx   界面、工具、侧边栏、生命周期
tests/switch.test.tsx        总开关
tsconfig.json                继承引擎生成的配置

检查和测试

在仓库根目录运行:

claude plugin validate crush-style    # 引擎会怎么读这个 mod,会拒绝什么
claude plugin test crush-style        # 40 个测试

测试用 Claude Code 自带的测试工具(claude-code/testing):在终端、桌面等表面上挂载组件,检查 hook 返回的元素树能通过校验、内容正确。它不画像素,所以颜色和对齐只能在真实终端里看。

类型检查

mod 被 Claude Code 加载过一次后,引擎会在 .claude-plugin/types/ 下写出本机版本的类型声明(这个目录不进 git)。之后:

npx -p typescript tsc -p crush-style

在 Windows 的 Git Bash 里用 claude -p "/crush on" 这类命令做试验时要小心:Git Bash 会把以 / 开头的参数改写成 Windows 路径。请改用 PowerShell。

已知问题

  • 状态行本该在侧边栏停靠时让位:代码用 e.viewport.columns > e.props.bodyColumns 判断是否停靠。但真实终端里两者同时显示,说明这个条件在真实终端里不成立,测试里是按假设的数值通过的。修好之前,README 按实际表现写。

没在真实终端里验证的

  • 用户消息的竖条高度和换行是否对齐(中文、长行、窄窗口)
  • 侧边栏在全屏布局下的宽度和滚动
  • 关闭/打开总开关时,历史消息是否立刻重画
  • Spinner 的渐变在不同终端里的真彩色效果

README 里的插图

docs/*.svg 由 python docs/draw.py 生成,颜色和布局照搬 mod 的代码。界面改了就改脚本、重新生成,不要手改 SVG。

改配色

所有颜色都在 hooks/theme.tsx 顶部的 C 对象里,用的是 Charm 的调色板(Charple、Dolly、Bok 等)。正文不设颜色,跟随终端自己的前景色,所以亮色主题下也能读。

Source 6 files
hooks/register.tsx 538 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptDecoration, Register, Timer } from 'claude-code'
3
4import type { FileChange, TodoItem } from '../types'
5import { assistantRow, commandBox, footerRow, noticeRow, spinnerRow, spinnerWord, userRow } from './messages'
6import { bandRow, sidebar } from './sidebar'
7import type { Facts } from './sidebar'
8import { C, clean, clip } from './theme'
9import { changeStats, describeTool, diffTail, interruptedTail, outputBody, rec, str, toolHeader } from './tools'
10import type { Status } from './tools'
11
12const SIDEBAR = 'crush-sidebar'
13const DISMISSED = 'sidebarDismissed'
14// `$.store` key of the master switch: kept per plugin across every session.
15const SWITCH = 'enabled'
16const MAX_TEXT = 9900
17
18const CHANGING = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
19
20// Every value the drawings read lives in `$.state`, so a hot reload keeps it
21// and a write redraws exactly the rows that read it. They are declared here,
22// in the file that reads and writes them, which is where the scan looks.
23const frameAtom = atom({ plugin: 'crush-style', key: 'frame' } as const, 0)
24const turnStartAtom = atom({ plugin: 'crush-style', key: 'turnStartedAt' } as const, 0)
25const filesAtom = atom({ plugin: 'crush-style', key: 'files' } as const, [])
26const toolsAtom = atom({ plugin: 'crush-style', key: 'tools' } as const, {})
27const todosAtom = atom({ plugin: 'crush-style', key: 'todos' } as const, [])
28const titleAtom = atom({ plugin: 'crush-style', key: 'title' } as const, '')
29const branchAtom = atom({ plugin: 'crush-style', key: 'branch' } as const, '')
30const modelAtom = atom({ plugin: 'crush-style', key: 'model' } as const, '')
31const usageAtom = atom({ plugin: 'crush-style', key: 'usage' } as const, null)
32
33// Plain values the drawings read without subscribing: they change rarely and
34// nothing needs to redraw when they do. A reload starts them over.
35let home = ''
36let cwd = ''
37let modelId = ''
38let ticker: Timer | undefined
39let isTurnActive = false
40let hasTriedOpen = false
41let isSidebarAuto = true
42// The master switch, as `$.store` holds it; every drawing reads it.
43let isOn = true
44
45/** Colors a leading /command, a leading `!` and @file mentions in the draft. */
46const decorate = (text: string): PromptDecoration[] => {
47  const marks: PromptDecoration[] = []
48  const command = /^\/[A-Za-z0-9:_-]+/.exec(text)
49
50  if (command !== null) {
51    marks.push({ start: 0, end: command[0].length, color: C.secondary, bold: true })
52  } else if (text.startsWith('!')) {
53    marks.push({ start: 0, end: 1, color: C.warn, bold: true })
54  }
55
56  for (const m of text.matchAll(/(^|\s)(@[^\s]+)/g)) {
57    const start = (m.index ?? 0) + (m[1] ?? '').length
58
59    marks.push({ start, end: start + (m[2] ?? '').length, color: C.tertiary })
60  }
61
62  return marks
63}
64
65const asTodos = (value: unknown): TodoItem[] =>
66  (Array.isArray(value) ? value : []).flatMap((item): TodoItem[] => {
67    const o = rec(item)
68    const status = o.status === 'completed' || o.status === 'in_progress' ? o.status : 'pending'
69    const content = clip(clean(str(o.content)).trim(), 160)
70
71    return content === ''
72      ? []
73      : [{ content, status, activeForm: clip(clean(str(o.activeForm)).trim(), 160) }]
74  })
75
76/** Ticks the animation frame while a turn runs; it ends itself when none does. */
77function startTicker($: EngineInterface): void {
78  if (ticker === undefined) {
79    ticker = $.clock.every(120, () => {
80      if (!isTurnActive) {
81        stopTicker()
82
83        return
84      }
85
86      void update($, frameAtom, n => ((n ?? 0) + 1) % 1_000_000)
87    })
88  }
89}
90
91function stopTicker(): void {
92  ticker?.cancel()
93  ticker = undefined
94}
95
96/** Context, cost and limits, the model and the directory, as the status line has them. */
97async function refresh($: EngineInterface): Promise<void> {
98  try {
99    const [figures, model, dir] = await Promise.all([$.session.usage(), $.session.model(), $.session.cwd()])
100
101    modelId = model
102    cwd = dir
103    await update($, modelAtom, () => model)
104    await update($, usageAtom, () => ({
105      percent: figures.context.percent ?? null,
106      tokens: figures.context.tokens ?? null,
107      window: figures.context.window,
108      usd: figures.cost?.usd ?? null,
109      limits: figures.rateLimits.map(limit => ({ kind: limit.kind, percentUsed: limit.percentUsed })),
110    }))
111  } catch {
112    // The figures stay as they were.
113  }
114}
115
116async function refreshBranch($: EngineInterface): Promise<void> {
117  try {
118    const run = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 4000 })
119    const name = run.exitCode === 0 ? run.stdout.trim() : ''
120
121    await update($, branchAtom, () => name)
122  } catch {
123    // Not a repository, or no git: no branch.
124  }
125}
126
127async function gather($: EngineInterface, isFull: boolean): Promise<Facts> {
128  const [title, branch, model, usage, todos] = await Promise.all([
129    isFull ? read($, titleAtom) : '',
130    read($, branchAtom),
131    read($, modelAtom),
132    read($, usageAtom),
133    read($, todosAtom),
134  ])
135  const [files, tools] = isFull ? await Promise.all([read($, filesAtom), read($, toolsAtom)]) : [[], {}]
136
137  return {
138    title,
139    cwd: cwd === '' ? await $.session.cwd() : cwd,
140    home,
141    branch,
142    model,
143    usage,
144    files,
145    todos,
146    tools,
147  }
148}
149
150async function openSidebar($: EngineInterface): Promise<void> {
151  await $.ui.open({ id: SIDEBAR, title: 'Session', columns: 38 })
152}
153
154async function openIfWanted($: EngineInterface): Promise<void> {
155  if ((await $.store.get(DISMISSED)) !== true) {
156    await openSidebar($)
157  }
158}
159
160/** Opens the sidebar once, and only where it would dock beside the transcript. */
161function openOnce($: EngineInterface): void {
162  if (!isSidebarAuto || hasTriedOpen) {
163    return
164  }
165
166  hasTriedOpen = true
167
168  try {
169    $.clock.after(0, () => {
170      openIfWanted($).catch(() => undefined)
171    })
172  } catch {
173    hasTriedOpen = false
174  }
175}
176
177/** Reads the master switch; a change redraws every row this mod draws. */
178async function loadSwitch($: EngineInterface): Promise<void> {
179  try {
180    const stored = (await $.store.get(SWITCH)) !== false
181
182    if (stored !== isOn) {
183      isOn = stored
184      $.ui.invalidate('ui.render')
185    }
186  } catch {
187    // The look stays as it is.
188  }
189}
190
191/**
192 * `/crush [on|off]`: the master switch. It is kept in `$.store`, which lasts
193 * across sessions, so every new session starts the way the last /crush left
194 * it; this one redraws at once.
195 */
196async function switchLook($: EngineInterface, args: string): Promise<{ text: string }> {
197  const word = args.trim().toLowerCase()
198  const want = word === 'on' ? true : word === 'off' ? false : word === '' ? !isOn : undefined
199
200  if (want === undefined) {
201    return { text: 'Usage: /crush on, /crush off, or /crush alone to toggle. The choice is kept for every session.' }
202  }
203
204  if (want === isOn) {
205    return { text: `Crush style is already ${isOn ? 'on' : 'off'}.` }
206  }
207
208  await $.store.set(SWITCH, want)
209  isOn = want
210
211  if (want) {
212    hasTriedOpen = false
213  } else {
214    stopTicker()
215    await $.ui.close({ id: SIDEBAR }).catch(() => undefined)
216  }
217
218  $.ui.invalidate('ui.render')
219
220  return {
221    text: want
222      ? 'Crush style on, here and in every new session.'
223      : 'Crush style off, here and in every new session. /crush on brings it back.',
224  }
225}
226
227export const register: Register = (on, options) => {
228  const showBand = options.band !== false
229  const expandGroups = options.toolGroups !== 'fold'
230  const crushWords = options.spinnerWords !== 'claude'
231
232  isSidebarAuto = options.sidebar !== false
233
234  on('command.run', { command: 'crush' }, ($, e) => switchLook($, e.args))
235
236  on('session.start', async ($, e, next) => {
237    await $.command.register({
238      name: 'crush',
239      description: 'Crush-style look on or off for every session: /crush on, /crush off',
240    })
241
242    await loadSwitch($)
243
244    home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
245    await $.command.register({ name: 'sidebar', description: 'Toggle the Crush-style sidebar' })
246    await Promise.all([refresh($), refreshBranch($)])
247
248    // A reload in the middle of a turn: its clock is in `$.state`, its ticker is gone.
249    try {
250      if ((await read($, turnStartAtom)) > 0) {
251        isTurnActive = true
252        startTicker($)
253      }
254    } catch {
255      // The spinner holds its last frame until the next turn.
256    }
257
258    return next(e)
259  })
260
261  on('session.end', async ($, e, next) => {
262    if (e.reason === 'clear') {
263      await Promise.all([
264        update($, filesAtom, () => []),
265        update($, toolsAtom, () => ({})),
266        update($, todosAtom, () => []),
267        update($, titleAtom, () => ''),
268      ])
269    }
270
271    return next(e)
272  })
273
274  on('command.run', { command: 'sidebar' }, async $ => {
275    if (!isOn) {
276      return { text: 'Crush style is off. /crush on turns it back on.' }
277    }
278
279    const isOpen = (await $.ui.panes()).some(pane => pane.id === SIDEBAR)
280
281    if (isOpen) {
282      await $.store.set(DISMISSED, true)
283      await $.ui.close({ id: SIDEBAR })
284
285      return { text: 'Sidebar off.' }
286    }
287
288    await $.store.set(DISMISSED, false)
289    await openSidebar($)
290
291    return { text: 'Sidebar on.' }
292  })
293
294  on('ui.close', { id: SIDEBAR }, async ($, e, next) => {
295    if (e.origin.kind === 'person') {
296      await $.store.set(DISMISSED, true)
297    }
298
299    return next(e)
300  })
301
302  on('prompt.submit', async ($, e, next) => {
303    const text = clean(e.text).trim()
304
305    if (e.origin.kind === 'composer' && text !== '' && !text.startsWith('/')) {
306      const first = clip(text.split('\n')[0]?.replace(/\s+/g, ' ') ?? '', 80)
307
308      await update($, titleAtom, current => (current === undefined || current === '' ? first : current))
309    }
310
311    return next(e)
312  })
313
314  on('prompt.edit', async ($, e, next) => {
315    if (!isOn) {
316      return next(e)
317    }
318
319    const box = await next(e)
320    const marks = decorate(box.text)
321
322    return marks.length === 0 ? box : { ...box, decorations: [...(box.decorations ?? []), ...marks] }
323  })
324
325  on('turn.start', async ($, e, next) => {
326    const now = await $.clock.now()
327
328    await update($, turnStartAtom, () => now)
329    isTurnActive = true
330
331    if (isOn) {
332      startTicker($)
333    }
334
335    return next(e)
336  })
337
338  on('turn.complete', async ($, e, next) => {
339    const done = await next(e)
340
341    if (e.agentId === undefined) {
342      isTurnActive = false
343      stopTicker()
344      await update($, turnStartAtom, () => 0)
345      await Promise.all([refresh($), refreshBranch($)])
346    }
347
348    return done
349  })
350
351  on('tool.call', async ($, e, next) => {
352    const tool = String(e.tool)
353
354    await update($, toolsAtom, counts => ({ ...counts, [tool]: ((counts ?? {})[tool] ?? 0) + 1 }))
355
356    const ran = await next(e)
357
358    try {
359      if (ran.deny !== undefined || ran.isError === true) {
360        return ran
361      }
362
363      const input = rec(e)
364
365      if (CHANGING.includes(tool) && rec(ran.result).staged !== true) {
366        const path = str(input.file_path) !== '' ? str(input.file_path) : str(input.notebook_path)
367        const stats = changeStats(ran.result)
368
369        if (path !== '' && stats !== undefined) {
370          await update($, filesAtom, list => {
371            const current = list ?? []
372            const prior = current.find(file => file.path === path)
373            const merged: FileChange = {
374              path,
375              added: (prior?.added ?? 0) + stats.added,
376              removed: (prior?.removed ?? 0) + stats.removed,
377            }
378
379            return [merged, ...current.filter(file => file.path !== path)].slice(0, 50)
380          })
381        }
382      } else if (tool === 'TodoWrite') {
383        await update($, todosAtom, () => asTodos(input.todos))
384      }
385    } catch {
386      // The sidebar misses one update; the call itself is untouched.
387    }
388
389    return ran
390  })
391
392  on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
393    if (!isOn || e.surface !== 'terminal' || e.props.origin.kind !== 'composer' || e.props.text.length > 8000) {
394      return next(e)
395    }
396
397    return userRow($.ui.resolve(e), e.props.text, e.viewport?.columns ?? 100)
398  })
399
400  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
401    if (!isOn || e.surface !== 'terminal') {
402      return next(e)
403    }
404
405    const text = clean(e.props.text)
406
407    return text.trim() === '' || text.length > MAX_TEXT ? next(e) : assistantRow($.ui.resolve(e), text)
408  })
409
410  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
411    if (!isOn || e.surface !== 'terminal') {
412      return next(e)
413    }
414
415    const p = e.props
416    const t = $.ui.resolve(e)
417    const status: Status = p.isRunning ? 'running' : p.isInterrupted ? 'interrupted' : p.isErrored ? 'error' : 'done'
418    const { name, detail } = describeTool(p.tool, p.input, cwd)
419    const frame = status === 'running' ? await read($, frameAtom) : 0
420    const stats = status === 'done' && CHANGING.includes(p.tool) ? changeStats(p.output) : undefined
421    const tail =
422      stats !== undefined ? diffTail(t, stats) : status === 'interrupted' ? interruptedTail(t) : undefined
423
424    return toolHeader(t, { status, name, detail, ...(tail === undefined ? {} : { tail }), frame })
425  })
426
427  on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
428    if (!isOn || e.surface !== 'terminal') {
429      return next(e)
430    }
431
432    const p = e.props
433    const t = $.ui.resolve(e)
434
435    if (p.isErrored) {
436      return typeof p.output === 'string' && p.output.trim() !== ''
437        ? outputBody(t, p.output, { max: 6, color: C.error })
438        : next(e)
439    }
440
441    if (p.tool !== 'Bash') {
442      return next(e)
443    }
444
445    const out = rec(p.output)
446
447    if (out.isImage === true || typeof out.backgroundTaskId === 'string') {
448      return next(e)
449    }
450
451    const text = [str(out.stdout), str(out.stderr)].filter(part => part.trim() !== '').join('\n')
452
453    return text === ''
454      ? outputBody(t, '(no output)', { max: 1, color: C.subtle })
455      : outputBody(t, text, { max: 10 })
456  })
457
458  on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
459    if (!isOn || e.surface !== 'terminal' || !expandGroups || e.props.isExpanded) {
460      return next(e)
461    }
462
463    return next({ ...e, props: { ...e.props, isExpanded: true } })
464  })
465
466  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
467    if (!isOn || e.surface !== 'terminal') {
468      return next(e)
469    }
470
471    const frame = await read($, frameAtom)
472    const startedAt = await read($, turnStartAtom)
473    const now = await $.clock.now()
474    const label = e.props.message ?? (crushWords ? spinnerWord(e.props.mode) : e.props.word)
475
476    return spinnerRow($.ui.resolve(e), {
477      label,
478      suffix: e.props.suffix,
479      frame,
480      elapsedMs: startedAt > 0 ? now - startedAt : 0,
481    })
482  })
483
484  on('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
485    if (!isOn || e.surface !== 'terminal') {
486      return next(e)
487    }
488
489    return footerRow($.ui.resolve(e), modelId, e.props.durationMs)
490  })
491
492  on('ui.render', { component: 'InfoNotice' }, ($, e, next) => {
493    if (!isOn || e.surface !== 'terminal') {
494      return next(e)
495    }
496
497    return noticeRow($.ui.resolve(e), e.props.text, e.props.command)
498  })
499
500  on('ui.render', { component: 'CommandOutput' }, ($, e, next) => {
501    if (!isOn || e.surface !== 'terminal') {
502      return next(e)
503    }
504
505    const text = clean(e.props.text)
506
507    return text.trim() === '' || text.length > MAX_TEXT
508      ? next(e)
509      : commandBox($.ui.resolve(e), text, e.props.isErrored)
510  })
511
512  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
513    if (!isOn || e.surface !== 'terminal') {
514      return next(e)
515    }
516
517    if (e.viewport?.isFullscreen === true) {
518      openOnce($)
519    }
520
521    const isDocked = (e.viewport?.columns ?? 0) > e.props.bodyColumns
522
523    if (!showBand || e.props.hasSurvey || isDocked) {
524      return next(e)
525    }
526
527    return bandRow($.ui.resolve(e), await gather($, false), e.props.bodyColumns)
528  })
529
530  on('ui.render', { component: 'Pane', requestId: SIDEBAR }, async ($, e, next) => {
531    if (!isOn || e.surface !== 'terminal') {
532      return next(e)
533    }
534
535    return sidebar($.ui.resolve(e), await gather($, true), e.props.bodyColumns)
536  })
537}
538
hooks/messages.tsx 107 lines
1import { C, SPIN, clean, fmtDuration, grad, mix, prettyModel, rowsFor } from './theme'
2import type { T } from './theme'
3
4/** The person's prompt: a bar of Charple down its left edge. */
5export const userRow = (t: T, text: string, columns: number) => {
6  const { Box, Text } = t
7  const body = clean(text)
8  const rows = Math.min(rowsFor(body, Math.max(10, columns - 3)), 300)
9  const bar = Array.from({ length: rows }, () => '▌').join('\n')
10
11  return (
12    <Box marginTop={1}>
13      <Box width={2} flexShrink={0}>
14        <Text color={C.primary}>{bar}</Text>
15      </Box>
16      <Box flexGrow={1} flexShrink={1}>
17        <Text>{body}</Text>
18      </Box>
19    </Box>
20  )
21}
22
23/** A block of the model's reply, indented under no mark at all. */
24export const assistantRow = (t: T, text: string) => {
25  const { Box, Markdown } = t
26
27  return (
28    <Box marginTop={1} paddingLeft={2}>
29      <Markdown text={text} />
30    </Box>
31  )
32}
33
34const WORDS = {
35  requesting: 'Connecting',
36  thinking: 'Thinking',
37  responding: 'Generating',
38  'tool-input': 'Preparing',
39  'tool-use': 'Working',
40} as const
41
42export const spinnerWord = (mode: keyof typeof WORDS): string => WORDS[mode]
43
44type SpinnerInput = { label: string; suffix: string; frame: number; elapsedMs: number }
45
46/** The line that runs while a turn does: a glyph, a word with a color wave in it, the time. */
47export const spinnerRow = (t: T, v: SpinnerInput) => {
48  const { Box, Text } = t
49  const glyph = SPIN[v.frame % SPIN.length] ?? '●'
50  const suffix = v.label.endsWith('…') ? '' : v.suffix
51
52  return (
53    <Box marginTop={1}>
54      <Box width={2} flexShrink={0}>
55        <Text color={mix(C.primary, C.secondary, 0.5 - 0.5 * Math.cos(v.frame / 3))}>{glyph}</Text>
56      </Box>
57      {grad(t, v.label, { phase: v.frame / 14 })}
58      <Text color={C.subtle}>{suffix}</Text>
59      {v.elapsedMs >= 1000 ? <Text color={C.subtle}>{`  ${fmtDuration(v.elapsedMs)}`}</Text> : null}
60    </Box>
61  )
62}
63
64/** What closes a turn: the model and how long it took. */
65export const footerRow = (t: T, model: string, durationMs: number) => {
66  const { Box, Text } = t
67
68  return (
69    <Box>
70      <Text color={C.secondary}>◇ </Text>
71      <Text color={C.muted}>{model === '' ? 'Claude' : prettyModel(model)}</Text>
72      <Text color={C.subtle}>{`  ${fmtDuration(durationMs)}`}</Text>
73    </Box>
74  )
75}
76
77/** The dim line under the logo, in Crush's notice style. */
78export const noticeRow = (t: T, text: string, command: string | null) => {
79  const { Box, Text } = t
80
81  return (
82    <Box>
83      <Text color={C.subtle}>╱ </Text>
84      <Text color={C.muted} wrap="truncate-end">
85        {clean(text)}
86      </Text>
87      {command === null ? null : <Text color={C.secondary}>{` ${clean(command)}`}</Text>}
88    </Box>
89  )
90}
91
92/** A slash command's output, in a rounded frame that shrinks to it. */
93export const commandBox = (t: T, text: string, isErrored: boolean) => {
94  const { Box, Markdown } = t
95
96  return (
97    <Box
98      paddingX={1}
99      borderStyle="round"
100      borderColor={isErrored ? C.error : C.line}
101      alignSelf="flex-start"
102    >
103      <Markdown text={text} />
104    </Box>
105  )
106}
107
hooks/sidebar.tsx 242 lines
1import type { RenderChildren, RenderNode } from 'claude-code'
2
3import type { FileChange, TodoItem, UsageInfo } from '../types'
4import { toolLabel } from './tools'
5import { C, clip, clipLeft, cellWidth, dimmed, fmtTokens, fmtUsd, grad, meter, prettyModel, relPath, rule, section, tilde } from './theme'
6import type { T } from './theme'
7
8export type Facts = {
9  title: string
10  cwd: string
11  home: string
12  branch: string
13  model: string
14  usage: UsageInfo | null
15  files: FileChange[]
16  todos: TodoItem[]
17  tools: Record<string, number>
18}
19
20const LIMIT_LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
21
22const MAX_FILES = 8
23const MAX_TODOS = 8
24const MAX_TOOLS = 6
25
26/** The docked sidebar: session, model and context, files, to-dos, tool counts. */
27export const sidebar = (t: T, f: Facts, columns: number) => {
28  const { Box, Text } = t
29  const w = Math.max(12, columns - 2)
30  const u = f.usage
31  const doneTodos = f.todos.filter(todo => todo.status === 'completed').length
32  const shownFiles = f.files.slice(0, MAX_FILES)
33  const shownTodos = f.todos.slice(0, MAX_TODOS)
34  const toolRows = Object.entries(f.tools)
35    .sort((a, b) => b[1] - a[1])
36    .slice(0, MAX_TOOLS)
37  const meterWidth = Math.max(6, Math.min(20, w - 8))
38
39  return (
40    <Box flexDirection="column" paddingX={1}>
41      <Box>
42        {grad(t, 'CLAUDE CODE', { bold: true })}
43        <Text> </Text>
44        {rule(t, w - 12, { from: dimmed(C.primary), to: dimmed(C.secondary) })}
45      </Box>
46
47      <Box marginTop={1}>
48        <Text bold wrap="truncate-end">
49          {f.title === '' ? 'New session' : f.title}
50        </Text>
51      </Box>
52      <Text color={C.muted} wrap="truncate-start">
53        {tilde(f.cwd, f.home)}
54      </Text>
55      {f.branch === '' ? null : (
56        <Text color={C.muted} wrap="truncate-end">
57          {`⎇ ${f.branch}`}
58        </Text>
59      )}
60
61      <Box marginTop={1}>
62        <Text color={C.secondary}>◇ </Text>
63        <Text wrap="truncate-end">{f.model === '' ? 'Claude' : prettyModel(f.model)}</Text>
64      </Box>
65      {u === null || u.percent === null ? null : (
66        <Box paddingLeft={2}>
67          {meter(t, u.percent, meterWidth)}
68          <Text color={C.muted}>{` ${Math.round(u.percent)}%`}</Text>
69        </Box>
70      )}
71      {u === null || u.tokens === null ? null : (
72        <Box paddingLeft={2}>
73          <Text color={C.muted} wrap="truncate-end">
74            {`${fmtTokens(u.tokens)} / ${fmtTokens(u.window)}`}
75          </Text>
76        </Box>
77      )}
78      {/* The cost on a line of its own: beside the tokens a narrow sidebar cut it to `$1…`. */}
79      {u === null || u.usd === null ? null : (
80        <Box paddingLeft={2}>
81          <Text color={C.muted}>{fmtUsd(u.usd)}</Text>
82        </Box>
83      )}
84      {u === null
85        ? null
86        : u.limits.map(limit => (
87            <Box paddingLeft={2}>
88              <Box width={6} flexShrink={0}>
89                <Text color={C.muted}>{LIMIT_LABELS[limit.kind] ?? limit.kind}</Text>
90              </Box>
91              {meter(t, limit.percentUsed, Math.max(4, Math.min(12, w - 14)))}
92              <Text color={C.muted}>{` ${Math.round(limit.percentUsed)}%`}</Text>
93            </Box>
94          ))}
95
96      {section(t, 'Modified Files', w)}
97      {shownFiles.length === 0 ? <Text color={C.subtle}>None</Text> : null}
98      {shownFiles.map(file => (
99        <Box>
100          <Box flexShrink={0} marginRight={1}>
101            <Text>
102              {file.added > 0 ? <Text color={C.success}>{`+${file.added}`}</Text> : null}
103              {file.removed > 0 ? <Text color={C.error}>{` -${file.removed}`}</Text> : null}
104              {file.added === 0 && file.removed === 0 ? <Text color={C.subtle}>±0</Text> : null}
105            </Text>
106          </Box>
107          <Box flexGrow={1} flexShrink={1}>
108            <Text wrap="truncate-start">{relPath(file.path, f.cwd)}</Text>
109          </Box>
110        </Box>
111      ))}
112      {f.files.length > shownFiles.length ? (
113        <Text color={C.subtle}>{`…and ${f.files.length - shownFiles.length} more`}</Text>
114      ) : null}
115
116      {f.todos.length === 0 ? null : section(t, `To-Do ${doneTodos}/${f.todos.length}`, w)}
117      {shownTodos.map(todo => (
118        <Box>
119          <Box width={2} flexShrink={0}>
120            {todo.status === 'completed' ? (
121              <Text color={C.success}>✓</Text>
122            ) : todo.status === 'in_progress' ? (
123              <Text color={C.secondary}>●</Text>
124            ) : (
125              <Text color={C.subtle}>○</Text>
126            )}
127          </Box>
128          <Box flexGrow={1} flexShrink={1}>
129            {todo.status === 'in_progress' ? (
130              <Text wrap="truncate-end">{todo.activeForm || todo.content}</Text>
131            ) : (
132              <Text color={C.muted} wrap="truncate-end">
133                {todo.content}
134              </Text>
135            )}
136          </Box>
137        </Box>
138      ))}
139      {f.todos.length > shownTodos.length ? (
140        <Text color={C.subtle}>{`…and ${f.todos.length - shownTodos.length} more`}</Text>
141      ) : null}
142
143      {toolRows.length === 0 ? null : section(t, 'Activity', w)}
144      {toolRows.map(([tool, count]) => (
145        <Box justifyContent="space-between">
146          <Text color={C.muted}>{toolLabel(tool)}</Text>
147          <Text color={C.subtle}>{String(count)}</Text>
148        </Box>
149      ))}
150    </Box>
151  )
152}
153
154type Seg = { w: number; node: RenderNode }
155
156/** One line above the prompt: model, context, cost, to-dos, branch, directory. */
157export const bandRow = (t: T, f: Facts, columns: number) => {
158  const { Box, Text } = t
159  const u = f.usage
160  const name = f.model === '' ? 'Claude' : prettyModel(f.model)
161  const doneTodos = f.todos.filter(todo => todo.status === 'completed').length
162  const segs: Seg[] = []
163
164  segs.push({
165    w: 2 + cellWidth(name),
166    node: (
167      <Text>
168        <Text color={C.secondary}>◇ </Text>
169        <Text>{name}</Text>
170      </Text>
171    ),
172  })
173
174  if (u !== null && u.percent !== null) {
175    const pct = ` ${Math.round(u.percent)}%`
176
177    segs.push({
178      w: 8 + cellWidth(pct),
179      node: (
180        <Text>
181          {meter(t, u.percent, 8)}
182          <Text color={C.muted}>{pct}</Text>
183        </Text>
184      ),
185    })
186  }
187
188  if (u !== null && u.usd !== null) {
189    segs.push({ w: cellWidth(fmtUsd(u.usd)), node: <Text color={C.muted}>{fmtUsd(u.usd)}</Text> })
190  }
191
192  if (f.todos.length > 0) {
193    const label = `✓ ${doneTodos}/${f.todos.length}`
194
195    segs.push({ w: cellWidth(label), node: <Text color={C.muted}>{label}</Text> })
196  }
197
198  if (f.branch !== '') {
199    const label = `⎇ ${clip(f.branch, 24)}`
200
201    segs.push({ w: cellWidth(label), node: <Text color={C.muted}>{label}</Text> })
202  }
203
204  const place = clipLeft(tilde(f.cwd, f.home), 32)
205
206  segs.push({ w: cellWidth(place), node: <Text color={C.subtle}>{place}</Text> })
207
208  const lead = 3
209  const sep = 3
210  const kept: Seg[] = []
211  let used = lead
212
213  for (const seg of segs) {
214    const next = used + (kept.length === 0 ? 0 : sep) + seg.w
215
216    if (next > columns - 1) {
217      break
218    }
219
220    kept.push(seg)
221    used = next
222  }
223
224  const fill = columns - used - 2
225  const row: RenderChildren[] = [rule(t, 2, { from: dimmed(C.primary), to: dimmed(C.secondary) }), <Text> </Text>]
226
227  kept.forEach((seg, i) => {
228    if (i > 0) {
229      row.push(<Text color={C.subtle}> · </Text>)
230    }
231
232    row.push(seg.node)
233  })
234
235  if (fill >= 3) {
236    row.push(<Text> </Text>)
237    row.push(rule(t, fill, { from: dimmed(C.secondary), to: dimmed(C.primary) }))
238  }
239
240  return <Box>{row}</Box>
241}
242
hooks/theme.tsx 272 lines
1import type { Elements } from 'claude-code'
2
3export type T = Elements['terminal']
4
5// Charm's palette, as Crush draws with it. Body text carries no color at all,
6// so it follows the terminal's own foreground on a light theme as on a dark one.
7export const C = {
8  primary: '#6B50FF', // Charple
9  secondary: '#FF60FF', // Dolly
10  tertiary: '#68FFD6', // Bok
11  accent: '#E8FE96', // Zest
12  info: '#00A4FF', // Malibu
13  success: '#12C78F', // Guac
14  warn: '#F5EF34', // Mustard
15  error: '#EB4268', // Sriracha
16  muted: '#858392', // Squid
17  subtle: '#605F6B', // Oyster
18  line: '#4B4A57',
19} as const
20
21const toRgb = (hex: string): [number, number, number] => {
22  const n = parseInt(hex.slice(1), 16)
23
24  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
25}
26
27const toHex = (n: number): string =>
28  Math.round(Math.max(0, Math.min(255, n)))
29    .toString(16)
30    .padStart(2, '0')
31
32/** The color `t` of the way from `a` to `b`, both `#rrggbb`. */
33export const mix = (a: string, b: string, t: number): string => {
34  const [ar, ag, ab] = toRgb(a)
35  const [br, bg, bb] = toRgb(b)
36
37  return `#${toHex(ar + (br - ar) * t)}${toHex(ag + (bg - ag) * t)}${toHex(ab + (bb - ab) * t)}`
38}
39
40const isWide = (cp: number): boolean =>
41  (cp >= 0x1100 && cp <= 0x115f) ||
42  (cp >= 0x2e80 && cp <= 0xa4cf) ||
43  (cp >= 0xac00 && cp <= 0xd7a3) ||
44  (cp >= 0xf900 && cp <= 0xfaff) ||
45  (cp >= 0xfe30 && cp <= 0xfe6f) ||
46  (cp >= 0xff00 && cp <= 0xff60) ||
47  (cp >= 0xffe0 && cp <= 0xffe6) ||
48  (cp >= 0x1f300 && cp <= 0x1f64f) ||
49  (cp >= 0x1f900 && cp <= 0x1f9ff) ||
50  (cp >= 0x20000 && cp <= 0x3fffd)
51
52const isZero = (cp: number): boolean =>
53  (cp >= 0x300 && cp <= 0x36f) || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)
54
55/** Terminal cells `s` takes: CJK and emoji two, combining marks none. */
56export const cellWidth = (s: string): number => {
57  let width = 0
58
59  for (const ch of s) {
60    const cp = ch.codePointAt(0) ?? 0
61    width += isZero(cp) ? 0 : isWide(cp) ? 2 : 1
62  }
63
64  return width
65}
66
67/** `s` cut to `max` cells, with an ellipsis where it was cut. */
68export const clip = (s: string, max: number): string => {
69  if (max <= 0) {
70    return ''
71  }
72
73  if (cellWidth(s) <= max) {
74    return s
75  }
76
77  let out = ''
78  let width = 0
79
80  for (const ch of s) {
81    const w = cellWidth(ch)
82
83    if (width + w > max - 1) {
84      break
85    }
86
87    out += ch
88    width += w
89  }
90
91  return `${out}…`
92}
93
94/** `s` cut from the left to `max` cells, so the end of a path stays visible. */
95export const clipLeft = (s: string, max: number): string => {
96  if (max <= 0) {
97    return ''
98  }
99
100  if (cellWidth(s) <= max) {
101    return s
102  }
103
104  let out = ''
105  let width = 0
106
107  for (const ch of Array.from(s).reverse()) {
108    const w = cellWidth(ch)
109
110    if (width + w > max - 1) {
111      break
112    }
113
114    out = ch + out
115    width += w
116  }
117
118  return `…${out}`
119}
120
121/** Rows `text` takes when wrapped to `columns` cells. */
122export const rowsFor = (text: string, columns: number): number =>
123  text
124    .split('\n')
125    .reduce((rows, line) => rows + Math.max(1, Math.ceil(cellWidth(line) / Math.max(1, columns))), 0)
126
127/** Text a tree may carry: no escape sequences or control characters, tabs as spaces. */
128export const clean = (s: string): string =>
129  s
130    .replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '')
131    .replace(/\u001b\][^\u0007]*\u0007/g, '')
132    .replace(/\r/g, '')
133    .replace(/\t/g, '  ')
134    .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
135
136type GradientOptions = { from?: string; to?: string; bold?: boolean; phase?: number }
137
138/**
139 * `text` colored along a ramp, one Text per character. With `phase` the ramp
140 * is a wave that travels one wavelength each time the phase moves by 1.
141 */
142export const grad = (t: T, text: string, options: GradientOptions = {}) => {
143  const { Text } = t
144  const from = options.from ?? C.primary
145  const to = options.to ?? C.secondary
146  const chars = Array.from(text)
147  const last = Math.max(1, chars.length - 1)
148  const { phase } = options
149
150  return (
151    <Text bold={options.bold === true}>
152      {chars.map((ch, i) => {
153        const x =
154          phase === undefined
155            ? i / last
156            : 0.5 - 0.5 * Math.cos(2 * Math.PI * (i / Math.max(1, chars.length) - phase))
157
158        return <Text color={mix(from, to, x)}>{ch}</Text>
159      })}
160    </Text>
161  )
162}
163
164/** A run of Crush's diagonal fill, `width` cells across. */
165export const rule = (t: T, width: number, options: GradientOptions = {}) =>
166  width > 0 ? grad(t, '╱'.repeat(width), options) : null
167
168/** `color` pulled toward the border grey, for fills that should stay behind the text. */
169export const dimmed = (color: string): string => mix(color, C.line, 0.55)
170
171/** A section heading with the diagonal fill running out to `width` cells. */
172export const section = (t: T, title: string, width: number) => {
173  const { Box, Text } = t
174
175  return (
176    <Box marginTop={1}>
177      <Text color={C.muted}>{title} </Text>
178      {rule(t, width - cellWidth(title) - 1, { from: dimmed(C.primary), to: dimmed(C.secondary) })}
179    </Box>
180  )
181}
182
183/** A bar `width` cells wide, `percent` full, its fill on the brand ramp. */
184export const meter = (t: T, percent: number, width: number) => {
185  const { Text } = t
186  const clamped = Math.max(0, Math.min(100, percent))
187  const filled = Math.round((clamped / 100) * width)
188  const hot = clamped >= 90 ? C.error : clamped >= 75 ? C.warn : undefined
189  const cells = Array.from({ length: filled }, (_, i) =>
190    hot === undefined ? mix(C.primary, C.secondary, i / Math.max(1, width - 1)) : hot,
191  )
192
193  return (
194    <Text>
195      {cells.map(color => (
196        <Text color={color}>█</Text>
197      ))}
198      <Text color={C.line}>{'░'.repeat(Math.max(0, width - filled))}</Text>
199    </Text>
200  )
201}
202
203export const SPIN = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] as const
204
205/** `claude-sonnet-5-5` as `Sonnet 5.5`. */
206export const prettyModel = (id: string): string => {
207  const base = id
208    .replace(/\[.*\]$/, '')
209    .replace(/^claude-/, '')
210    .replace(/-\d{8}$/, '')
211  const out: string[] = []
212
213  for (const part of base.split('-').filter(Boolean)) {
214    const prev = out[out.length - 1]
215
216    if (/^\d+$/.test(part)) {
217      if (prev !== undefined && /^\d+(\.\d+)*$/.test(prev)) {
218        out[out.length - 1] = `${prev}.${part}`
219      } else {
220        out.push(part)
221      }
222    } else {
223      out.push(part.charAt(0).toUpperCase() + part.slice(1))
224    }
225  }
226
227  return out.join(' ') || id
228}
229
230export const fmtTokens = (n: number): string =>
231  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n)
232
233export const fmtUsd = (n: number): string => `$${n.toFixed(2)}`
234
235export const fmtDuration = (ms: number): string => {
236  const s = Math.max(0, Math.round(ms / 1000))
237
238  if (s < 60) {
239    return `${s}s`
240  }
241
242  const m = Math.floor(s / 60)
243
244  return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`
245}
246
247export const slash = (p: string): string => p.replace(/\\/g, '/')
248
249/** `path` relative to `cwd` when it lies under it. */
250export const relPath = (path: string, cwd: string): string => {
251  const p = slash(path)
252  const base = slash(cwd).replace(/\/$/, '')
253
254  return base !== '' && p.toLowerCase().startsWith(`${base.toLowerCase()}/`) ? p.slice(base.length + 1) : p
255}
256
257/** `path` with the home directory written `~`. */
258export const tilde = (path: string, home: string): string => {
259  const p = slash(path)
260  const h = slash(home).replace(/\/$/, '')
261
262  if (h === '') {
263    return p
264  }
265
266  if (p.toLowerCase() === h.toLowerCase()) {
267    return '~'
268  }
269
270  return p.toLowerCase().startsWith(`${h.toLowerCase()}/`) ? `~${p.slice(h.length)}` : p
271}
272
hooks/tools.tsx 243 lines
1import type { RenderNode } from 'claude-code'
2
3import { C, SPIN, clean, clip, mix, relPath } from './theme'
4import type { T } from './theme'
5
6export type Status = 'running' | 'done' | 'error' | 'interrupted'
7
8export const rec = (v: unknown): Record<string, unknown> =>
9  typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {}
10
11export const str = (v: unknown): string => (typeof v === 'string' ? v : '')
12
13const oneLine = (s: string): string => {
14  const lines = clean(s)
15    .split('\n')
16    .map(line => line.trim())
17    .filter(line => line !== '')
18
19  return lines.length > 1 ? `${lines[0] ?? ''} …` : (lines[0] ?? '')
20}
21
22const firstString = (input: Record<string, unknown>): string => {
23  for (const value of Object.values(input)) {
24    if (typeof value === 'string' && value !== '') {
25      return oneLine(value)
26    }
27  }
28
29  return ''
30}
31
32/** The name Crush gives a tool, and what its row says it is working on. */
33export const describeTool = (tool: string, input: unknown, cwd: string): { name: string; detail: string } => {
34  const i = rec(input)
35  const path = (key: string): string => relPath(str(i[key]), cwd)
36
37  switch (tool) {
38    case 'Bash':
39      return {
40        name: 'Bash',
41        detail: oneLine(str(i.command)) + (i.run_in_background === true ? '  (background)' : ''),
42      }
43    case 'Read': {
44      const offset = typeof i.offset === 'number' ? i.offset : undefined
45      const limit = typeof i.limit === 'number' ? i.limit : undefined
46      const range =
47        offset === undefined ? '' : limit === undefined ? `:${offset}` : `:${offset}-${offset + limit}`
48
49      return { name: 'View', detail: path('file_path') + range }
50    }
51    case 'Edit':
52    case 'MultiEdit':
53      return { name: 'Edit', detail: path('file_path') }
54    case 'Write':
55      return { name: 'Write', detail: path('file_path') }
56    case 'NotebookEdit':
57      return { name: 'Notebook', detail: path('notebook_path') }
58    case 'Glob':
59      return { name: 'Glob', detail: oneLine(str(i.pattern)) + (str(i.path) === '' ? '' : `  in ${path('path')}`) }
60    case 'Grep': {
61      const where = str(i.glob) !== '' ? str(i.glob) : str(i.path) === '' ? '' : path('path')
62
63      return { name: 'Grep', detail: oneLine(str(i.pattern)) + (where === '' ? '' : `  in ${where}`) }
64    }
65    case 'WebFetch':
66      return { name: 'Fetch', detail: oneLine(str(i.url)) }
67    case 'WebSearch':
68      return { name: 'Search', detail: oneLine(str(i.query)) }
69    case 'Agent':
70    case 'Task': {
71      const kind = str(i.subagent_type)
72
73      return {
74        name: 'Agent',
75        detail: oneLine(str(i.description) !== '' ? str(i.description) : str(i.prompt)) + (kind === '' ? '' : `  (${kind})`),
76      }
77    }
78    case 'TodoWrite': {
79      const list = Array.isArray(i.todos) ? i.todos : []
80      const done = list.filter(item => rec(item).status === 'completed').length
81
82      return { name: 'To-Do', detail: `${done}/${list.length} done` }
83    }
84    default:
85      break
86  }
87
88  if (tool.startsWith('mcp__')) {
89    const [, server = '', ...rest] = tool.split('__')
90    const detail = firstString(i)
91
92    return { name: rest.join('__') || tool, detail: detail === '' ? server : `${server} · ${detail}` }
93  }
94
95  return { name: tool, detail: firstString(i) }
96}
97
98/** The label a tool carries in counts and lists. */
99export const toolLabel = (tool: string): string => describeTool(tool, {}, '').name
100
101/** Lines added and removed by an Edit or Write result, when it says. */
102export const changeStats = (output: unknown): { added: number; removed: number } | undefined => {
103  const o = rec(output)
104  const git = rec(o.gitDiff)
105
106  if (typeof git.additions === 'number' && typeof git.deletions === 'number') {
107    return { added: git.additions, removed: git.deletions }
108  }
109
110  if (Array.isArray(o.structuredPatch)) {
111    let added = 0
112    let removed = 0
113
114    for (const hunk of o.structuredPatch) {
115      const lines = rec(hunk).lines
116
117      for (const line of Array.isArray(lines) ? lines : []) {
118        if (typeof line !== 'string') {
119          continue
120        }
121
122        if (line.startsWith('+')) {
123          added += 1
124        } else if (line.startsWith('-')) {
125          removed += 1
126        }
127      }
128    }
129
130    return { added, removed }
131  }
132
133  if (o.type === 'create' && typeof o.content === 'string') {
134    return { added: o.content.split('\n').length, removed: 0 }
135  }
136
137  return undefined
138}
139
140/** The `+3 -1` a changed file's row ends with. */
141export const diffTail = (t: T, stats: { added: number; removed: number }): RenderNode => {
142  const { Text } = t
143
144  return (
145    <Text>
146      <Text color={C.success}>{`+${stats.added}`}</Text>
147      <Text color={C.error}>{` -${stats.removed}`}</Text>
148    </Text>
149  )
150}
151
152/** What an interrupted call's row ends with. */
153export const interruptedTail = (t: T): RenderNode => {
154  const { Text } = t
155
156  return <Text color={C.warn}>interrupted</Text>
157}
158
159type HeaderInput = { status: Status; name: string; detail: string; tail?: RenderNode; frame: number }
160
161/** A tool call's row: status mark, name, what it works on. */
162export const toolHeader = (t: T, v: HeaderInput) => {
163  const { Box, Text } = t
164  const color =
165    v.status === 'running'
166      ? mix(C.primary, C.secondary, 0.5 - 0.5 * Math.cos(v.frame / 3))
167      : v.status === 'done'
168        ? C.success
169        : v.status === 'error'
170          ? C.error
171          : C.warn
172  const icon =
173    v.status === 'running'
174      ? (SPIN[v.frame % SPIN.length] ?? '●')
175      : v.status === 'done'
176        ? '✓'
177        : v.status === 'error'
178          ? '×'
179          : '⊘'
180
181  return (
182    <Box>
183      <Box width={2} flexShrink={0}>
184        <Text color={color}>{icon}</Text>
185      </Box>
186      <Box flexShrink={0} marginRight={1}>
187        <Text color={C.info} bold>
188          {v.name}
189        </Text>
190      </Box>
191      <Box flexGrow={1} flexShrink={1}>
192        <Text color={C.muted} wrap="truncate-end">
193          {v.detail === '' ? ' ' : v.detail}
194        </Text>
195      </Box>
196      {v.tail === undefined ? null : (
197        <Box flexShrink={0} marginLeft={1}>
198          {v.tail}
199        </Box>
200      )}
201    </Box>
202  )
203}
204
205type BodyOptions = { max: number; color?: string }
206
207/** Output under a tool row: a gutter, the first lines, and a count of the rest. */
208export const outputBody = (t: T, text: string, options: BodyOptions) => {
209  const { Box, Text } = t
210  const lines = clean(text).replace(/\s+$/, '').split('\n')
211  const shown = lines.slice(0, options.max)
212  const hidden = lines.length - shown.length
213
214  return (
215    <Box flexDirection="column" paddingLeft={2}>
216      {shown.map(line => (
217        <Box>
218          <Box width={2} flexShrink={0}>
219            <Text color={C.line}>│</Text>
220          </Box>
221          <Box flexGrow={1} flexShrink={1}>
222            {options.color === undefined ? (
223              <Text wrap="truncate-end">{line === '' ? ' ' : clip(line, 400)}</Text>
224            ) : (
225              <Text color={options.color} wrap="truncate-end">
226                {line === '' ? ' ' : clip(line, 400)}
227              </Text>
228            )}
229          </Box>
230        </Box>
231      ))}
232      {hidden > 0 ? (
233        <Box>
234          <Box width={2} flexShrink={0}>
235            <Text color={C.line}>╰</Text>
236          </Box>
237          <Text color={C.subtle}>{`… ${hidden} more line${hidden === 1 ? '' : 's'} (ctrl+o to expand)`}</Text>
238        </Box>
239      ) : null}
240    </Box>
241  )
242}
243
types/index.d.ts 43 lines
1export type FileChange = { path: string; added: number; removed: number }
2
3export type TodoItem = {
4  content: string
5  status: 'pending' | 'in_progress' | 'completed'
6  activeForm: string
7}
8
9export type LimitInfo = { kind: string; percentUsed: number }
10
11export type UsageInfo = {
12  percent: number | null
13  tokens: number | null
14  window: number
15  usd: number | null
16  limits: LimitInfo[]
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'crush-style': {
22      /** Animation frame, ticked while a turn runs. */
23      frame: number
24      /** `$.clock.now()` when the running turn began; 0 while idle. */
25      turnStartedAt: number
26      /** Files the session changed, newest first. */
27      files: FileChange[]
28      /** Tool calls made, by tool name. */
29      tools: Record<string, number>
30      /** The model's latest to-do list. */
31      todos: TodoItem[]
32      /** The first prompt, shortened: the session's title. */
33      title: string
34      /** The current git branch; '' outside a repository. */
35      branch: string
36      /** The model id, as /model shows it. */
37      model: string
38      /** Context, cost and rate-limit figures, as the status line has them. */
39      usage: UsageInfo | null
40    }
41  }
42}
43