SLOPSHOPPER

receipt

What each turn did, in a band above the prompt once it ends (files changed with lines, commands and failures, reads, subagents), and a toast when the model…

newpanebandguardcommandtoast
★ 5v0.2.1MITupdated 2026-10-09hoobnn/hoobnn-agent-mods/claude-code/receipt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · receipt
│ ┃ receipt-replay ✕ › fix the failing auth test and add an audit log call │ ┃ ▶ edit 1 of 3 1 2 3 │ ┃ src/auth.ts Edit +0 −0 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ (no diff to show) ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [ ◀ Prev ] [ Next ▶ ] [ Close ] ⏺ 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 │ │ › /receipt │ ⎿ receipt: Last turn 42s · Edited 3 +0 −0 · Commands 4 · 1 failed │ ⎿ receipt: │ ⎿ receipt: Files changed │ ⎿ receipt: src/auth.ts +0 −0 │ ⎿ receipt: src/audit.ts +0 −0 │ ⎿ receipt: src/cache.ts +0 −0 │ │ ✓ Last turn 42s · Edited 3 +0 −0 · Commands 4 · 1 failed · Reads 2 [ Replay ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✓ Last turn 42s · Edited 3 +0 −0 · Commands 4 · 1 failed · Reads 2 [ Replay ] ⟨Claude Code's own drawing⟩
Pane · receipt-replay
▶ edit 1 of 3 1 2 3 src/auth.ts Edit +0 −0 (no diff to show) [ ◀ Prev ] [ Next ▶ ] [ Close ]
README

receipt:Claude Code 回合回执

简体中文 · English

Claude 每轮回复结束后,输入框上方会留下一行回执:改了几个文件、增删多少行、跑了几条命令、有几条失败。不用翻记录,就知道这一轮做了什么。Claude 原地打转时还会弹提示。全程不消耗 token。

receipt:上一轮改了什么、跑了什么、读了什么

功能

  • 一行回执:先显示这一轮怎么结束的(✓ 正常回答、◼ 被中断、✗ 出错)和用时,再列出有内容的几项:改动的文件和增删行数、执行的命令和失败数、读取次数(Read、Grep、Glob)、开了几个子代理、产生了几条预警。
  • 子代理也算:子代理改的文件和跑的命令都计入开启它的这一轮。
  • 不打扰:没用工具的纯聊天轮不留回执,下一轮开始时回执自动收起;输入框弹出 / 或 @ 选择器时也会让开。
  • 改了没验证就标出来:这一轮最后一次改代码之后没有跑过测试、构建、lint 或类型检查(或者根本没跑),回执末尾显示黄色的「未验证」,/receipt 里也会写明。只改 Markdown、纯文本这类文档不算。识别 npm test、pytest、go test、cargo check、tsc、eslint、make、bash scripts/check.sh 等常见写法,包括 uv run、npx 这类前缀。
  • 逐处回放改动:回执末尾有「回放」按钮(ctrl+x Tab 聚焦横条后按 r),也可以输入 /receipt replay,打开面板逐处查看这一轮每次编辑的 diff:文件、增删行数、从第几行开始改,n / p 前后翻,c 或 Esc 关闭。diff 取自编辑实际执行后的结果,被拒绝或失败的编辑不会出现;子代理的编辑也在里面。每轮最多记录前 50 处,每处最多 120 行。
  • 原地打转提醒:这一轮进行中,出现下面两种情况会弹 toast,每段连续只提醒一次:
  • 同一个调用连续失败 repeatFailures 次(默认 3),中间没有任何改动。编辑文件或跑了会改东西的命令(如 sed -i、npm install)算在推进,计数重来;cat、ls 这类只读命令不算。
  • 同一个文件被改回之前的样子,达到 flipFlops 次(默认 2)。
  • 零 token:只在工具调用执行完后读取结果(Edit、Write、NotebookEdit 统计文件;有 git 行数用 git 的,没有就按补丁算;Bash 统计命令),不注册工具,不加提示词,不拦截调用。

安装

claude plugin marketplace add hoobnn/hoobnn-agent-mods
claude plugin install receipt@hoobnn-agent-mods

命令

  • /receipt:完整列出上一轮的回执:每个文件的增删行数(新建的文件标「新建」)、每条失败的命令、每条预警。
  • /receipt replay:打开回放面板,逐处查看上一轮每次编辑的 diff。
  • /receipt off、/receipt on:隐藏或显示回执。这个设置会写回 /config,以后的会话也会沿用。

选项

在 /config 里修改,或写在 ~/.claude/settings.json 的 pluginConfigs 里:

选项作用默认
visible两轮之间在输入框上方显示回执开
repeatFailures同一调用连续失败几次后提醒;0 关闭3
flipFlops同一文件来回改几次后提醒;0 关闭2
flagUnverified最后一次改代码之后没跑测试、构建或检查时,回执标「未验证」开
language界面语言:auto、en、zh-Hans、zh-Hant、ja、ko、es、fr、de、pt-BR、ruauto

language 为 auto 时,依次跟随 Claude Code 的 language 设置和系统语言环境,都没有时用英语。

同系列 mod

hoobnn-agent-mods 里还有任务进度条(todo-bar)、状态栏 HUD(hud)、运行动画和宠物(spinner)、Tailscale 节点状态(ts-band)和一言(hitokoto),可以搭配使用。

开发

  • hooks/register.tsx:钩子(会话开始时的语言和 /receipt,回合开始与结束,读取工具调用,命令和横条)。
  • hooks/ledger.ts:根据每次调用的内容生成回执,以及两条打转规则。
  • hooks/config.ts:选项,一次性读成类型化的 Config。
  • hooks/i18n.ts:各语言文案。
  • hooks/kit/:claude-code/kit 的副本;改源文件后运行 scripts/sync-kit.sh。
Source 9 files
hooks/register.tsx 316 lines
1// What the last turn did, in a band above the prompt once it ends, and a toast
2// when the main thread goes in circles. Read from the turn's own tool calls
3// once they have run: no tool of its own, nothing in the prompt, no tokens.
4import { atom, derive, read, update } from 'claude-code'
5import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
6
7import type { Receipt, Step } from '../types'
8import { readConfig } from './config'
9import { m, setLang } from './i18n'
10import { isPickerOpen, stackAbove } from './kit/band'
11import { resolveLanguage } from './kit/lang'
12import { persist, switchArg } from './kit/prefs'
13import type { Prefs } from './kit/prefs'
14import { NO_WATCH, addCall, finish, formatDuration, isEmpty, newReceipt, totals, watchCall } from './ledger'
15import type { Alert } from './ledger'
16
17const receipt = atom({ plugin: 'receipt', key: 'receipt' } as const, null as Receipt | null)
18const watch = atom({ plugin: 'receipt', key: 'watch' } as const, NO_WATCH)
19// The band shows between turns, for a turn that used a tool.
20const isShown = atom({ plugin: 'receipt', key: 'isShown' } as const, false)
21// Set this session by `/receipt` or session.start; null in a session resumed or
22// cleared, which gets no session.start: `isHidden` is the `visible` row's then.
23const hiddenSet = atom({ plugin: 'receipt', key: 'isHidden' } as const, null as boolean | null)
24// The `visible` row's, set in register.
25let isRowHidden = false
26const isHidden = derive([hiddenSet], set => set ?? isRowHidden)
27// True while a picker is open above the band (see kit/band).
28const isPicking = atom({ plugin: 'receipt', key: 'isPicking' } as const, false)
29// The replay pane: the edit it shows, and whether it is open (the band steps aside meanwhile).
30const replayAt = atom({ plugin: 'receipt', key: 'replayAt' } as const, 0)
31const isReplaying = atom({ plugin: 'receipt', key: 'isReplaying' } as const, false)
32
33const PANE = 'receipt-replay'
34// Rows the pane takes besides the diff: the header, the file, the buttons and the gaps.
35const PANE_CHROME = 6
36
37const SEP = ' · '
38
39/** The kit's hold on this mod's store and `/config` rows. */
40function prefsOf($: EngineInterface): Prefs {
41  return {
42    kept: key => $.store.get(key),
43    forget: key => $.store.delete(key),
44    write: (field, value) => $.config.set({ key: `receipt.${field}`, value }),
45  }
46}
47
48function alertText(alert: Alert): string {
49  return alert.kind === 'repeat' ? m('alert.repeat', { n: alert.n, label: alert.label }) : m('alert.flip', { n: alert.n, path: alert.path })
50}
51
52/** The receipt's headline: how the turn went and how long it took (so far, while it runs). */
53function headline(r: Receipt, now: number): string {
54  if (r.durationMs === null) return m('band.running', { d: formatDuration(now - r.startedAt) })
55  const d = formatDuration(r.durationMs)
56  return r.reason === 'aborted' ? m('band.aborted', { d }) : r.reason === 'answer' ? m('band.answer', { d }) : m('band.error', { d })
57}
58
59type Segment = { text: string; color?: string }
60
61/** The counts after the headline, each only when it has something to say; a count may be several segments. */
62function counts(r: Receipt, flagUnverified: boolean): Segment[][] {
63  const parts: Segment[][] = []
64  if (r.files.length > 0) {
65    const { added, removed } = totals(r)
66    parts.push([{ text: `${m('band.edited', { n: r.files.length })} ` }, { text: `+${added}`, color: 'green' }, { text: ' ' }, { text: `−${removed}`, color: 'red' }])
67  }
68  if (r.commands > 0) parts.push([{ text: m('band.commands', { n: r.commands }) }])
69  if (r.failed.length > 0) parts.push([{ text: m('band.failed', { n: r.failed.length }), color: 'red' }])
70  if (r.reads > 0) parts.push([{ text: m('band.reads', { n: r.reads }) }])
71  if (r.agents > 0) parts.push([{ text: m('band.agents', { n: r.agents }) }])
72  if (r.warnings.length > 0) parts.push([{ text: `⚠ ${r.warnings.length}`, color: 'yellow' }])
73  if (flagUnverified && r.isUnverified) parts.push([{ text: m('band.unverified'), color: 'yellow' }])
74  return parts
75}
76
77const plain = (part: Segment[]) => part.map(s => s.text).join('')
78
79/** `/receipt` alone: the headline, then every file, failed command and warning. */
80async function listing($: EngineInterface, flagUnverified: boolean): Promise<string> {
81  const r = await read($, receipt)
82  if (!r) return m('cmd.none')
83  const lines = [[headline(r, await $.clock.now()), ...counts(r, flagUnverified).map(plain)].join(SEP)]
84  if (r.files.length > 0) {
85    lines.push('', m('list.files'))
86    for (const f of r.files) lines.push(`  ${f.path}  +${f.added} −${f.removed}${f.isNew ? `  ${m('list.new')}` : ''}`)
87  }
88  if (r.failed.length > 0) lines.push('', m('list.failed'), ...r.failed.map(c => `  ✗ ${c}`))
89  if (r.warnings.length > 0) lines.push('', m('list.warnings'), ...r.warnings.map(w => `  ⚠ ${w}`))
90  if (flagUnverified && r.isUnverified) lines.push('', `⚠ ${m('list.unverified')}`)
91  if ((r.steps ?? []).length > 0) lines.push('', m('list.replay'))
92  return lines.join('\n')
93}
94
95/** Opens the replay on the turn's first edit; false when the last turn changed no file. */
96async function openReplay($: EngineInterface): Promise<boolean> {
97  const r = await read($, receipt)
98  if (!r || (r.steps ?? []).length === 0) return false
99  await update($, replayAt, () => 0)
100  // A band Button pressed holds the keys: the band steps aside first, so the pane can take them.
101  await update($, isReplaying, () => true)
102  await $.clock.sleep(150)
103  try {
104    await $.ui.open({ id: PANE, title: m('replay.title'), focus: true, closeOnEscape: true })
105  } catch (err) {
106    await update($, isReplaying, () => false)
107    throw err
108  }
109  return true
110}
111
112export const register: Register = (on, options) => {
113  const config = readConfig(options)
114  isRowHidden = !config.isVisible
115  const rules = { repeatFailures: config.repeatFailures, flipFlops: config.flipFlops }
116
117  on('session.start', async ($, e, next) => {
118    const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
119    const locale = await Promise.all([
120      $.env.get('LC_ALL').catch(() => undefined),
121      $.env.get('LC_MESSAGES').catch(() => undefined),
122      $.env.get('LANG').catch(() => undefined),
123    ])
124    setLang(resolveLanguage(config.language, settings.language, locale))
125    await $.command.register({ name: 'receipt', description: m('cmd.description'), argumentHint: '[replay|off|on]' })
126    await update($, hiddenSet, () => !config.isVisible)
127    return next(e)
128  })
129
130  // Only the main loop raises turn.start: a new receipt, and the last one leaves the band.
131  on('turn.start', async ($, e, next) => {
132    const now = await $.clock.now()
133    await update($, receipt, () => newReceipt(e.turnId, now))
134    await update($, watch, () => NO_WATCH)
135    await update($, isShown, () => false)
136    return next(e)
137  })
138
139  // Every call of the turn once it has run, its subagents' included; a refused call did nothing.
140  on('tool.call', async ($, e, next) => {
141    const ran = await next(e)
142    if (ran.deny !== undefined) return ran
143    const was = await read($, receipt)
144    if (!was || was.durationMs !== null) return ran
145    const { tool, tool_use_id: _, agentId, ...input } = e as unknown as Record<string, unknown> & { tool: string }
146    const call = { tool, input, result: ran.result, isError: ran.isError === true, isReadOnly: ran.isReadOnly === true }
147    const cwd = await $.session.cwd().catch(() => '')
148    let next_ = addCall(was, call, cwd, agentId === undefined)
149    if (agentId === undefined) {
150      const seen = watchCall(await read($, watch), call, rules, cwd)
151      await update($, watch, () => seen.watch)
152      if (seen.alert) {
153        const text = alertText(seen.alert)
154        next_ = { ...next_, warnings: [...next_.warnings, text] }
155        $.ui.toast(`⚠ ${text}`, { timeoutMs: 8000 })
156      }
157    }
158    await update($, receipt, () => next_)
159    return ran
160  })
161
162  on('turn.complete', async ($, e, next) => {
163    if (e.agentId !== undefined) return next(e)
164    const was = await read($, receipt)
165    if (was && was.durationMs === null) {
166      const done = finish(was, e.durationMs, e.reason)
167      await update($, receipt, () => done)
168      await update($, isShown, () => !isEmpty(done))
169    }
170    return next(e)
171  })
172
173  on('command.run', { command: 'receipt' }, async ($, e) => {
174    const arg = e.args.trim().toLowerCase()
175    if (arg === 'replay') {
176      if (!(await openReplay($))) return { text: m('cmd.noEdits') }
177      return { text: m('cmd.replaying', { n: ((await read($, receipt))?.steps ?? []).length }) }
178    }
179    if (arg !== 'off' && arg !== 'on') return { text: await listing($, config.flagUnverified) }
180    const was = await read($, isHidden)
181    const hidden = await update($, hiddenSet, () => switchArg(arg, was))
182    if (hidden !== was) await persist(prefsOf($), 'visible', !hidden)
183    return { text: m(hidden ? 'cmd.hidden' : 'cmd.shown') }
184  })
185
186  // A picker (`/` commands, `@` files) opens above the band: the band steps aside meanwhile.
187  on('prompt.edit', async ($, e, next) => {
188    const box = await next(e)
189    const isOpen = isPickerOpen(box.text, box.cursor)
190    if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
191    return box
192  })
193  on('prompt.submit', async ($, e, next) => {
194    if (await read($, isPicking)) await update($, isPicking, () => false)
195    return next(e)
196  })
197
198  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
199    const r = await read($, receipt)
200    if (e.props.hasSurvey || r === null || !(await read($, isShown)) || (await read($, isHidden)) || (await read($, isPicking))) return next(e)
201    if (await read($, isReplaying)) return next(e)
202    const ui = $.ui.resolve(e)
203    return stackAbove(ui, drawReceipt(ui, r, config.flagUnverified, () => void openReplay($)), await next(e))
204  })
205
206  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
207    const steps = (await read($, receipt))?.steps ?? []
208    const total = steps.length
209    const k = Math.max(0, Math.min(await read($, replayAt), total - 1))
210    const go = (to: number) => void update($, replayAt, () => Math.max(0, Math.min(to, total - 1)))
211    const close = () => void $.ui.close({ id: PANE })
212    const rows = Math.max(4, Math.min(30, e.props.scroll.bodyRows - PANE_CHROME))
213    return drawReplay($.ui.resolve(e), steps, k, rows, e.props.bodyColumns, { go, close })
214  })
215
216  // However the pane closes (its button, Escape, the person's close mark), the band comes back.
217  on('ui.close', async ($, e, next) => {
218    if (e.id === PANE) await update($, isReplaying, () => false)
219    return next(e)
220  })
221}
222
223const DIFF_COLOR: Record<string, string> = { '+': 'green', '-': 'red' }
224
225/** One edit: where it stands among the turn's, its file and counts, its diff, and the buttons that move. */
226function drawReplay(
227  ui: Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>,
228  steps: Step[],
229  k: number,
230  rows: number,
231  columns: number,
232  act: { go: (to: number) => void; close: () => void },
233): RenderElement {
234  const { Box, Text, Button } = ui
235  const step = steps[k]
236  if (!step) return <Text dimColor>{m('cmd.noEdits')}</Text>
237  const shown = step.lines.slice(0, rows)
238  const more = step.lines.length - shown.length + step.more
239  // A strip of the steps, the one shown inverted, while they fit on a row.
240  const strip = steps.length > 1 && steps.length * 4 <= columns ? steps : []
241  return (
242    <Box flexDirection="column">
243      <Box flexDirection="row" columnGap={2}>
244        <Text bold color="magenta">{`▶ ${m('replay.step', { k: k + 1, n: steps.length })}`}</Text>
245        {strip.length > 0 ? (
246          <Text>
247            {strip.map((_, i) => (
248              <Text key={String(i)} inverse={i === k} dimColor={i !== k}>{` ${i + 1} `}</Text>
249            ))}
250          </Text>
251        ) : null}
252      </Box>
253      <Box flexDirection="row" columnGap={1}>
254        <Box flexShrink={1}>
255          <Text bold color="cyan" wrap="truncate-start">{step.path}</Text>
256        </Box>
257        <Box flexShrink={0}>
258          <Text>
259            <Text dimColor>{`${step.tool}${step.isNew ? ` · ${m('list.new')}` : ''} `}</Text>
260            <Text color="green">{`+${step.added}`}</Text>
261            <Text> </Text>
262            <Text color="red">{`−${step.removed}`}</Text>
263          </Text>
264        </Box>
265      </Box>
266      <Box flexDirection="column" marginTop={1}>
267        {shown.length === 0 ? <Text dimColor>{m('replay.empty')}</Text> : null}
268        {shown.map((line, i) =>
269          line.startsWith('@') ? (
270            <Text key={String(i)} dimColor>{`⋯ ${m('replay.line', { n: line.slice(1) })}`}</Text>
271          ) : (
272            <Text key={String(i)} color={DIFF_COLOR[line[0] ?? '']} dimColor={!DIFF_COLOR[line[0] ?? '']} wrap="truncate-end">
273              {`${line[0] ?? ' '} ${line.slice(1)}`}
274            </Text>
275          ),
276        )}
277        {more > 0 ? <Text dimColor>{m('replay.more', { n: more })}</Text> : null}
278      </Box>
279      <Box flexDirection="row" columnGap={2} marginTop={1}>
280        <Button key="prev" label={`◀ ${m('replay.prev')}`} hotkey="p" dimColor={k === 0} onPress={() => act.go(k - 1)} />
281        <Button key="next" label={`${m('replay.next')} ▶`} hotkey="n" autoFocus variant="primary" onPress={() => act.go(k + 1)} />
282        <Button key="close" label={m('replay.close')} hotkey="c" role="dismiss" onPress={act.close} />
283      </Box>
284    </Box>
285  )
286}
287
288/** One row: a mark for how the turn ended, the headline, the counts, then a button to replay the edits. */
289function drawReceipt(ui: Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>, r: Receipt, flagUnverified: boolean, replay: () => void): RenderElement {
290  const { Box, Text, Button } = ui
291  const mark = r.reason === 'answer' ? { glyph: '✓', color: 'green' } : r.reason === 'aborted' ? { glyph: '◼', color: 'yellow' } : { glyph: '✗', color: 'red' }
292  return (
293    <Box flexDirection="row" columnGap={1}>
294      <Text color={mark.color} bold>{mark.glyph}</Text>
295      <Box flexGrow={1} flexShrink={1}>
296        <Text wrap="truncate-end">
297          <Text dimColor>{headline(r, 0)}</Text>
298          {counts(r, flagUnverified).map((p, i) => (
299            <Text key={String(i)}>
300              <Text dimColor>{SEP}</Text>
301              {p.map((seg, j) => (
302                <Text key={String(j)} color={seg.color}>{seg.text}</Text>
303              ))}
304            </Text>
305          ))}
306        </Text>
307      </Box>
308      {(r.steps ?? []).length > 0 ? (
309        <Box flexShrink={0}>
310          <Button key="replay" label={m('band.replay')} hotkey="r" dimColor onPress={replay} />
311        </Box>
312      ) : null}
313    </Box>
314  )
315}
316
hooks/config.ts 27 lines
1// receipt's options (plugin.json `userConfig`), read once into a typed config.
2import type { PluginOptions } from 'claude-code'
3
4import { count, flag, text } from './kit/options'
5
6export type Config = {
7  isVisible: boolean
8  /** The same call failing this many times in a row toasts; 0 off. */
9  repeatFailures: number
10  /** A file edited back to what it was this many times toasts; 0 off. */
11  flipFlops: number
12  /** Mark a turn that changed code after its last test, build or check. */
13  flagUnverified: boolean
14  /** `auto` or a language; the kit resolves it (kit/lang.ts). */
15  language: string
16}
17
18export function readConfig(options: PluginOptions): Config {
19  return {
20    isVisible: flag(options.visible, true),
21    repeatFailures: count(options.repeatFailures, 3, { min: 0, isInteger: true }),
22    flipFlops: count(options.flipFlops, 2, { min: 0, isInteger: true }),
23    flagUnverified: flag(options.flagUnverified, true),
24    language: text(options.language, 'auto'),
25  }
26}
27
hooks/i18n.ts 397 lines
1// receipt's messages; the language is resolved by the kit (kit/lang.ts).
2// Counts are written label first (`Commands 6`), so no language needs plural forms.
3import { createMessages } from './kit/lang'
4import type { Lang } from './kit/lang'
5
6export { parseLanguage, resolveLanguage } from './kit/lang'
7
8export type Key =
9  | 'cmd.description'
10  | 'cmd.hidden'
11  | 'cmd.shown'
12  | 'cmd.none'
13  | 'band.answer'
14  | 'band.aborted'
15  | 'band.error'
16  | 'band.running'
17  | 'band.edited'
18  | 'band.commands'
19  | 'band.failed'
20  | 'band.reads'
21  | 'band.agents'
22  | 'band.unverified'
23  | 'list.files'
24  | 'list.failed'
25  | 'list.warnings'
26  | 'list.new'
27  | 'list.unverified'
28  | 'band.replay'
29  | 'list.replay'
30  | 'cmd.noEdits'
31  | 'cmd.replaying'
32  | 'replay.title'
33  | 'replay.step'
34  | 'replay.line'
35  | 'replay.more'
36  | 'replay.empty'
37  | 'replay.prev'
38  | 'replay.next'
39  | 'replay.close'
40  | 'alert.repeat'
41  | 'alert.flip'
42
43export const MESSAGES: Record<Lang, Record<Key, string>> = {
44  en: {
45    'cmd.description': "What the last turn did: files changed, commands, failures, warnings; replay steps through the edits; off / on hides or shows the band (kept across sessions)",
46    'cmd.hidden': 'Turn receipt hidden',
47    'cmd.shown': 'Turn receipt shown',
48    'cmd.none': 'No turn yet: the receipt appears once a turn has used a tool',
49    'band.answer': 'Last turn {d}',
50    'band.aborted': 'Interrupted after {d}',
51    'band.error': 'Ended by an error after {d}',
52    'band.running': 'Turn running, {d} so far',
53    'band.edited': 'Edited {n}',
54    'band.commands': 'Commands {n}',
55    'band.failed': '{n} failed',
56    'band.reads': 'Reads {n}',
57    'band.agents': 'Agents {n}',
58    'band.unverified': 'unverified',
59    'list.files': 'Files changed',
60    'list.failed': 'Failed commands',
61    'list.warnings': 'Warnings',
62    'list.new': 'new',
63    'list.unverified': 'Code changed after the last test, build or check run, or none ran.',
64    'band.replay': 'Replay',
65    'list.replay': "/receipt replay steps through each edit's diff.",
66    'cmd.noEdits': 'The last turn changed no file',
67    'cmd.replaying': 'Replaying {n} edits',
68    'replay.title': 'Replay',
69    'replay.step': 'edit {k} of {n}',
70    'replay.line': 'line {n}',
71    'replay.more': '… {n} more lines',
72    'replay.empty': '(no diff to show)',
73    'replay.prev': 'Prev',
74    'replay.next': 'Next',
75    'replay.close': 'Close',
76    'alert.repeat': 'Failed {n} times in a row: {label}',
77    'alert.flip': '{path}: edited back and forth {n} times',
78  },
79  'zh-Hans': {
80    'cmd.description': '上一轮做了什么:改动的文件、命令、失败与预警;replay 逐处回放改动;off / on 隐藏或显示回执(跨会话保持)',
81    'cmd.hidden': '回合回执已隐藏',
82    'cmd.shown': '回合回执已显示',
83    'cmd.none': '还没有回合:一轮用过工具后会出现回执',
84    'band.answer': '上一轮 {d}',
85    'band.aborted': '已中断,用时 {d}',
86    'band.error': '因出错结束,用时 {d}',
87    'band.running': '本轮进行中,已 {d}',
88    'band.edited': '改动 {n} 个文件',
89    'band.commands': '命令 {n}',
90    'band.failed': '{n} 失败',
91    'band.reads': '读取 {n}',
92    'band.agents': '子代理 {n}',
93    'band.unverified': '未验证',
94    'list.files': '改动的文件',
95    'list.failed': '失败的命令',
96    'list.warnings': '预警',
97    'list.new': '新建',
98    'list.unverified': '最后一次改代码之后没有跑过测试、构建或检查。',
99    'band.replay': '回放',
100    'list.replay': '/receipt replay 可逐处查看每次改动的 diff。',
101    'cmd.noEdits': '上一轮没有改动文件',
102    'cmd.replaying': '回放 {n} 处改动',
103    'replay.title': '改动回放',
104    'replay.step': '第 {k}/{n} 处',
105    'replay.line': '第 {n} 行',
106    'replay.more': '… 还有 {n} 行',
107    'replay.empty': '(没有可显示的 diff)',
108    'replay.prev': '上一处',
109    'replay.next': '下一处',
110    'replay.close': '关闭',
111    'alert.repeat': '同一调用已连续失败 {n} 次:{label}',
112    'alert.flip': '{path}:来回改了 {n} 次',
113  },
114  'zh-Hant': {
115    'cmd.description': '上一輪做了什麼:改動的檔案、命令、失敗與預警;replay 逐處回放改動;off / on 隱藏或顯示回執(跨工作階段保留)',
116    'cmd.hidden': '回合回執已隱藏',
117    'cmd.shown': '回合回執已顯示',
118    'cmd.none': '還沒有回合:一輪用過工具後會出現回執',
119    'band.answer': '上一輪 {d}',
120    'band.aborted': '已中斷,用時 {d}',
121    'band.error': '因出錯結束,用時 {d}',
122    'band.running': '本輪進行中,已 {d}',
123    'band.edited': '改動 {n} 個檔案',
124    'band.commands': '命令 {n}',
125    'band.failed': '{n} 失敗',
126    'band.reads': '讀取 {n}',
127    'band.agents': '子代理 {n}',
128    'band.unverified': '未驗證',
129    'list.files': '改動的檔案',
130    'list.failed': '失敗的命令',
131    'list.warnings': '預警',
132    'list.new': '新建',
133    'list.unverified': '最後一次改程式碼之後沒有跑過測試、建置或檢查。',
134    'band.replay': '回放',
135    'list.replay': '/receipt replay 可逐處查看每次改動的 diff。',
136    'cmd.noEdits': '上一輪沒有改動檔案',
137    'cmd.replaying': '回放 {n} 處改動',
138    'replay.title': '改動回放',
139    'replay.step': '第 {k}/{n} 處',
140    'replay.line': '第 {n} 行',
141    'replay.more': '… 還有 {n} 行',
142    'replay.empty': '(沒有可顯示的 diff)',
143    'replay.prev': '上一處',
144    'replay.next': '下一處',
145    'replay.close': '關閉',
146    'alert.repeat': '同一呼叫已連續失敗 {n} 次:{label}',
147    'alert.flip': '{path}:來回改了 {n} 次',
148  },
149  ja: {
150    'cmd.description': '直前のターンで何をしたか:変更ファイル、コマンド、失敗、警告。replay で変更を順に再生。off / on で表示を切り替え(セッションをまたいで保持)',
151    'cmd.hidden': 'ターンのレシートを非表示にしました',
152    'cmd.shown': 'ターンのレシートを表示しました',
153    'cmd.none': 'まだターンがありません。ツールを使ったターンの後に表示されます',
154    'band.answer': '直前のターン {d}',
155    'band.aborted': '中断({d})',
156    'band.error': 'エラーで終了({d})',
157    'band.running': 'ターン実行中、{d} 経過',
158    'band.edited': '変更 {n} ファイル',
159    'band.commands': 'コマンド {n}',
160    'band.failed': '失敗 {n}',
161    'band.reads': '読み取り {n}',
162    'band.agents': 'サブエージェント {n}',
163    'band.unverified': '未検証',
164    'list.files': '変更したファイル',
165    'list.failed': '失敗したコマンド',
166    'list.warnings': '警告',
167    'list.new': '新規',
168    'list.unverified': '最後のコード変更のあと、テスト・ビルド・チェックを実行していません。',
169    'band.replay': '再生',
170    'list.replay': '/receipt replay で変更ごとの diff を順に確認できます。',
171    'cmd.noEdits': '直前のターンはファイルを変更していません',
172    'cmd.replaying': '{n} 件の変更を再生',
173    'replay.title': '変更の再生',
174    'replay.step': '{k}/{n} 件目',
175    'replay.line': '{n} 行目',
176    'replay.more': '… ほか {n} 行',
177    'replay.empty': '(表示できる diff はありません)',
178    'replay.prev': '前へ',
179    'replay.next': '次へ',
180    'replay.close': '閉じる',
181    'alert.repeat': '同じ呼び出しが {n} 回続けて失敗:{label}',
182    'alert.flip': '{path}:{n} 回行ったり来たりの編集',
183  },
184  ko: {
185    'cmd.description': '직전 턴에서 한 일: 변경 파일, 명령, 실패, 경고. replay로 변경 다시 보기. off / on으로 숨기기·표시(세션 간 유지)',
186    'cmd.hidden': '턴 영수증을 숨겼습니다',
187    'cmd.shown': '턴 영수증을 표시했습니다',
188    'cmd.none': '아직 턴이 없습니다. 도구를 쓴 턴이 끝나면 나타납니다',
189    'band.answer': '직전 턴 {d}',
190    'band.aborted': '중단됨 ({d})',
191    'band.error': '오류로 종료 ({d})',
192    'band.running': '턴 진행 중, {d} 경과',
193    'band.edited': '파일 변경 {n}',
194    'band.commands': '명령 {n}',
195    'band.failed': '실패 {n}',
196    'band.reads': '읽기 {n}',
197    'band.agents': '서브에이전트 {n}',
198    'band.unverified': '미검증',
199    'list.files': '변경한 파일',
200    'list.failed': '실패한 명령',
201    'list.warnings': '경고',
202    'list.new': '새 파일',
203    'list.unverified': '마지막 코드 변경 이후 테스트·빌드·검사를 실행하지 않았습니다.',
204    'band.replay': '다시 보기',
205    'list.replay': '/receipt replay로 변경마다 diff를 차례로 볼 수 있습니다.',
206    'cmd.noEdits': '직전 턴에서 변경된 파일이 없습니다',
207    'cmd.replaying': '변경 {n}건 다시 보기',
208    'replay.title': '변경 다시 보기',
209    'replay.step': '{k}/{n}번째',
210    'replay.line': '{n}번째 줄',
211    'replay.more': '… {n}줄 더',
212    'replay.empty': '(표시할 diff 없음)',
213    'replay.prev': '이전',
214    'replay.next': '다음',
215    'replay.close': '닫기',
216    'alert.repeat': '같은 호출이 {n}번 연속 실패: {label}',
217    'alert.flip': '{path}: {n}번 되돌렸다 고쳤다 반복',
218  },
219  es: {
220    'cmd.description': 'Qué hizo el último turno: archivos, comandos, fallos y avisos; replay recorre las ediciones; off / on oculta o muestra la barra (se mantiene entre sesiones)',
221    'cmd.hidden': 'Resumen del turno oculto',
222    'cmd.shown': 'Resumen del turno visible',
223    'cmd.none': 'Aún no hay turnos: el resumen aparece tras un turno que use herramientas',
224    'band.answer': 'Último turno {d}',
225    'band.aborted': 'Interrumpido tras {d}',
226    'band.error': 'Terminó por un error tras {d}',
227    'band.running': 'Turno en curso, {d} hasta ahora',
228    'band.edited': 'Archivos editados: {n}',
229    'band.commands': 'Comandos: {n}',
230    'band.failed': 'fallidos: {n}',
231    'band.reads': 'Lecturas: {n}',
232    'band.agents': 'Subagentes: {n}',
233    'band.unverified': 'sin verificar',
234    'list.files': 'Archivos cambiados',
235    'list.failed': 'Comandos fallidos',
236    'list.warnings': 'Avisos',
237    'list.new': 'nuevo',
238    'list.unverified': 'No se ejecutaron pruebas, compilación ni comprobaciones tras el último cambio de código.',
239    'band.replay': 'Repetir',
240    'list.replay': '/receipt replay recorre el diff de cada edición.',
241    'cmd.noEdits': 'El último turno no cambió archivos',
242    'cmd.replaying': 'Repitiendo {n} ediciones',
243    'replay.title': 'Repetición',
244    'replay.step': 'edición {k} de {n}',
245    'replay.line': 'línea {n}',
246    'replay.more': '… {n} líneas más',
247    'replay.empty': '(sin diff que mostrar)',
248    'replay.prev': 'Anterior',
249    'replay.next': 'Siguiente',
250    'replay.close': 'Cerrar',
251    'alert.repeat': 'Falló {n} veces seguidas: {label}',
252    'alert.flip': '{path}: editado de ida y vuelta {n} veces',
253  },
254  fr: {
255    'cmd.description': "Ce qu'a fait le dernier tour : fichiers, commandes, échecs, alertes ; replay rejoue les modifications ; off / on masque ou affiche la barre (conservé entre les sessions)",
256    'cmd.hidden': 'Récapitulatif du tour masqué',
257    'cmd.shown': 'Récapitulatif du tour affiché',
258    'cmd.none': "Pas encore de tour : le récapitulatif apparaît après un tour qui utilise un outil",
259    'band.answer': 'Dernier tour {d}',
260    'band.aborted': 'Interrompu après {d}',
261    'band.error': 'Terminé par une erreur après {d}',
262    'band.running': 'Tour en cours, {d} écoulées',
263    'band.edited': 'Fichiers modifiés : {n}',
264    'band.commands': 'Commandes : {n}',
265    'band.failed': 'échecs : {n}',
266    'band.reads': 'Lectures : {n}',
267    'band.agents': 'Sous-agents : {n}',
268    'band.unverified': 'non vérifié',
269    'list.files': 'Fichiers modifiés',
270    'list.failed': 'Commandes en échec',
271    'list.warnings': 'Alertes',
272    'list.new': 'nouveau',
273    'list.unverified': 'Aucun test, build ni vérification lancé après la dernière modification du code.',
274    'band.replay': 'Rejouer',
275    'list.replay': '/receipt replay parcourt le diff de chaque modification.',
276    'cmd.noEdits': "Le dernier tour n'a modifié aucun fichier",
277    'cmd.replaying': 'Relecture de {n} modifications',
278    'replay.title': 'Relecture',
279    'replay.step': 'modification {k} sur {n}',
280    'replay.line': 'ligne {n}',
281    'replay.more': '… {n} lignes de plus',
282    'replay.empty': '(aucun diff à afficher)',
283    'replay.prev': 'Précédent',
284    'replay.next': 'Suivant',
285    'replay.close': 'Fermer',
286    'alert.repeat': 'Échec {n} fois de suite : {label}',
287    'alert.flip': '{path} : modifié dans un sens puis dans l’autre {n} fois',
288  },
289  de: {
290    'cmd.description': 'Was der letzte Durchlauf getan hat: Dateien, Befehle, Fehlschläge, Warnungen; replay spielt die Änderungen ab; off / on blendet die Leiste aus oder ein (bleibt über Sitzungen erhalten)',
291    'cmd.hidden': 'Durchlauf-Beleg ausgeblendet',
292    'cmd.shown': 'Durchlauf-Beleg eingeblendet',
293    'cmd.none': 'Noch kein Durchlauf: Der Beleg erscheint nach einem Durchlauf mit Werkzeugen',
294    'band.answer': 'Letzter Durchlauf {d}',
295    'band.aborted': 'Abgebrochen nach {d}',
296    'band.error': 'Durch einen Fehler beendet nach {d}',
297    'band.running': 'Durchlauf läuft, bisher {d}',
298    'band.edited': 'Geänderte Dateien: {n}',
299    'band.commands': 'Befehle: {n}',
300    'band.failed': 'fehlgeschlagen: {n}',
301    'band.reads': 'Gelesen: {n}',
302    'band.agents': 'Subagenten: {n}',
303    'band.unverified': 'ungeprüft',
304    'list.files': 'Geänderte Dateien',
305    'list.failed': 'Fehlgeschlagene Befehle',
306    'list.warnings': 'Warnungen',
307    'list.new': 'neu',
308    'list.unverified': 'Nach der letzten Codeänderung lief kein Test, Build oder Check.',
309    'band.replay': 'Abspielen',
310    'list.replay': '/receipt replay geht den Diff jeder Änderung durch.',
311    'cmd.noEdits': 'Die letzte Runde hat keine Datei geändert',
312    'cmd.replaying': '{n} Änderungen abspielen',
313    'replay.title': 'Wiedergabe',
314    'replay.step': 'Änderung {k} von {n}',
315    'replay.line': 'Zeile {n}',
316    'replay.more': '… {n} weitere Zeilen',
317    'replay.empty': '(kein Diff zum Anzeigen)',
318    'replay.prev': 'Zurück',
319    'replay.next': 'Weiter',
320    'replay.close': 'Schließen',
321    'alert.repeat': '{n}-mal hintereinander fehlgeschlagen: {label}',
322    'alert.flip': '{path}: {n}-mal hin und zurück geändert',
323  },
324  'pt-BR': {
325    'cmd.description': 'O que o último turno fez: arquivos, comandos, falhas e avisos; replay percorre as edições; off / on oculta ou mostra a barra (mantido entre sessões)',
326    'cmd.hidden': 'Resumo do turno oculto',
327    'cmd.shown': 'Resumo do turno visível',
328    'cmd.none': 'Ainda não há turnos: o resumo aparece após um turno que use ferramentas',
329    'band.answer': 'Último turno {d}',
330    'band.aborted': 'Interrompido após {d}',
331    'band.error': 'Encerrado por um erro após {d}',
332    'band.running': 'Turno em andamento, {d} até agora',
333    'band.edited': 'Arquivos editados: {n}',
334    'band.commands': 'Comandos: {n}',
335    'band.failed': 'falhas: {n}',
336    'band.reads': 'Leituras: {n}',
337    'band.agents': 'Subagentes: {n}',
338    'band.unverified': 'não verificado',
339    'list.files': 'Arquivos alterados',
340    'list.failed': 'Comandos com falha',
341    'list.warnings': 'Avisos',
342    'list.new': 'novo',
343    'list.unverified': 'Nenhum teste, build ou verificação rodou depois da última mudança no código.',
344    'band.replay': 'Rever',
345    'list.replay': '/receipt replay percorre o diff de cada edição.',
346    'cmd.noEdits': 'O último turno não alterou arquivos',
347    'cmd.replaying': 'Revendo {n} edições',
348    'replay.title': 'Revisão',
349    'replay.step': 'edição {k} de {n}',
350    'replay.line': 'linha {n}',
351    'replay.more': '… mais {n} linhas',
352    'replay.empty': '(nenhum diff para mostrar)',
353    'replay.prev': 'Anterior',
354    'replay.next': 'Próxima',
355    'replay.close': 'Fechar',
356    'alert.repeat': 'Falhou {n} vezes seguidas: {label}',
357    'alert.flip': '{path}: editado de um lado para o outro {n} vezes',
358  },
359  ru: {
360    'cmd.description': 'Что сделал последний ход: файлы, команды, сбои, предупреждения; replay показывает правки по очереди; off / on скрывает или показывает панель (сохраняется между сессиями)',
361    'cmd.hidden': 'Сводка хода скрыта',
362    'cmd.shown': 'Сводка хода показана',
363    'cmd.none': 'Ходов пока нет: сводка появится после хода с инструментами',
364    'band.answer': 'Последний ход {d}',
365    'band.aborted': 'Прерван через {d}',
366    'band.error': 'Завершён ошибкой через {d}',
367    'band.running': 'Ход идёт, уже {d}',
368    'band.edited': 'Изменено файлов: {n}',
369    'band.commands': 'Команды: {n}',
370    'band.failed': 'сбоев: {n}',
371    'band.reads': 'Чтений: {n}',
372    'band.agents': 'Субагенты: {n}',
373    'band.unverified': 'не проверено',
374    'list.files': 'Изменённые файлы',
375    'list.failed': 'Команды со сбоем',
376    'list.warnings': 'Предупреждения',
377    'list.new': 'новый',
378    'list.unverified': 'После последнего изменения кода не запускались тесты, сборка или проверки.',
379    'band.replay': 'Повтор',
380    'list.replay': '/receipt replay показывает diff каждой правки по очереди.',
381    'cmd.noEdits': 'Прошлый ход не менял файлы',
382    'cmd.replaying': 'Повтор {n} правок',
383    'replay.title': 'Повтор правок',
384    'replay.step': 'правка {k} из {n}',
385    'replay.line': 'строка {n}',
386    'replay.more': '… ещё {n} строк',
387    'replay.empty': '(нет diff для показа)',
388    'replay.prev': 'Назад',
389    'replay.next': 'Далее',
390    'replay.close': 'Закрыть',
391    'alert.repeat': 'Сбой {n} раз подряд: {label}',
392    'alert.flip': '{path}: правки туда и обратно {n} раз',
393  },
394}
395
396export const { m, setLang } = createMessages(MESSAGES)
397
hooks/kit/band.tsx 31 lines
1// Generated from claude-code/kit/band.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's rows in the band above the prompt. Several mods draw there, so each
3// stacks its rows over what the rest of the chain drew (`await next(e)`)
4// instead of replacing it, two cells in, as the engine indents the lines under
5// the prompt. The hook stays the mod's own (`on('ui.render', ...)` is spelled
6// in the hooks module): it yields to a survey, draws, and hands both here.
7import type { Elements, RenderElement } from 'claude-code'
8
9/**
10 * Whether the prompt's draft has a picker open: a slash command being named
11 * (`/sp`, before any space) or a file being mentioned (`@src/a`, the word at the
12 * cursor). The engine draws the picker above the band, so a band steps aside
13 * while one is open and the picker sits right on the prompt.
14 */
15export function isPickerOpen(text: string, cursor = text.length): boolean {
16  const before = text.slice(0, cursor)
17  return /^\/\S*$/.test(before) || /(^|\s)@\S*$/.test(before)
18}
19
20export function stackAbove(ui: Pick<Elements['terminal'], 'Box'>, mine: RenderElement, below: RenderElement): RenderElement {
21  const { Box } = ui
22  return (
23    <Box flexDirection="column">
24      <Box flexDirection="column" paddingLeft={2}>
25        {mine}
26      </Box>
27      {below}
28    </Box>
29  )
30}
31
hooks/kit/lang.ts 68 lines
1// Generated from claude-code/kit/lang.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's language: the `language` option, or with `auto` Claude Code's own
3// `language` setting (free text: "简体中文", "Japanese", "pt-BR"), then the locale
4// (LC_ALL, LC_MESSAGES, LANG), then English. The messages stay the mod's own:
5// `createMessages(MESSAGES)` gives it `m` over them.
6
7export type Lang = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'es' | 'fr' | 'de' | 'pt-BR' | 'ru'
8
9export const LANGS: readonly Lang[] = ['en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'ru']
10
11/** The `language` option's values, as a manifest's `options` lists them. */
12export const LANGUAGE_OPTIONS = ['auto', ...LANGS] as const
13
14// Names a person may write, folded (lowercase, no spaces, `-`, `_` or `.`).
15const NAMES: Record<string, Lang> = {
16  en: 'en', english: 'en', 英语: 'en', 英文: 'en', 英語: 'en',
17  zh: 'zh-Hans', zhcn: 'zh-Hans', zhsg: 'zh-Hans', zhhans: 'zh-Hans', chinese: 'zh-Hans',
18  simplifiedchinese: 'zh-Hans', 中文: 'zh-Hans', 简体中文: 'zh-Hans', 简体: 'zh-Hans', 汉语: 'zh-Hans', 普通话: 'zh-Hans',
19  zhtw: 'zh-Hant', zhhk: 'zh-Hant', zhmo: 'zh-Hant', zhhant: 'zh-Hant', traditionalchinese: 'zh-Hant',
20  繁體中文: 'zh-Hant', 繁体中文: 'zh-Hant', 正體中文: 'zh-Hant', 繁體: 'zh-Hant', 繁体: 'zh-Hant',
21  ja: 'ja', japanese: 'ja', 日本語: 'ja', 日语: 'ja',
22  ko: 'ko', korean: 'ko', 한국어: 'ko', 韩语: 'ko', 韓語: 'ko',
23  es: 'es', spanish: 'es', español: 'es', espanol: 'es', castellano: 'es', 西班牙语: 'es',
24  fr: 'fr', french: 'fr', français: 'fr', francais: 'fr', 法语: 'fr',
25  de: 'de', german: 'de', deutsch: 'de', 德语: 'de',
26  pt: 'pt-BR', ptbr: 'pt-BR', portuguese: 'pt-BR', brazilianportuguese: 'pt-BR', português: 'pt-BR', portugues: 'pt-BR', 葡萄牙语: 'pt-BR',
27  ru: 'ru', russian: 'ru', русский: 'ru', 俄语: 'ru',
28}
29
30const fold = (s: string) => s.trim().toLowerCase().replace(/[\s_.-]/g, '')
31
32/** "简体中文", "zh_TW.UTF-8", "es-MX", "Deutsch" → a language the mods have, else null. */
33export function parseLanguage(text: unknown): Lang | null {
34  if (typeof text !== 'string' || !text.trim()) return null
35  const bare = text.split('.')[0]!.split('@')[0]!
36  const named = NAMES[fold(bare)]
37  if (named) return named
38  // A tag with a region or script this table does not list: its first subtag.
39  const [primary = '', ...rest] = bare.trim().toLowerCase().split(/[-_\s]/)
40  if (primary === 'zh') return rest.some(s => ['tw', 'hk', 'mo', 'hant'].includes(s)) ? 'zh-Hant' : 'zh-Hans'
41  return NAMES[primary] ?? null
42}
43
44/** The language to draw in: the option unless `auto`, then the setting, then the locale. */
45export function resolveLanguage(option: unknown, setting: unknown, locale: readonly (string | undefined)[]): Lang {
46  if (typeof option === 'string' && option !== 'auto' && (LANGS as readonly string[]).includes(option)) return option as Lang
47  return parseLanguage(setting) ?? locale.map(parseLanguage).find(Boolean) ?? 'en'
48}
49
50
51export type Params = Record<string, string | number>
52
53/**
54 * `m(key, params?, lang?)` over a mod's table: the message in the current
55 * language (English when missing), its `{placeholders}` filled.
56 */
57export function createMessages<K extends string>(table: Record<Lang, Record<K, string>>) {
58  let current: Lang = 'en'
59  return {
60    m: (key: K, params: Params = {}, lang: Lang = current): string =>
61      (table[lang][key] ?? table.en[key]).replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? '')),
62    setLang: (lang: Lang): void => {
63      current = lang
64    },
65    lang: (): Lang => current,
66  }
67}
68
hooks/kit/prefs.ts 60 lines
1// Generated from claude-code/kit/prefs.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// `/config` is where a mod's settings live: a slash command that changes one
3// (`/ts off`, `/spinner neon`) writes that row as the person would in the menu,
4// so the menu shows it, settings.json keeps it, and the engine reloads the
5// module with it. The command also sets the mod's session state, so the change
6// shows at once, and stays for the session where no row can be written
7// (`claude -p` has no plugin rows).
8//
9// The engine follows `$` only within the hooks module's own file, so the mod
10// hands the kit closures over `$` (a `Prefs`) rather than `$` itself.
11import type { ConfigValue } from 'claude-code'
12
13/** The mod's store and its own `/config` rows, as closures the hooks module builds over `$`. */
14export type Prefs = {
15  kept: (key: string) => Promise<unknown>
16  forget: (key: string) => Promise<void>
17  /** `$.config.set` on `<plugin>.<field>`. */
18  write: (field: string, value: ConfigValue) => Promise<{ deny?: string }>
19}
20
21/** Writes one of the mod's rows; false when no row took it. */
22export async function persist(prefs: Prefs, field: string, value: ConfigValue): Promise<boolean> {
23  const result = await prefs.write(field, value).catch(() => null)
24  return result !== null && result.deny === undefined
25}
26
27/** What a value kept in the store under an older version becomes: a row and its value, or nothing. */
28export type Move = (kept: unknown) => readonly [field: string, value: ConfigValue] | null
29
30/** The rows values older versions kept in `$.store` stand for, which the session applies at once. */
31export async function keptRows(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<Record<string, ConfigValue>> {
32  const rows: Record<string, ConfigValue> = {}
33  for (const [key, move] of Object.entries(moves)) {
34    const kept = await prefs.kept(key)
35    const target = kept === undefined ? null : move(kept)
36    if (target) rows[target[0]] = target[1]
37  }
38  return rows
39}
40
41/**
42 * Moves those values to their `/config` rows, once: a key is dropped only
43 * after its row took the value (or it has none to give). Run it after
44 * `session.start`'s `next(e)`: the plugin's rows join `/config` as the
45 * session comes up, and the write brings a reload that applies them.
46 */
47export async function migrateStore(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<void> {
48  for (const [key, move] of Object.entries(moves)) {
49    const kept = await prefs.kept(key)
50    if (kept === undefined) continue
51    const target = move(kept)
52    if (!target || (await persist(prefs, target[0], target[1]))) await prefs.forget(key)
53  }
54}
55
56/** `off` → true, `on` → false, anything else flips `isOff`. */
57export function switchArg(arg: string, isOff: boolean): boolean {
58  return arg === 'off' ? true : arg === 'on' ? false : !isOff
59}
60
hooks/ledger.ts 228 lines
1// What a turn did, from the calls it made once they have run, and the loop
2// rules over the main thread's calls. Pure: register.tsx hands in each call.
3import type { FileChange, Receipt, Step, Watch } from '../types'
4
5/** One call as it ran: the tool, its arguments, what it answered; `isReadOnly` when the tool held it read-only. */
6export type Call = { tool: string; input: Record<string, unknown>; result: unknown; isError: boolean; isReadOnly?: boolean }
7
8/** A loop the rules caught: a call failing again and again, or a file edited back and forth. */
9export type Alert = { kind: 'repeat'; label: string; n: number } | { kind: 'flip'; path: string; n: number }
10
11/** The thresholds; 0 turns a rule off. */
12export type Rules = { repeatFailures: number; flipFlops: number }
13
14const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
15const READS = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead'])
16const SHELLS = new Set(['Bash', 'PowerShell'])
17const AGENTS = new Set(['Agent', 'Task'])
18// A command that checks the code, at the head of the line or after `&&`, `;`, `|`, `(`,
19// past env assignments and a runner (`uv run`, `npx`, `bash`): a test runner, a type
20// checker or linter, a build, or a project's own check script.
21const CHECK = new RegExp(
22  String.raw`(?:^|[;&|(]\s*)(?:\S+=\S*\s+)*(?:(?:bash|sh|zsh|time|npx|bunx|uv\s+run|poetry\s+run|pnpm\s+exec|bundle\s+exec)\s+)*(?:` +
23    [
24      String.raw`(?:npm|pnpm|yarn|bun|deno)\s+(?:run\s+)?(?:test|build|lint|check|typecheck|tsc)\b`,
25      String.raw`(?:jest|vitest|mocha|pytest|rspec|phpunit|tox|nox|tsc|eslint|biome|ruff|mypy|pyright|rubocop|golangci-lint|shellcheck|make|playwright\s+test)\b`,
26      String.raw`python3?\s+-m\s+(?:pytest|unittest|mypy)\b`,
27      String.raw`go\s+(?:test|build|vet)\b`,
28      String.raw`cargo\s+(?:test|check|build|clippy)\b`,
29      String.raw`(?:swift|dotnet|mix|mvn|gradle|\.\/gradlew|zig|xcodebuild)\s+(?:test|build)\b`,
30      String.raw`claude\s+plugin\s+(?:test|validate)\b`,
31      String.raw`\S*(?:check|test|verify)\S*\.sh\b`,
32    ].join('|') +
33    ')',
34)
35// Files whose edits nothing runs to check: prose.
36const PROSE = /\.(?:md|mdx|markdown|txt|rst|adoc)$/i
37
38// Edits kept per file for the back-and-forth rule.
39const KEEP_EDITS = 20
40// Cells a call's label keeps, in a toast and in /receipt.
41const LABEL_CELLS = 80
42// What /receipt replay keeps of a turn: its first edits, each diff's first lines.
43export const KEEP_STEPS = 50
44const KEEP_LINES = 120
45
46const str = (v: unknown) => (typeof v === 'string' ? v : '')
47const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim()
48const clip = (s: string) => (s.length > LABEL_CELLS ? `${s.slice(0, LABEL_CELLS - 1)}…` : s)
49
50export function newReceipt(turnId: string, now: number): Receipt {
51  return {
52    turnId,
53    startedAt: now,
54    durationMs: null,
55    reason: null,
56    files: [],
57    commands: 0,
58    failed: [],
59    errors: 0,
60    reads: 0,
61    agents: 0,
62    warnings: [],
63    checks: 0,
64    isUnverified: false,
65    steps: [],
66  }
67}
68
69export const NO_WATCH: Watch = { failKey: null, failCount: 0, edits: {}, flips: {} }
70
71/** `path` relative to `cwd` when it lies under it. */
72export function relative(path: string, cwd: string): string {
73  const base = cwd.replace(/\/+$/, '')
74  return base && path.startsWith(base + '/') ? path.slice(base.length + 1) : path
75}
76
77/** A short label for a call: the command for a shell, else the tool and its file. */
78export function labelOf(call: Pick<Call, 'tool' | 'input'>, cwd: string): string {
79  const { tool, input } = call
80  if (SHELLS.has(tool)) return clip(oneLine(str(input.command)))
81  const file = str(input.file_path) || str(input.notebook_path) || str(input.path)
82  return clip(file ? `${tool}: ${relative(file, cwd)}` : tool)
83}
84
85/** The lines an edit added and removed: git's own count, else the patch's, else a new file's length. */
86export function changeOf(call: Call, cwd: string): FileChange | null {
87  if (!EDITS.has(call.tool) || call.isError) return null
88  const path = str(call.input.file_path) || str(call.input.notebook_path)
89  if (!path) return null
90  const result = (call.result ?? {}) as Record<string, unknown>
91  const isNew = result.type === 'create'
92  const git = result.gitDiff as { additions?: unknown; deletions?: unknown } | undefined
93  if (typeof git?.additions === 'number' && typeof git.deletions === 'number') {
94    return { path: relative(path, cwd), added: git.additions, removed: git.deletions, isNew }
95  }
96  let added = 0
97  let removed = 0
98  for (const hunk of Array.isArray(result.structuredPatch) ? (result.structuredPatch as { lines?: unknown }[]) : []) {
99    for (const line of Array.isArray(hunk.lines) ? hunk.lines : []) {
100      if (typeof line !== 'string') continue
101      if (line.startsWith('+')) added++
102      else if (line.startsWith('-')) removed++
103    }
104  }
105  if (added === 0 && removed === 0 && isNew) added = str(result.content).split('\n').length
106  return { path: relative(path, cwd), added, removed, isNew }
107}
108
109/**
110 * The edit's diff for `/receipt replay`, from the hunks its result carried
111 * (`structuredPatch`), else a new file's content.
112 */
113export function stepOf(call: Call, change: FileChange): Step {
114  const result = (call.result ?? {}) as Record<string, unknown>
115  const lines: string[] = []
116  for (const hunk of Array.isArray(result.structuredPatch) ? (result.structuredPatch as Record<string, unknown>[]) : []) {
117    const at = typeof hunk.newStart === 'number' ? hunk.newStart : typeof hunk.oldStart === 'number' ? hunk.oldStart : 0
118    lines.push(`@${at}`)
119    for (const line of Array.isArray(hunk.lines) ? hunk.lines : []) if (typeof line === 'string') lines.push(line)
120  }
121  if (lines.length === 0 && change.isNew && str(result.content)) {
122    lines.push('@1', ...str(result.content).replace(/\n$/, '').split('\n').map(l => `+${l}`))
123  }
124  return { ...change, tool: call.tool, lines: lines.slice(0, KEEP_LINES), more: Math.max(0, lines.length - KEEP_LINES) }
125}
126
127/** Whether a shell command checks the code: a test, build, lint or type check. */
128export function isCheck(command: string): boolean {
129  return CHECK.test(command)
130}
131
132/** The receipt with one more call counted. */
133export function addCall(receipt: Receipt, call: Call, cwd: string, isMain: boolean): Receipt {
134  const next = { ...receipt }
135  if (call.isError) next.errors++
136  const change = changeOf(call, cwd)
137  if (change) {
138    const had = receipt.files.find(f => f.path === change.path)
139    next.files = had
140      ? receipt.files.map(f => (f === had ? { ...f, added: f.added + change.added, removed: f.removed + change.removed } : f))
141      : [...receipt.files, change]
142    if (!PROSE.test(change.path)) next.isUnverified = true
143    const steps = receipt.steps ?? []
144    if (steps.length < KEEP_STEPS) next.steps = [...steps, stepOf(call, change)]
145  } else if (SHELLS.has(call.tool)) {
146    next.commands++
147    if (call.isError) next.failed = [...receipt.failed, labelOf(call, cwd)]
148    // A check that ran counts whatever it found: its failure is on the receipt already.
149    if (isCheck(str(call.input.command))) {
150      next.checks = (receipt.checks ?? 0) + 1
151      next.isUnverified = false
152    }
153  } else if (READS.has(call.tool)) {
154    next.reads++
155  } else if (AGENTS.has(call.tool) && isMain) {
156    next.agents++
157  }
158  return next
159}
160
161export function finish(receipt: Receipt, durationMs: number, reason: string): Receipt {
162  return { ...receipt, durationMs, reason }
163}
164
165/** Lines added and removed across the turn's files. */
166export function totals(receipt: Receipt): { added: number; removed: number } {
167  return receipt.files.reduce((t, f) => ({ added: t.added + f.added, removed: t.removed + f.removed }), { added: 0, removed: 0 })
168}
169
170/** Whether the turn did anything worth a receipt: a chat-only turn has none. */
171export function isEmpty(receipt: Receipt): boolean {
172  return receipt.files.length === 0 && receipt.commands === 0 && receipt.reads === 0 && receipt.agents === 0 && receipt.errors === 0
173}
174
175// What identifies a call for the repeat rule: the tool and its arguments.
176function keyOf(call: Call): string {
177  if (SHELLS.has(call.tool)) return `${call.tool}:${oneLine(str(call.input.command))}`
178  const { description: _, ...rest } = call.input
179  return `${call.tool}:${JSON.stringify(rest)}`
180}
181
182/**
183 * The main thread's watch after one more call, and the alert it raises, if any.
184 *
185 * Repeat: the same call failing `repeatFailures` times in a row with nothing
186 * changed in between: no edit, no shell command that was not read-only
187 * (re-running a test after `sed -i` or an edit is work, not a loop).
188 * Back and forth: an edit that puts back exactly what an earlier one took out
189 * of the same file, the `flipFlops`th time.
190 */
191export function watchCall(watch: Watch, call: Call, rules: Rules, cwd: string): { watch: Watch; alert: Alert | null } {
192  const edited = EDITS.has(call.tool) && !call.isError
193  const changed = edited || (SHELLS.has(call.tool) && !call.isError && call.isReadOnly !== true)
194  let next: Watch = watch
195  let alert: Alert | null = null
196
197  if (call.isError) {
198    const key = keyOf(call)
199    const failCount = watch.failKey === key ? watch.failCount + 1 : 1
200    next = { ...next, failKey: key, failCount }
201    if (rules.repeatFailures > 0 && failCount === rules.repeatFailures) alert = { kind: 'repeat', label: labelOf(call, cwd), n: failCount }
202  } else if (changed || keyOf(call) === watch.failKey) {
203    next = { ...next, failKey: null, failCount: 0 }
204  }
205
206  if (edited && (call.tool === 'Edit' || call.tool === 'MultiEdit')) {
207    const path = relative(str(call.input.file_path), cwd)
208    const pairs =
209      call.tool === 'Edit'
210        ? [{ from: str(call.input.old_string), to: str(call.input.new_string) }]
211        : (Array.isArray(call.input.edits) ? (call.input.edits as Record<string, unknown>[]) : []).map(e => ({ from: str(e.old_string), to: str(e.new_string) }))
212    const before = watch.edits[path] ?? []
213    const isFlip = pairs.some(p => p.from !== p.to && before.some(b => b.from === p.to && b.to === p.from))
214    const flips = isFlip ? (watch.flips[path] ?? 0) + 1 : (watch.flips[path] ?? 0)
215    next = { ...next, edits: { ...watch.edits, [path]: [...before, ...pairs].slice(-KEEP_EDITS) }, flips: { ...watch.flips, [path]: flips } }
216    if (isFlip && rules.flipFlops > 0 && flips === rules.flipFlops) alert = { kind: 'flip', path, n: flips }
217  }
218  return { watch: next, alert }
219}
220
221/** 42s, 3m 12s, 1h 05m. */
222export function formatDuration(ms: number): string {
223  const sec = Math.max(0, Math.round(ms / 1000))
224  if (sec < 60) return `${sec}s`
225  if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`
226  return `${Math.floor(sec / 3600)}h ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}m`
227}
228
hooks/kit/options.ts 31 lines
1// Generated from claude-code/kit/options.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// Readers for `register`'s options: each takes the raw value and the field's
3// default, so a mod's `readConfig` is one typed line per `userConfig` field.
4// The engine has validated the type already; these hold the ranges.
5
6export function text(value: unknown, fallback = ''): string {
7  return typeof value === 'string' ? value.trim() : fallback
8}
9
10export function flag(value: unknown, fallback: boolean): boolean {
11  return typeof value === 'boolean' ? value : fallback
12}
13
14/** A finite number, clamped to `min` / `max` and floored with `isInteger`. */
15export function count(
16  value: unknown,
17  fallback: number,
18  range: { min?: number; max?: number; isInteger?: boolean } = {},
19): number {
20  let n = typeof value === 'number' && Number.isFinite(value) ? value : fallback
21  if (range.isInteger) n = Math.floor(n)
22  if (range.min !== undefined) n = Math.max(range.min, n)
23  if (range.max !== undefined) n = Math.min(range.max, n)
24  return n
25}
26
27/** One of `values`, else the fallback. */
28export function oneOf<const T extends string>(value: unknown, values: readonly T[], fallback: T): T {
29  return values.find(v => v === value) ?? fallback
30}
31
types/index.d.ts 55 lines
1/** One file a turn changed: the lines it gained and lost, and whether the turn made it. */
2export type FileChange = { path: string; added: number; removed: number; isNew: boolean }
3
4/**
5 * One edit as `/receipt replay` steps through it: the file, the tool, what it
6 * added and removed, and its diff as the edit's result carried it: lines that
7 * start `+`, `-` or a space, and `@<n>` where a hunk starts at line n.
8 * `more` counts the diff's lines past what was kept.
9 */
10export type Step = { path: string; tool: string; isNew: boolean; added: number; removed: number; lines: string[]; more: number }
11
12/**
13 * What one main-thread turn did, from its tool calls (its subagents' included):
14 * the files it changed, the commands it ran and those that failed, the reads,
15 * the subagents it started, the loops it was warned about, and whether code
16 * changed after the last command that checks it (a test, build, lint or type check).
17 * `durationMs` and `reason` are set when the turn ends.
18 */
19export type Receipt = {
20  turnId: string
21  startedAt: number
22  durationMs: number | null
23  reason: string | null
24  files: FileChange[]
25  commands: number
26  failed: string[]
27  errors: number
28  reads: number
29  agents: number
30  warnings: string[]
31  /** Commands that check the code: tests, builds, linters, type checkers. */
32  checks: number
33  /** A code file (not prose) changed after the last check, or with none run. */
34  isUnverified: boolean
35  /** Each edit in order, for `/receipt replay`; absent in a receipt kept from before 0.2. */
36  steps?: Step[]
37}
38
39/** The main thread's recent calls, as the loop rules read them. */
40export type Watch = {
41  /** The last call that failed and how many times in a row it has, no file changed between. */
42  failKey: string | null
43  failCount: number
44  /** Each file's recent edits, oldest first. */
45  edits: Record<string, { from: string; to: string }[]>
46  /** Each file's edits that put back what an earlier edit took out. */
47  flips: Record<string, number>
48}
49
50declare module 'claude-code' {
51  interface PluginState {
52    receipt: { receipt: Receipt | null; watch: Watch; isHidden: boolean | null; isPicking: boolean; isShown: boolean; replayAt: number; isReplaying: boolean }
53  }
54}
55