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

怎么安装、怎么用,见仓库根目录的 README。这里写给想读或改代码的人。
这是一个 Claude Code 的 mod:用函数 hook 写成的插件,在 Claude Code 2.1.287 上开发。mod 的 API 仍是早期版本,以本机 Claude Code 写出的类型声明为准(见下文"类型检查")。
| 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。总开关放在 $.store 而不是 userConfig,原因是实测在 claude -p 会话里,$.config.list() 不列出任何插件配置行,$.config.set('<plugin>.<field>') 会直接报错。关闭时所有 hook 仍然注册、只是原样放行,并调用 $.ui.invalidate('ui.render') 让已画出的行重画;改动文件等统计照常记录。
动画计时器(120 ms 一帧)只在一轮对话进行中运行,turn.complete 时停止;热重载发生在一轮中间时,会从 $.state 里的开始时间恢复。
写 hook 时 claude plugin validate 会静态扫描源码,有两条规矩类型声明里看不出来:
$ 的辅助函数必须是本文件顶层的函数声明(或绑定到函数的 const),不能写在 register 里面,也不能从别的文件导入。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 按实际表现写。docs/*.svg 由 python docs/draw.py 生成,颜色和布局照搬 mod 的代码。界面改了就改脚本、重新生成,不要手改 SVG。
所有颜色都在 hooks/theme.tsx 顶部的 C 对象里,用的是 Charm 的调色板(Charple、Dolly、Bok 等)。正文不设颜色,跟随终端自己的前景色,所以亮色主题下也能读。
hooks/register.tsx 538 lines1import { 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}
538hooks/messages.tsx 107 lines1import { 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}
107hooks/sidebar.tsx 242 lines1import 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}
242hooks/theme.tsx 272 lines1import 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}
272hooks/tools.tsx 243 lines1import 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}
243types/index.d.ts 43 lines1export 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