SLOPSHOPPER

changes-dashboard

右侧常驻“当前改动”侧栏(Git 未提交改动 + Diff)和输入框上方的请求状态栏

newpanebandguardcommandprocess
★ 1v0.1.0no licenseupdated 2026-10-09zzzpyyy/changes-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · changes-dashboard
│ ┃ 当前改动 ✕ › fix the failing auth test and add an audit log call │ ┃ c: 改动 s: 搜索 │ j: 下个 k: 上个 │ ┃ 搜索 : 输入文字后回车,在仓库文件里查找 ⏎ 搜 ⏺ Read(src/auth.ts) │ ┃ ── 结果 ───────────────────────────────────… ⎿ Read 6 lines │ ┃ 按字面查找;查询全小写时忽略大小写。包含未跟 ⏺ Update(src/auth.ts) │ ┃ 踪文件,跳过二进制文件。 ⎿ Added 2 lines, removed 1 line │ ┃ ── 提交记录 ───────────────────────────────… ⏺ Bash(bun test) │ ┃ 选择一条结果,查看它的提交记录。 ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /changes │ ⎿ changes-dashboard: “当前改动”侧栏已打开。Esc 回到输入框,Ctrl+X Tab 再次聚焦。 │ │ ● 完成 claude-opus-5-5 上下文 97k/200k 49% c: 改动 i: 收起 最近一次 入 暂无 出 暂无 缓存读 暂无 缓存写 暂无 耗时 暂无 请求 暂无 工具 暂无 本轮 入 暂无 出 暂无 缓存读 暂无 缓存写 暂无 耗时 42.0s 请求 0 工具 9 会话 入 暂无 出 暂无 缓存读 暂无 缓存写 暂无 耗时 — 请求 0 工具 9 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● 完成 claude-opus-5-5 上下文 97k/200k 49% c: 改动 i: 收起 最近一次 入 暂无 出 暂无 缓存读 暂无 缓存写 暂无 耗时 暂无 请求 暂无 工具 暂无 本轮 入 暂无 出 暂无 缓存读 暂无 缓存写 暂无 耗时 42.0s 请求 0 工具 9 会话 入 暂无 出 暂无 缓存读 暂无 缓存写 暂无 耗时 — 请求 0 工具 9
Pane · 当前改动
c: 改动 s: 搜索 │ j: 下个 k: 上个 搜索 : 输入文字后回车,在仓库文件里查找 ⏎ 搜索 ── 结果 ──────────────────────────────────────────────── 按字面查找;查询全小写时忽略大小写。包含未跟踪文件,跳过二进 制文件。 ── 提交记录 ──────────────────────────────────────────── 选择一条结果,查看它的提交记录。
README

changes-dashboard

Claude Code 终端版的 mod。需要 Claude Code 2.1.291 或更新版本。

  • 右侧侧栏“当前改动”:列出 Git 工作区所有未提交改动(已暂存、未暂存、未跟踪),带行号和颜色显示 Diff。
  • 侧栏“搜索”页:在仓库文件里搜文字,选中一条结果,显示那一行每次被谁、什么时候改过。
  • 输入框上方的状态栏:模型、状态、上下文用量,以及最近一次请求、本轮、会话三种口径的 token 用量。

安装

在 Claude Code 终端的输入框里运行:

/plugin install changes-dashboard --marketplace zzzpyyy/changes-dashboard

提示添加插件市场时按 y,再按 Enter 选用户级安装。装完立即生效,不用重启。

从压缩包安装

解压到一个固定的目录,例如 ~/mods/changes-dashboard,然后任选一种方式。

只在这次启动里用:

claude --plugin-dir ~/mods/changes-dashboard

长期使用:在 Claude Code 里把这个目录加成本地插件市场,再安装。

/plugin marketplace add ~/mods/changes-dashboard
/plugin install changes-dashboard@changes-dashboard

用法

  • /changes:打开侧栏并聚焦。Claude 第一次改文件时侧栏也会自动打开,手动关过就不再自动打开。
  • /search 文字:搜索并打开侧栏的搜索页。
  • /request-info:在对话里输出请求用量表。
  • Ctrl+X Tab:在状态栏、侧栏、输入框之间切换焦点。Esc 回到输入框。
  • 侧栏有焦点时:c 改动页,s 搜索页,j / k 下一个 / 上一个,d / u 翻页,r 刷新。

可选:快捷键开关侧栏

在 ~/.claude/keybindings.json 里加上下面这段,之后在输入框里按 Ctrl+X C 就能开关侧栏:

{
  "bindings": [
    { "context": "Global", "bindings": { "ctrl+x c": "app:toggleReplTab" } }
  ]
}

限制

  • 侧栏要停靠在右侧,需要全屏模式且终端至少 110 列;更窄时显示在输入框上方。
  • 列出的是全部未提交改动,不区分是不是 Claude 这一轮改的。
  • 最多列 500 个文件、300 条搜索结果、一行的 10 次提交记录。
  • 统计只算主对话,子代理不计入;会话累计从 mod 加载时开始,/clear 后清零。
Source 3 files
hooks/register.tsx 983 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register, RenderElement, SessionContextUsage, TurnStepResult } from 'claude-code'
3
4import type { Detail, FileArea, FileEntry, GrepHit, History, Phase, Search, Snapshot, Stats, Usage4 } from '../types'
5import {
6  LOG_DATE, LOG_FORMAT, addUsage, buildEntries, cleanLine, clipW, diffArgv, fitPath, fmtMs, fmtTokens, grepArgv,
7  keepSelection, padW, parseBlame, parseDiffRows, parseGrep, parseLog, parseNoIndexNumstat, parseNumstat,
8  parseStatus, step, toDiffBody, width,
9} from './git'
10import type { DiffRow, NumStat } from './git'
11
12const PANE = 'changes'
13const TITLE = '当前改动'
14const GIT = ['git', '--no-optional-locks', '-c', 'core.quotepath=off']
15// ponytail: 文件数上限,超大工作区(未忽略的 node_modules)只列前面这些。
16const MAX_FILES = 500
17const MAX_UNTRACKED_STATS = 200
18
19const FRESH_STATS: Stats = {
20  model: null,
21  phase: 'idle',
22  turnStartedAt: null,
23  turnDurationMs: null,
24  turnRequests: 0,
25  turnTools: 0,
26  turnUsage: null,
27  last: null,
28  session: { requests: 0, tools: 0, usage: null },
29  context: { tokens: null, window: null, percent: null },
30}
31
32const snapshotA = atom({ plugin: 'changes-dashboard', key: 'snapshot' } as const, { kind: 'loading' })
33const selectedA = atom({ plugin: 'changes-dashboard', key: 'selected' } as const, null)
34const detailA = atom({ plugin: 'changes-dashboard', key: 'detail' } as const, null)
35const scrollA = atom({ plugin: 'changes-dashboard', key: 'scroll' } as const, { id: null, diff: 0, list: 0 })
36const statsA = atom({ plugin: 'changes-dashboard', key: 'stats' } as const, FRESH_STATS)
37const nowA = atom({ plugin: 'changes-dashboard', key: 'now' } as const, 0)
38const compactA = atom({ plugin: 'changes-dashboard', key: 'compact' } as const, false)
39const modeA = atom({ plugin: 'changes-dashboard', key: 'mode' } as const, 'changes')
40const EMPTY_SEARCH: Search = { query: '', status: 'idle', hits: [], isCut: false, reason: null }
41const searchA = atom({ plugin: 'changes-dashboard', key: 'search' } as const, EMPTY_SEARCH)
42const hitSelA = atom({ plugin: 'changes-dashboard', key: 'hitSel' } as const, null)
43const hitScrollA = atom({ plugin: 'changes-dashboard', key: 'hitScroll' } as const, 0)
44const historyA = atom({ plugin: 'changes-dashboard', key: 'history' } as const, null)
45// ponytail: 搜索最多留 300 条结果、提交记录最多 10 条;要更多再加翻页。
46const MAX_HITS = 300
47const MAX_COMMITS = 10
48
49type $T = EngineInterface
50
51const AREA_LABEL: Record<FileArea, string> = { staged: '已暂存', unstaged: '未暂存', untracked: '未跟踪' }
52const PHASE_LABEL: Record<Phase, string> = {
53  idle: '等待',
54  requesting: '请求中',
55  tool: '工具执行中',
56  done: '完成',
57  aborted: '中断',
58  refused: '已拒绝',
59  error: '出错',
60}
61const PHASE_COLOR: Record<Phase, string> = {
62  idle: 'inactive',
63  requesting: 'claude',
64  tool: 'warning',
65  done: 'success',
66  aborted: 'warning',
67  refused: 'error',
68  error: 'error',
69}
70
71function codeColor(code: string): string {
72  if (code === 'A' || code === '?') return 'success'
73  if (code === 'D' || code === 'U') return 'error'
74  if (code === 'R' || code === 'C') return 'suggestion'
75  return 'warning'
76}
77
78function clamp(n: number, lo: number, hi: number): number {
79  return Math.max(lo, Math.min(hi, n))
80}
81
82function firstLine(s: string): string {
83  return s.trim().split('\n')[0] ?? ''
84}
85
86function toUsage4(u: ModelUsage): Usage4 {
87  return {
88    input: u.input_tokens,
89    output: u.output_tokens,
90    cacheRead: u.cache_read_input_tokens,
91    cacheWrite: u.cache_creation_input_tokens,
92  }
93}
94
95/** 列表区的一行:分组标题,或一个文件。 */
96type ListRow = { kind: 'head'; label: string } | { kind: 'file'; file: FileEntry }
97
98function listRowsOf(files: readonly FileEntry[]): ListRow[] {
99  const rows: ListRow[] = []
100  for (const area of ['staged', 'unstaged', 'untracked'] as const) {
101    const group = files.filter(f => f.area === area)
102    if (group.length === 0) continue
103    rows.push({ kind: 'head', label: `${AREA_LABEL[area]} ${group.length}` })
104    for (const file of group) rows.push({ kind: 'file', file })
105  }
106  return rows
107}
108
109/** 上一次绘制时各区域的位置和大小,滚动和 j/k 按它换算。 */
110const layout = { listTop: 2, listRows: 0, listLen: 0, diffRows: 0, diffLen: 0, detailId: null as string | null }
111
112function listIndexOf(files: readonly FileEntry[], id: string | null): number {
113  return listRowsOf(files).findIndex(r => r.kind === 'file' && r.file.id === id)
114}
115
116function listOffsetFor(index: number, offset: number): number {
117  if (index < 0 || layout.listRows <= 0) return offset
118  if (index < offset) return index
119  if (index >= offset + layout.listRows) return index - layout.listRows + 1
120  return offset
121}
122
123// 模块变量:热重载后清零,只放可丢的东西。
124let isRunning = false
125let isAgain = false
126let pending: { cancel: () => void } | undefined
127let ticker: { cancel: () => void } | undefined
128let rowsCache: { source: string; rows: DiffRow[] } | undefined
129
130function git($: $T, cwd: string, args: string[]) {
131  return $.process.run([...GIT, ...args], { cwd, timeoutMs: 20000 })
132}
133
134async function scan($: $T): Promise<Snapshot> {
135  const cwd = await $.session.cwd()
136  let top
137  try {
138    top = await git($, cwd, ['rev-parse', '--show-toplevel'])
139  } catch (err) {
140    return { kind: 'error', reason: `无法运行 git:${String(err)}` }
141  }
142  if (top.exitCode !== 0) return { kind: 'not-git', reason: firstLine(top.stderr) || cwd }
143  const root = top.stdout.trim()
144  const [st, cached, work, branch] = await Promise.all([
145    git($, root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
146    git($, root, ['diff', '--cached', '--numstat', '-z', '-M']),
147    git($, root, ['diff', '--numstat', '-z']),
148    git($, root, ['branch', '--show-current']),
149  ])
150  if (st.exitCode !== 0) return { kind: 'error', reason: firstLine(st.stderr) || 'git status 失败' }
151  const status = parseStatus(st.stdout).slice(0, MAX_FILES)
152  const untracked = new Map<string, NumStat>()
153  const paths = status.filter(s => s.xy === '??').map(s => s.path).slice(0, MAX_UNTRACKED_STATS)
154  // ponytail: 每个未跟踪文件跑一次 git diff --no-index,每批 16 个。
155  for (let i = 0; i < paths.length; i += 16) {
156    await Promise.all(paths.slice(i, i + 16).map(async path => {
157      const r = await git($, root, ['diff', '--no-index', '--numstat', '--', '/dev/null', path]).catch(() => undefined)
158      const stat = r && parseNoIndexNumstat(r.stdout)
159      if (stat) untracked.set(path, stat)
160    }))
161  }
162  return {
163    kind: 'ok',
164    root,
165    branch: branch.stdout.trim() || 'HEAD(游离)',
166    files: buildEntries(status, parseNumstat(cached.stdout), parseNumstat(work.stdout), untracked),
167  }
168}
169
170async function setDetail($: $T, detail: Detail | null) {
171  const prev = await read($, detailA)
172  if (JSON.stringify(prev) !== JSON.stringify(detail)) await update($, detailA, () => detail)
173}
174
175async function loadDetail($: $T, id: string | null, showLoading: boolean) {
176  if (id === null) return setDetail($, null)
177  const snap = await read($, snapshotA)
178  const entry = snap.kind === 'ok' ? snap.files.find(f => f.id === id) : undefined
179  if (snap.kind !== 'ok' || entry === undefined) return setDetail($, null)
180  if (entry.isBinary) return setDetail($, { id, kind: 'binary' })
181  if (showLoading) await setDetail($, { id, kind: 'loading' })
182  let detail: Detail
183  try {
184    const r = await git($, snap.root, diffArgv(entry))
185    // --no-index 有差异时退出码是 1,不算失败。
186    if (r.exitCode > 1) detail = { id, kind: 'error', reason: firstLine(r.stderr) || `git diff 退出码 ${r.exitCode}` }
187    else detail = { id, ...toDiffBody(r.stdout, r.isStdoutTruncated) }
188  } catch (err) {
189    detail = { id, kind: 'error', reason: String(err) }
190  }
191  // 读的过程中换了文件,就丢掉这次结果。
192  if ((await read($, selectedA)) === id) await setDetail($, detail)
193}
194
195async function refreshOnce($: $T) {
196  const snap = await scan($)
197  const prev = await read($, snapshotA)
198  if (JSON.stringify(prev) !== JSON.stringify(snap)) await update($, snapshotA, () => snap)
199  const current = await read($, selectedA)
200  const next = keepSelection(snap.kind === 'ok' ? snap.files : [], current)
201  if (next !== current) {
202    await update($, selectedA, () => next)
203    await update($, scrollA, s => ({ ...s, id: next, diff: 0 }))
204  }
205  // 同一个文件刷新时不显示“读取中”,避免闪烁;阅读位置保持不变。
206  await loadDetail($, next, next !== current)
207}
208
209async function refresh($: $T) {
210  if (isRunning) {
211    isAgain = true
212    return
213  }
214  isRunning = true
215  try {
216    do {
217      isAgain = false
218      await refreshOnce($)
219    } while (isAgain)
220  } finally {
221    isRunning = false
222  }
223}
224
225/** Claude 连续改文件时合并成一次刷新。 */
226function scheduleRefresh($: $T) {
227  pending?.cancel()
228  pending = $.clock.after(400, () => {
229    pending = undefined
230    void refresh($)
231  })
232}
233
234async function select($: $T, id: string) {
235  const snap = await read($, snapshotA)
236  const index = listIndexOf(snap.kind === 'ok' ? snap.files : [], id)
237  await update($, selectedA, () => id)
238  await update($, scrollA, s => ({ id, diff: 0, list: listOffsetFor(index, s.list) }))
239  await loadDetail($, id, true)
240}
241
242async function move($: $T, by: 1 | -1) {
243  const snap = await read($, snapshotA)
244  const next = step(snap.kind === 'ok' ? snap.files : [], await read($, selectedA), by)
245  if (next !== null) await select($, next)
246}
247
248async function scrollDiff($: $T, by: number) {
249  const max = Math.max(0, layout.diffLen - layout.diffRows)
250  await update($, scrollA, s => {
251    const base = s.id === layout.detailId ? s.diff : 0
252    return { ...s, id: layout.detailId, diff: clamp(base + by, 0, max) }
253  })
254}
255
256async function setContext($: $T, context: SessionContextUsage) {
257  const next = { tokens: context.tokens ?? null, window: context.window || null, percent: context.percent ?? null }
258  await update($, statsA, (s): Stats => ({ ...s, context: next }))
259}
260
261async function openPane($: $T, focus: boolean) {
262  return $.ui.open({ id: PANE, title: TITLE, columns: 72, rows: 24, ...(focus ? { focus: true as const } : {}) })
263}
264
265/** 快捷键和状态栏按钮共用:侧栏没开或被别的标签挡住就打开并聚焦,正在显示就关掉。 */
266async function togglePane($: $T) {
267  const pane = (await $.ui.panes()).find(p => p.id === PANE)
268  if (pane?.isShown && pane.isPlaced) {
269    await $.ui.close({ id: PANE })
270    return
271  }
272  await $.store.delete('closedByPerson')
273  await openPane($, true)
274  void refresh($)
275}
276
277/** Claude 第一次改文件时自动打开侧栏,和内置 diff 面板一样;人手动关过就不再自动开。 */
278async function autoOpen($: $T) {
279  if ((await $.store.get('closedByPerson')) === true) return
280  if ((await $.ui.panes()).some(p => p.id === PANE)) return
281  await openPane($, false)
282}
283
284// —— 搜索 ——
285
286const hitId = (h: GrepHit) => `${h.path}:${h.line}`
287
288/** 搜索结果列表上一次绘制的行数,j/k 滚动用。 */
289const hitLayout = { rows: 0 }
290
291async function repoRoot($: $T): Promise<string | null> {
292  const snap = await read($, snapshotA)
293  if (snap.kind === 'ok') return snap.root
294  const r = await git($, await $.session.cwd(), ['rev-parse', '--show-toplevel']).catch(() => undefined)
295  return r && r.exitCode === 0 ? r.stdout.trim() : null
296}
297
298async function runSearch($: $T, raw: string) {
299  const query = raw.trim()
300  if (query === '') {
301    await update($, searchA, () => EMPTY_SEARCH)
302    await update($, hitSelA, () => null)
303    await update($, historyA, () => null)
304    return
305  }
306  await update($, searchA, (): Search => ({ ...EMPTY_SEARCH, query, status: 'running' }))
307  const root = await repoRoot($)
308  if (root === null) {
309    await update($, searchA, (): Search => ({ ...EMPTY_SEARCH, query, status: 'error', reason: '当前目录不是 Git 仓库' }))
310    return
311  }
312  let next: Search
313  try {
314    const r = await git($, root, grepArgv(query))
315    // git grep 没有匹配时退出码是 1。
316    if (r.exitCode > 1) next = { ...EMPTY_SEARCH, query, status: 'error', reason: firstLine(r.stderr) || `git grep 退出码 ${r.exitCode}` }
317    else next = { query, status: 'done', reason: null, ...parseGrep(r.stdout, MAX_HITS) }
318  } catch (err) {
319    next = { ...EMPTY_SEARCH, query, status: 'error', reason: String(err) }
320  }
321  // 搜索期间又输入了新的词,就丢掉这次结果。
322  if ((await read($, searchA)).query !== query) return
323  await update($, searchA, () => next)
324  await update($, hitScrollA, () => 0)
325  const first = next.hits[0]
326  await update($, hitSelA, () => (first ? hitId(first) : null))
327  await loadHistory($, first ?? null)
328}
329
330/**
331 * 读某一处结果那一行的提交记录。
332 *
333 * 先用 git blame 找到这一行最后一次被哪个提交改、在那次提交里是第几行,
334 * 再从那个提交起用 `git log -L` 往前追这一行的每次修改。这样工作区有未提交
335 * 改动、行号已经错位时,追的仍是同一行。
336 */
337async function loadHistory($: $T, hit: GrepHit | null) {
338  if (hit === null) return update($, historyA, () => null)
339  const id = hitId(hit)
340  const root = await repoRoot($)
341  if (root === null) return update($, historyA, (): History => ({ id, kind: 'error', reason: '当前目录不是 Git 仓库' }))
342  await update($, historyA, (): History => ({ id, kind: 'loading' }))
343  let next: History
344  try {
345    const blame = await git($, root, ['blame', '--porcelain', '-L', `${hit.line},${hit.line}`, '--', hit.path])
346    const b = blame.exitCode === 0 ? parseBlame(blame.stdout) ?? null : null
347    if (b === null) next = { id, kind: 'ok', blame: null, note: '这个文件还没有提交过(未跟踪),没有提交记录。', commits: [] }
348    else if (b.isUncommitted) next = { id, kind: 'ok', blame: null, note: '这一行是未提交的改动,还没有提交记录。', commits: [] }
349    else {
350      const log = await git($, root, [
351        'log', `-n${MAX_COMMITS}`, '-s', LOG_FORMAT, LOG_DATE,
352        `-L${b.origLine},${b.origLine}:${b.origPath}`, b.hash,
353      ])
354      if (log.exitCode !== 0) next = { id, kind: 'error', reason: firstLine(log.stderr) || `git log 退出码 ${log.exitCode}` }
355      else next = { id, kind: 'ok', blame: b, note: null, commits: parseLog(log.stdout) }
356    }
357  } catch (err) {
358    next = { id, kind: 'error', reason: String(err) }
359  }
360  if ((await read($, hitSelA)) === id) await update($, historyA, () => next)
361}
362
363async function selectHit($: $T, hit: GrepHit) {
364  await update($, hitSelA, () => hitId(hit))
365  await loadHistory($, hit)
366}
367
368async function moveHit($: $T, by: 1 | -1) {
369  const { hits } = await read($, searchA)
370  const current = await read($, hitSelA)
371  const index = hits.findIndex(hit => hitId(hit) === current)
372  const next = hits[clamp(index < 0 ? 0 : index + by, 0, Math.max(0, hits.length - 1))]
373  if (!next) return
374  const nextIndex = hits.indexOf(next)
375  await update($, hitScrollA, off => {
376    if (hitLayout.rows <= 0) return off
377    if (nextIndex < off) return nextIndex
378    if (nextIndex >= off + hitLayout.rows) return nextIndex - hitLayout.rows + 1
379    return off
380  })
381  await selectHit($, next)
382}
383
384async function setMode($: $T, mode: 'changes' | 'search') {
385  await update($, modeA, () => mode)
386  if (mode === 'search') void $.ui.focus({ requestId: PANE, key: 'q' }).catch(() => undefined)
387}
388
389export const register: Register = on => {
390  // —— 会话与命令 ——
391
392  on('session.start', async ($, e, next) => {
393    await $.command.register({ name: 'changes', description: '打开或聚焦“当前改动”侧栏' })
394    await $.command.register({ name: 'search', description: '在仓库文件里搜索文字,并查看命中那一行的提交记录', argumentHint: '[文字]' })
395    await $.command.register({ name: 'request-info', description: '查看最近一次请求、本轮和整个会话的 token 用量' })
396    const [model, usage] = await Promise.all([
397      $.session.model().catch(() => null),
398      $.session.usage().catch(() => undefined),
399    ])
400    await update($, statsA, (s): Stats => ({ ...s, model: s.model ?? model }))
401    if (usage) await setContext($, usage.context)
402    void refresh($)
403    // 人手动关过侧栏,就不在新会话里自动打开,直到再运行 /changes。
404    if (e.isInteractive && (await $.store.get('closedByPerson')) !== true) {
405      const isUp = (await $.ui.panes()).some(p => p.id === PANE)
406      if (!isUp) void openPane($, false)
407    }
408    return next(e)
409  })
410
411  on('session.end', async ($, e, next) => {
412    if (e.reason === 'clear') {
413      await update($, statsA, (s): Stats => ({ ...FRESH_STATS, model: s.model, context: s.context }))
414    }
415    return next(e)
416  })
417
418  on('ui.close', { id: PANE }, async ($, e, next) => {
419    if (e.origin.kind === 'person') await $.store.set('closedByPerson', true)
420    return next(e)
421  })
422
423  on('command.run', { command: 'changes' }, async ($, e) => {
424    await update($, modeA, () => 'changes')
425    await $.store.delete('closedByPerson')
426    await openPane($, true)
427    void refresh($)
428    const selected = await read($, selectedA)
429    if (selected !== null) void $.ui.focus({ requestId: PANE, key: `file:${selected}` }).catch(() => undefined)
430    return {
431      text: e.presentation.isFullscreen
432        ? '“当前改动”侧栏已打开。Esc 回到输入框,Ctrl+X Tab 再次聚焦。'
433        : '“当前改动”已在输入框上方打开。当前不是全屏模式,侧栏不能停靠在右侧。',
434    }
435  })
436
437  on('command.run', { command: 'search' }, async ($, e) => {
438    await update($, modeA, () => 'search')
439    await openPane($, true)
440    const query = e.args.trim()
441    if (query !== '') await runSearch($, query)
442    else void $.ui.focus({ requestId: PANE, key: 'q' }).catch(() => undefined)
443    if (query === '') return { text: '已切到搜索。输入文字后回车。' }
444    const { hits, isCut, status, reason } = await read($, searchA)
445    if (status === 'error') return { text: `搜索失败:${reason}` }
446    return { text: `“${query}”找到 ${hits.length}${isCut ? '+' : ''} 处,结果在侧栏里。` }
447  })
448
449  on('command.run', { command: 'request-info' }, async $ => {
450    await update($, compactA, () => false)
451    return { text: infoLines(await read($, statsA), await $.clock.now()).join('\n') }
452  })
453
454  // —— 请求统计:只算主对话 ——
455
456  on('turn.start', async ($, e, next) => {
457    const t = await $.clock.now()
458    const model = await $.session.model().catch(() => null)
459    await update($, statsA, (s): Stats => ({
460      ...s,
461      model: model ?? s.model,
462      phase: 'requesting',
463      turnStartedAt: t,
464      turnDurationMs: null,
465      turnRequests: 0,
466      turnTools: 0,
467      turnUsage: null,
468    }))
469    await update($, nowA, () => t)
470    ticker?.cancel()
471    ticker = $.clock.every(1000, () => {
472      void $.clock.now().then(n => update($, nowA, () => n))
473    })
474    return next(e)
475  })
476
477  on('turn.step', async function* ($, e, next) {
478    if (e.agentId !== undefined) return yield* next(e)
479    const t0 = await $.clock.now()
480    await update($, statsA, (s): Stats => ({
481      ...s,
482      phase: 'requesting',
483      turnRequests: s.turnRequests + 1,
484      session: { ...s.session, requests: s.session.requests + 1 },
485    }))
486    let result: TurnStepResult | undefined
487    try {
488      result = yield* next(e)
489      return result
490    } finally {
491      const t1 = await $.clock.now()
492      const answered = result
493      const usage = answered?.usage ? toUsage4(answered.usage) : null
494      const tools = answered?.toolUses.length ?? 0
495      await update($, statsA, (s): Stats => ({
496        ...s,
497        model: answered?.usage?.model ?? e.model,
498        phase: tools > 0 ? 'tool' : s.phase,
499        last: {
500          model: answered?.usage?.model ?? e.model,
501          usage,
502          durationMs: t1 - t0,
503          stopReason: answered?.stopReason ?? null,
504          tools,
505        },
506        turnUsage: usage ? addUsage(s.turnUsage, usage) : s.turnUsage,
507        session: { ...s.session, usage: usage ? addUsage(s.session.usage, usage) : s.session.usage },
508      }))
509    }
510  })
511
512  on('tool.call', async ($, e, next) => {
513    if (e.agentId === undefined) {
514      await update($, statsA, (s): Stats => ({
515        ...s,
516        phase: 'tool',
517        turnTools: s.turnTools + 1,
518        session: { ...s.session, tools: s.session.tools + 1 },
519      }))
520    }
521    const ran = await next(e)
522    // 子代理改的文件也要刷新;只读工具不触发。
523    if (ran.deny === undefined && ran.isReadOnly !== true) {
524      scheduleRefresh($)
525      void autoOpen($).catch(() => undefined)
526    }
527    return ran
528  })
529
530  on('turn.complete', async ($, e, next) => {
531    if (e.agentId === undefined) {
532      ticker?.cancel()
533      ticker = undefined
534      const phase: Phase =
535        e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'aborted' : e.reason === 'refusal' ? 'refused' : 'error'
536      await update($, statsA, (s): Stats => ({ ...s, phase, turnDurationMs: e.durationMs }))
537      scheduleRefresh($)
538    }
539    return next(e)
540  })
541
542  on('session.measure', async ($, e, next) => {
543    await setContext($, e.context)
544    return next(e)
545  })
546
547  // —— 侧栏 ——
548
549  on('ui.scroll', { requestId: PANE }, async ($, e) => {
550    if ((await read($, modeA)) === 'search') {
551      const { hits } = await read($, searchA)
552      await update($, hitScrollA, off => clamp(off + e.by, 0, Math.max(0, hits.length - hitLayout.rows)))
553      return {}
554    }
555    const row = e.pointer?.row
556    const isOverList = row !== undefined && row >= layout.listTop && row < layout.listTop + layout.listRows
557    if (isOverList) {
558      const max = Math.max(0, layout.listLen - layout.listRows)
559      await update($, scrollA, s => ({ ...s, list: clamp(s.list + e.by, 0, max) }))
560    } else {
561      await scrollDiff($, e.by)
562    }
563    return {}
564  })
565
566  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
567    const { Box, Text, Button } = $.ui.resolve(e)
568    const W = Math.max(24, e.props.bodyColumns)
569    const R = Math.max(8, e.props.scroll.bodyRows)
570    const snap = await read($, snapshotA)
571    const selected = await read($, selectedA)
572    const detail = await read($, detailA)
573    const scroll = await read($, scrollA)
574
575    const mode = await read($, modeA)
576    const tabs = (
577      <Box key="tabs" flexDirection="row" gap={1}>
578        <Button key="tab-changes" plain hotkey="c" dimColor={mode !== 'changes'} variant={mode === 'changes' ? 'primary' : undefined} label="改动" onPress={() => setMode($, 'changes')} />
579        <Button key="tab-search" plain hotkey="s" dimColor={mode !== 'search'} variant={mode === 'search' ? 'primary' : undefined} label="搜索" onPress={() => setMode($, 'search')} />
580      </Box>
581    )
582    if (mode === 'search') {
583      const search = await read($, searchA)
584      const hitSel = await read($, hitSelA)
585      const history = await read($, historyA)
586      const hitOffsetRaw = await read($, hitScrollA)
587      // 手机端没有输入框,只能用 /search 文字 搜。
588      const table = $.ui.resolve(e)
589      const Input = 'Input' in table ? table.Input : undefined
590      const input = Input === undefined ? (
591        <Text key="q-hint" dimColor>在输入框里运行 /search 文字</Text>
592      ) : (
593        <Input
594          key="q"
595          label="搜索 "
596          placeholder="输入文字后回车,在仓库文件里查找"
597          value={search.query}
598          submitLabel="搜索"
599          autoFocus
600          onSubmit={(value: string) => runSearch($, value)}
601        />
602      )
603      const searchBar = (
604        <Box key="sbar" flexDirection="row" gap={1}>
605          {tabs}
606          <Text dimColor>│</Text>
607          <Button key="hit-next" plain hotkey="j" label="下个" onPress={() => moveHit($, 1)} />
608          <Button key="hit-prev" plain hotkey="k" label="上个" onPress={() => moveHit($, -1)} />
609        </Box>
610      )
611      const rule = (key: string, text: string) => (
612        <Text key={key} dimColor wrap="truncate-end">{clipW(text + '─'.repeat(Math.max(0, W - width(text))), W)}</Text>
613      )
614
615      // 结果列表
616      const hitRows = Math.max(3, Math.floor((R - 4) * 0.5))
617      const hitOffset = clamp(hitOffsetRaw, 0, Math.max(0, search.hits.length - hitRows))
618      hitLayout.rows = hitRows
619      const shownHits = search.hits.slice(hitOffset, hitOffset + hitRows)
620      let resultEls: RenderElement[]
621      if (search.status === 'idle') resultEls = [<Text key="s-idle" dimColor>按字面查找;查询全小写时忽略大小写。包含未跟踪文件,跳过二进制文件。</Text>]
622      else if (search.status === 'running') resultEls = [<Text key="s-run" color="inactive">搜索中…</Text>]
623      else if (search.status === 'error') resultEls = [<Text key="s-err" color="error" wrap="wrap">搜索失败:{search.reason}</Text>]
624      else if (search.hits.length === 0) resultEls = [<Text key="s-none" color="warning">没有找到“{search.query}”。</Text>]
625      else {
626        // 变量不能叫 h:h 是 JSX 的工厂函数。
627        resultEls = shownHits.map(hit => {
628          const id = hitId(hit)
629          const isSel = id === hitSel
630          const where = `${hit.path}:${hit.line}`
631          const whereText = fitPath(where, Math.max(8, Math.floor((W - 2) * 0.45)))
632          const room = Math.max(0, W - 3 - width(whereText))
633          return (
634            <Box key={`hit:${id}`} flexDirection="row">
635              <Text color={isSel ? 'claude' : undefined} bold>{isSel ? '› ' : '  '}</Text>
636              <Button key={`hit:${id}`} plain label={whereText} hover={{ color: 'suggestion', underline: true }} onPress={() => selectHit($, hit)} />
637              <Text dimColor wrap="truncate-end"> {clipW(cleanLine(hit.text.trim()), room)}</Text>
638            </Box>
639          )
640        })
641      }
642      const count =
643        search.status === 'done'
644          ? `── 结果 ${search.hits.length}${search.isCut ? '+(只显示前 ' + MAX_HITS + ' 条)' : ''}` +
645            (search.hits.length > hitRows ? ` · ${hitOffset + 1}-${hitOffset + shownHits.length}` : '') + ' '
646          : '── 结果 '
647
648      // 提交记录
649      const sel = search.hits.find(hit => hitId(hit) === hitSel)
650      const histRows = Math.max(2, R - 4 - Math.min(hitRows, Math.max(1, resultEls.length)))
651      let historyEls: RenderElement[] = []
652      if (!sel || history === null || history.id !== hitSel) historyEls = [<Text key="h-none" dimColor>选择一条结果,查看它的提交记录。</Text>]
653      else if (history.kind === 'loading') historyEls = [<Text key="h-load" color="inactive">读取提交记录…</Text>]
654      else if (history.kind === 'error') historyEls = [<Text key="h-err" color="error" wrap="wrap">读取提交记录失败:{history.reason}</Text>]
655      else {
656        if (history.note) historyEls.push(<Text key="h-note" color="warning">{history.note}</Text>)
657        if (history.commits.length > 0) {
658          const more = history.commits.length >= MAX_COMMITS ? `,只列最近 ${MAX_COMMITS} 次` : ''
659          historyEls.push(<Text key="h-ft" dimColor>这一行被改过 {history.commits.length} 次(从新到旧{more}):</Text>)
660        }
661        for (const c of history.commits.slice(0, Math.max(0, histRows - historyEls.length))) {
662          historyEls.push(
663            <Box key={`c:${c.hash}`} flexDirection="row">
664              <Text color={c === history.commits[0] ? 'claude' : 'subtle'}>{c === history.commits[0] ? '● ' : '· '}</Text>
665              <Text color="suggestion">{c.hash} </Text>
666              <Text color="warning">{c.time} </Text>
667              <Text color="claude">{padW(clipW(c.author, 10), 10)} </Text>
668              <Text wrap="truncate-end">{clipW(c.subject, Math.max(0, W - c.hash.length - 32))}</Text>
669            </Box>,
670          )
671        }
672      }
673      return (
674        <Box flexDirection="column">
675          {searchBar}
676          {input}
677          {rule('r-res', count)}
678          {resultEls}
679          {rule('r-hist', sel ? `── 提交记录 · ${sel.path}:${sel.line} ` : '── 提交记录 ')}
680          {historyEls}
681        </Box>
682      )
683    }
684
685    const title =
686      snap.kind === 'ok'
687        ? `${snap.branch} · ${snap.files.length} 项未提交(含会话前已有的)`
688        : TITLE
689    const toolbar = (
690      <Box flexDirection="row" gap={1}>
691        {tabs}
692        <Text dimColor>│</Text>
693        <Button key="refresh" plain hotkey="r" label="刷新" onPress={() => refresh($)} />
694        <Button key="next" plain hotkey="j" label="下个" onPress={() => move($, 1)} />
695        <Button key="prev" plain hotkey="k" label="上个" onPress={() => move($, -1)} />
696        <Button key="pgdn" plain hotkey="d" label="下翻" onPress={() => scrollDiff($, Math.max(1, layout.diffRows - 2))} />
697        <Button key="pgup" plain hotkey="u" label="上翻" onPress={() => scrollDiff($, -Math.max(1, layout.diffRows - 2))} />
698      </Box>
699    )
700    const head = [
701      <Text key="title" bold wrap="truncate-end">{clipW(title, W)}</Text>,
702      toolbar,
703    ]
704
705    const notice = (text: string, color?: string) => (
706      <Box flexDirection="column">
707        {head}
708        <Text color={color} wrap="wrap">{text}</Text>
709      </Box>
710    )
711    if (snap.kind === 'loading') return notice('正在读取 Git 状态…', 'inactive')
712    if (snap.kind === 'not-git') return notice(`当前目录不是 Git 仓库,没有可显示的改动。\n${snap.reason}`, 'warning')
713    if (snap.kind === 'error') return notice(`读取 Git 状态失败:${snap.reason}`, 'error')
714    if (snap.files.length === 0) return notice('工作区干净:没有未提交的改动。', 'success')
715
716    // 文件列表区
717    const rows = listRowsOf(snap.files)
718    const listRows = Math.min(rows.length, Math.max(3, Math.floor((R - 3) * 0.4)))
719    const listOffset = clamp(scroll.list, 0, Math.max(0, rows.length - listRows))
720    const shown = rows.slice(listOffset, listOffset + listRows)
721    const files = shown.flatMap(r => (r.kind === 'file' ? [r.file] : []))
722    const addW = Math.max(0, ...files.map(f => (f.isBinary ? 3 : f.added === null ? 0 : String(f.added).length + 1)))
723    const delW = Math.max(0, ...files.map(f => (f.isBinary || f.deleted === null ? 0 : String(f.deleted).length + 1)))
724    const listEls = shown.map(r => {
725      if (r.kind === 'head') return <Text key={`h:${r.label}`} dimColor bold>{r.label}</Text>
726      const f = r.file
727      const isSel = f.id === selected
728      const add = f.isBinary ? 'bin' : f.added === null ? '' : `+${f.added}`
729      const del = f.isBinary || f.deleted === null ? '' : `-${f.deleted}`
730      const label = f.origPath ? `${f.origPath} → ${f.path}` : f.path
731      const room = W - 4 - addW - delW - 2
732      return (
733        <Box key={`row:${f.id}`} flexDirection="row">
734          <Text color={isSel ? 'claude' : undefined} bold>{isSel ? '› ' : '  '}</Text>
735          <Text color={codeColor(f.code)}>{f.code} </Text>
736          <Text color="success">{padW(add, addW)} </Text>
737          <Text color="error">{padW(del, delW)} </Text>
738          <Button
739            key={`file:${f.id}`}
740            plain
741            label={fitPath(label, Math.max(4, room))}
742            hover={{ color: 'suggestion', underline: true }}
743            onPress={() => select($, f.id)}
744          />
745        </Box>
746      )
747    })
748
749    // 详情区
750    const diffRows = Math.max(1, R - 3 - listRows)
751    const entry = snap.files.find(f => f.id === selected)
752    let bodyRows: DiffRow[] = []
753    let message: { text: string; color?: string } | undefined
754    if (entry === undefined || detail === null || detail.id !== entry.id) message = { text: '选择一个文件查看改动。', color: 'inactive' }
755    else if (detail.kind === 'loading') message = { text: '读取中…', color: 'inactive' }
756    else if (detail.kind === 'binary') message = { text: '二进制文件,不显示内容。', color: 'warning' }
757    else if (detail.kind === 'empty') message = { text: '没有文本差异(可能只改了文件权限或类型)。', color: 'inactive' }
758    else if (detail.kind === 'error') message = { text: `读取 Diff 失败:${detail.reason}`, color: 'error' }
759    else {
760      if (rowsCache?.source !== detail.source) rowsCache = { source: detail.source, rows: parseDiffRows(detail.source) }
761      bodyRows = detail.isCut ? [...rowsCache.rows, { t: 'note', s: '… 内容过长,只显示前一部分' }] : rowsCache.rows
762    }
763    const diffOffset = clamp(scroll.id === selected ? scroll.diff : 0, 0, Math.max(0, bodyRows.length - diffRows))
764    const visible = bodyRows.slice(diffOffset, diffOffset + diffRows)
765    const range = bodyRows.length > 0 ? ` · ${diffOffset + 1}-${diffOffset + visible.length}/${bodyRows.length}` : ''
766    const caption = entry ? `── ${entry.path}(${AREA_LABEL[entry.area]})${range} ` : '── '
767    const oW = Math.max(1, ...visible.map(r => String(r.o ?? '').length))
768    const nW = Math.max(1, ...visible.map(r => String(r.n ?? '').length))
769    const gutter = oW + nW + 2
770    const diffEls = message
771      ? [<Text key="msg" color={message.color} wrap="wrap">{message.text}</Text>]
772      : visible.map((r, i) => {
773          if (r.t === 'hunk' || r.t === 'note') {
774            return <Text key={`d:${diffOffset + i}`} color="subtle" wrap="truncate-end">{clipW(cleanLine(r.s), W)}</Text>
775          }
776          const mark = r.t === 'add' ? '+' : r.t === 'del' ? '-' : ' '
777          const text = padW(clipW(mark + cleanLine(r.s), W - gutter), W - gutter)
778          const bg = r.t === 'add' ? 'diffAdded' : r.t === 'del' ? 'diffRemoved' : undefined
779          return (
780            <Box key={`d:${diffOffset + i}`} flexDirection="row">
781              <Text dimColor>{`${String(r.o ?? '').padStart(oW)} ${String(r.n ?? '').padStart(nW)} `}</Text>
782              <Text backgroundColor={bg} wrap="truncate-end">{text}</Text>
783            </Box>
784          )
785        })
786
787    Object.assign(layout, {
788      listTop: 2,
789      listRows,
790      listLen: rows.length,
791      diffRows,
792      diffLen: bodyRows.length,
793      detailId: entry?.id ?? null,
794    })
795
796    return (
797      <Box flexDirection="column">
798        {head}
799        {listEls}
800        <Text dimColor wrap="truncate-end">{clipW(caption + '─'.repeat(Math.max(0, W - width(caption))), W)}</Text>
801        {diffEls}
802      </Box>
803    )
804  })
805
806  // —— 请求状态栏 ——
807
808  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
809    if (e.props.hasSurvey) return next(e)
810    const { Box, Text, Button } = $.ui.resolve(e)
811    const s = await read($, statsA)
812    const isCompact = await read($, compactA)
813    const isRunningTurn = s.turnStartedAt !== null && (s.phase === 'requesting' || s.phase === 'tool')
814    const now = isRunningTurn ? await read($, nowA) : 0
815    const elapsed = isRunningTurn ? Math.max(0, now - (s.turnStartedAt ?? now)) : s.turnDurationMs
816    const W = Math.max(20, e.props.bodyColumns)
817    const toggle = (
818      <Button
819        key="info"
820        plain
821        hotkey="i"
822        dimColor
823        label={isCompact ? '展开' : '收起'}
824        onPress={() => update($, compactA, v => !v)}
825      />
826    )
827    // 打开/关闭侧栏:鼠标点它;或绑定 app:toggleReplTab 的快捷键(见 README),在输入框里直接按。
828    const paneButton = (
829      <Button
830        key="pane"
831        plain
832        hotkey="c"
833        action="app:toggleReplTab"
834        dimColor
835        label="改动"
836        onPress={() => togglePane($)}
837      />
838    )
839    // 两个按钮占 12 列,其余留给文字。
840    const draw = (key: string, segs: Seg[], room: number) => (
841      <Box key={key} flexDirection="row">
842        {clipSegs(segs, room).map((g, i) => (
843          <Text key={`${key}:${i}`} color={g.color} dimColor={g.dim} bold={g.bold}>{g.text}</Text>
844        ))}
845      </Box>
846    )
847    const head: Seg[] = [
848      { text: `● ${PHASE_LABEL[s.phase]}`, color: PHASE_COLOR[s.phase], bold: true },
849      { text: '  ' },
850      { text: s.model ?? '模型暂无', color: 'claude' },
851      { text: '  上下文 ', dim: true },
852      { text: contextText(s), color: contextColor(s.context.percent) },
853    ]
854    if (isCompact) {
855      head.push(
856        { text: '  本轮 ', dim: true },
857        { text: fmtMs(elapsed), color: 'claude' },
858        { text: ' · 请求 ', dim: true },
859        { text: String(s.turnRequests) },
860        { text: ' · 工具 ', dim: true },
861        { text: String(s.turnTools) },
862      )
863    }
864    return (
865      <Box flexDirection="column">
866        <Box flexDirection="row">
867          {draw('head', head, W - 12)}
868          <Text> </Text>
869          {paneButton}
870          <Text> </Text>
871          {toggle}
872        </Box>
873        {!isCompact && scopeRows(s, elapsed).map(r => draw(`scope:${r.key}`, r.segs, W))}
874      </Box>
875    )
876  })
877}
878
879/** 一段带颜色的文字。 */
880type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
881
882/** 按显示宽度从左往右截,超出的段丢掉,最后一段截半。 */
883function clipSegs(segs: readonly Seg[], room: number): Seg[] {
884  const out: Seg[] = []
885  let left = room
886  for (const g of segs) {
887    if (left <= 0) break
888    const text = clipW(g.text, left)
889    if (text === '') break
890    out.push({ ...g, text })
891    left -= width(text)
892  }
893  return out
894}
895
896/** 每个指标一种颜色,三行同一列颜色一致;“暂无”统一变暗。 */
897const METRIC_COLOR = {
898  input: 'text',
899  output: 'success',
900  cacheRead: 'suggestion',
901  cacheWrite: 'warning',
902  time: 'claude',
903  count: 'text',
904} as const
905
906const SCOPES = [
907  { key: 'last', label: '最近一次', color: 'suggestion' },
908  { key: 'turn', label: '本轮', color: 'claude' },
909  { key: 'session', label: '会话', color: 'merged' },
910] as const
911
912/** 三种口径各一行,列对齐。 */
913function scopeRows(s: Stats, turnMs: number | null): { key: string; segs: Seg[] }[] {
914  const values: Record<(typeof SCOPES)[number]['key'], { u: Usage4 | null; ms: string; req: string; tools: string }> = {
915    last: {
916      u: s.last?.usage ?? null,
917      ms: fmtMs(s.last?.durationMs),
918      req: s.last ? '1' : '暂无',
919      tools: s.last ? String(s.last.tools) : '暂无',
920    },
921    turn: { u: s.turnUsage, ms: fmtMs(turnMs), req: String(s.turnRequests), tools: String(s.turnTools) },
922    session: { u: s.session.usage, ms: '—', req: String(s.session.requests), tools: String(s.session.tools) },
923  }
924  const cell = (name: string, value: string, color: string, w: number): Seg[] => [
925    { text: `${name} `, dim: true },
926    { text: padW(value, w), color: value === '暂无' || value === '—' ? undefined : color, dim: value === '暂无' || value === '—' },
927  ]
928  return SCOPES.map(scope => {
929    const v = values[scope.key]
930    return {
931      key: scope.key,
932      segs: [
933        { text: padW(scope.label, 9), color: scope.color, bold: true },
934        ...cell('入', fmtTokens(v.u?.input), METRIC_COLOR.input, 7),
935        ...cell('出', fmtTokens(v.u?.output), METRIC_COLOR.output, 7),
936        ...cell('缓存读', fmtTokens(v.u?.cacheRead), METRIC_COLOR.cacheRead, 7),
937        ...cell('缓存写', fmtTokens(v.u?.cacheWrite), METRIC_COLOR.cacheWrite, 7),
938        ...cell('耗时', v.ms, METRIC_COLOR.time, 7),
939        ...cell('请求', v.req, METRIC_COLOR.count, 5),
940        ...cell('工具', v.tools, METRIC_COLOR.count, 4),
941      ],
942    }
943  })
944}
945
946function contextColor(percent: number | null): string | undefined {
947  if (percent === null) return undefined
948  return percent >= 85 ? 'error' : percent >= 60 ? 'warning' : 'success'
949}
950
951function contextText(s: Stats): string {
952  const { tokens, window, percent } = s.context
953  if (tokens === null) return '暂无'
954  return `${fmtTokens(tokens)}/${fmtTokens(window)}${percent === null ? '' : ` ${percent}%`}`
955}
956
957/** 请求详情表:最近一次请求、本轮、整个会话三列,拿不到的写“暂无”。 */
958export function infoLines(s: Stats, now: number): string[] {
959  const cols = ['最近一次', '本轮', '会话']
960  const pick = (u: Usage4 | null | undefined, k: keyof Usage4) => fmtTokens(u ? u[k] : null)
961  const isRunningTurn = s.turnStartedAt !== null && (s.phase === 'requesting' || s.phase === 'tool')
962  const turnMs = isRunningTurn ? Math.max(0, now - (s.turnStartedAt ?? now)) : s.turnDurationMs
963  const table: [string, string, string, string][] = [
964    ['输入', pick(s.last?.usage, 'input'), pick(s.turnUsage, 'input'), pick(s.session.usage, 'input')],
965    ['输出', pick(s.last?.usage, 'output'), pick(s.turnUsage, 'output'), pick(s.session.usage, 'output')],
966    ['缓存读取', pick(s.last?.usage, 'cacheRead'), pick(s.turnUsage, 'cacheRead'), pick(s.session.usage, 'cacheRead')],
967    ['缓存写入', pick(s.last?.usage, 'cacheWrite'), pick(s.turnUsage, 'cacheWrite'), pick(s.session.usage, 'cacheWrite')],
968    ['耗时', fmtMs(s.last?.durationMs), fmtMs(turnMs), '—'],
969    ['请求次数', s.last ? '1' : '暂无', String(s.turnRequests), String(s.session.requests)],
970    ['工具调用', s.last ? String(s.last.tools) : '暂无', String(s.turnTools), String(s.session.tools)],
971  ]
972  const w = [10, 10, 10, 10]
973  const row = (cells: string[]) => cells.map((c, i) => padW(c, w[i] ?? 10)).join('')
974  return [
975    row(['口径', ...cols]),
976    ...table.map(row),
977    `模型 ${s.last?.model ?? s.model ?? '暂无'} · 上下文 ${contextText(s)}` +
978      (s.context.window === null ? '' : `(窗口 ${fmtTokens(s.context.window)})`),
979    '最近一次=主对话最近一次模型请求,耗时为本地计时;最近一次的工具调用=该响应要求的工具数。',
980    '本轮=当前或上一次提问;会话=本插件加载以来,/clear 清零;子代理不计入。token 均为 API 报告值。',
981  ]
982}
983
hooks/git.ts 292 lines
1// 纯函数:解析 git 输出、拆 Diff 行、格式化数字。不碰 $,测试直接调用。
2import type { Blame, Commit, FileArea, FileEntry, GrepHit, Usage4 } from '../types'
3
4/** 单个 Diff 在状态里最多保留的字符数,超过就在行边界截断并提示。 */
5export const DIFF_CHAR_LIMIT = 1_000_000
6
7const UNMERGED = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'])
8
9export type StatusItem = { xy: string; path: string; origPath?: string }
10
11/** 解析 `git status --porcelain=v1 -z`。重命名和复制后面多跟一个原路径字段。 */
12export function parseStatus(out: string): StatusItem[] {
13  const parts = out.split('\0')
14  const items: StatusItem[] = []
15  for (let i = 0; i < parts.length; i++) {
16    const field = parts[i] ?? ''
17    if (field.length < 4) continue
18    const xy = field.slice(0, 2)
19    const item: StatusItem = { xy, path: field.slice(3) }
20    if (/[RC]/.test(xy)) item.origPath = parts[++i]
21    items.push(item)
22  }
23  return items
24}
25
26export type NumStat = { added: number | null; deleted: number | null; isBinary: boolean }
27
28function toNumStat(a: string, d: string): NumStat {
29  const isBinary = a === '-'
30  return { added: isBinary ? null : Number(a), deleted: isBinary ? null : Number(d), isBinary }
31}
32
33/** 解析 `git diff --numstat -z`。重命名时路径字段为空,后面依次是原路径和新路径。 */
34export function parseNumstat(out: string): Map<string, NumStat> {
35  const parts = out.split('\0')
36  const map = new Map<string, NumStat>()
37  for (let i = 0; i < parts.length; i++) {
38    const m = /^(-|\d+)\t(-|\d+)\t([\s\S]*)$/.exec(parts[i] ?? '')
39    if (!m) continue
40    let path = m[3] ?? ''
41    if (path === '') {
42      i++ // 原路径
43      path = parts[++i] ?? ''
44    }
45    map.set(path, toNumStat(m[1] ?? '-', m[2] ?? '-'))
46  }
47  return map
48}
49
50/** 解析 `git diff --no-index --numstat -- /dev/null <file>` 的一行(未跟踪文件用)。 */
51export function parseNoIndexNumstat(out: string): NumStat | undefined {
52  const m = /^(-|\d+)\t(-|\d+)\t/.exec(out)
53  return m ? toNumStat(m[1] ?? '-', m[2] ?? '-') : undefined
54}
55
56/** 把 status 和 numstat 合成侧栏的行。同一文件可以同时出现在已暂存和未暂存里。 */
57export function buildEntries(
58  status: StatusItem[],
59  cached: Map<string, NumStat>,
60  worktree: Map<string, NumStat>,
61  untracked: Map<string, NumStat>,
62): FileEntry[] {
63  const out: FileEntry[] = []
64  const push = (area: FileArea, code: string, item: StatusItem, stat: NumStat | undefined) => {
65    out.push({
66      id: `${area}:${item.path}`,
67      area,
68      path: item.path,
69      ...(area === 'staged' && item.origPath !== undefined ? { origPath: item.origPath } : {}),
70      code,
71      added: stat?.added ?? null,
72      deleted: stat?.deleted ?? null,
73      isBinary: stat?.isBinary ?? false,
74    })
75  }
76  for (const item of status) {
77    const x = item.xy[0] ?? ' '
78    const y = item.xy[1] ?? ' '
79    if (item.xy === '??') push('untracked', '?', item, untracked.get(item.path))
80    else if (item.xy === '!!') continue
81    else if (UNMERGED.has(item.xy)) push('unstaged', 'U', item, worktree.get(item.path))
82    else {
83      if (x !== ' ') push('staged', x, item, cached.get(item.path))
84      if (y !== ' ') push('unstaged', y, item, worktree.get(item.path))
85    }
86  }
87  const order: Record<FileArea, number> = { staged: 0, unstaged: 1, untracked: 2 }
88  return out.sort((a, b) => order[a.area] - order[b.area] || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
89}
90
91/** 查看某一行 Diff 的 git 参数。未跟踪文件和 /dev/null 比,所以整份内容都显示成新增。 */
92export function diffArgv(entry: FileEntry): string[] {
93  const base = ['diff', '--no-color', '--no-ext-diff']
94  if (entry.area === 'untracked') return [...base, '--no-index', '--', '/dev/null', entry.path]
95  if (entry.area === 'staged') {
96    return [...base, '--cached', '-M', '--', ...(entry.origPath ? [entry.origPath] : []), entry.path]
97  }
98  return [...base, '--', entry.path]
99}
100
101export type DiffBody =
102  | { kind: 'diff'; source: string; isCut: boolean }
103  | { kind: 'binary' }
104  | { kind: 'empty' }
105
106/** 去掉第一个 `@@` 之前的文件头;没有 hunk 时判断是二进制还是只有模式变化。 */
107export function toDiffBody(raw: string, isTruncated = false, limit = DIFF_CHAR_LIMIT): DiffBody {
108  const start = raw.startsWith('@@') ? 0 : raw.indexOf('\n@@') + 1
109  if (start === 0 && !raw.startsWith('@@')) {
110    return /^(Binary files |GIT binary patch)/m.test(raw) ? { kind: 'binary' } : { kind: 'empty' }
111  }
112  let source = raw.slice(start).replace(/\n$/, '')
113  let isCut = isTruncated
114  if (source.length > limit) {
115    source = source.slice(0, source.lastIndexOf('\n', limit))
116    isCut = true
117  }
118  return { kind: 'diff', source, isCut }
119}
120
121export type DiffRow = { t: 'hunk' | 'ctx' | 'add' | 'del' | 'note'; o?: number; n?: number; s: string }
122
123/** 把 hunk 文本拆成带新旧行号的行。行号从 hunk 头 `@@ -a,b +c,d @@` 起算。 */
124export function parseDiffRows(source: string): DiffRow[] {
125  const rows: DiffRow[] = []
126  let o = 0
127  let n = 0
128  for (const line of source.split('\n')) {
129    const head = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line)
130    if (head) {
131      o = Number(head[1])
132      n = Number(head[2])
133      rows.push({ t: 'hunk', s: line })
134    } else if (line[0] === '+') rows.push({ t: 'add', n: n++, s: line.slice(1) })
135    else if (line[0] === '-') rows.push({ t: 'del', o: o++, s: line.slice(1) })
136    else if (line[0] === '\\') rows.push({ t: 'note', s: line })
137    else rows.push({ t: 'ctx', o: o++, n: n++, s: line.slice(1) })
138  }
139  return rows
140}
141
142/** 下一个或上一个文件的 id;列表为空时为 null,没有选中时取第一个。 */
143export function step(files: readonly FileEntry[], current: string | null, by: 1 | -1): string | null {
144  const at = files.findIndex(f => f.id === current)
145  if (at < 0) return files[0]?.id ?? null
146  return files[Math.min(files.length - 1, Math.max(0, at + by))]?.id ?? null
147}
148
149/** 刷新后保留选择:原 id 还在就留着;同一路径换了区域就跟过去;都没有就取第一个。 */
150export function keepSelection(files: readonly FileEntry[], current: string | null): string | null {
151  if (current !== null) {
152    if (files.some(f => f.id === current)) return current
153    const path = current.slice(current.indexOf(':') + 1)
154    const moved = files.find(f => f.path === path)
155    if (moved) return moved.id
156  }
157  return files[0]?.id ?? null
158}
159
160export function addUsage(a: Usage4 | null, b: Usage4): Usage4 {
161  if (a === null) return { ...b }
162  return {
163    input: a.input + b.input,
164    output: a.output + b.output,
165    cacheRead: a.cacheRead + b.cacheRead,
166    cacheWrite: a.cacheWrite + b.cacheWrite,
167  }
168}
169
170export function fmtTokens(n: number | null | undefined): string {
171  if (n === null || n === undefined) return '暂无'
172  if (n < 1000) return String(n)
173  if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
174  return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 2 : 1)}M`
175}
176
177export function fmtMs(ms: number | null | undefined): string {
178  if (ms === null || ms === undefined) return '暂无'
179  if (ms < 1000) return `${Math.round(ms)}ms`
180  const s = ms / 1000
181  if (s < 60) return `${s.toFixed(1)}s`
182  return `${Math.floor(s / 60)}m${Math.round(s % 60)}s`
183}
184
185/** 终端显示宽度:CJK 和全角字符按 2 列算。ponytail: 粗略区间判断,够用于对齐和截断。 */
186export function width(s: string): number {
187  let w = 0
188  for (const ch of s) {
189    const c = ch.codePointAt(0) ?? 0
190    w += c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
191      (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) ||
192      (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff) || (c >= 0x20000 && c <= 0x3fffd)) ? 2 : 1
193  }
194  return w
195}
196
197export function padW(s: string, w: number): string {
198  return s + ' '.repeat(Math.max(0, w - width(s)))
199}
200
201/** 按显示宽度截到 room 列以内。 */
202export function clipW(s: string, room: number): string {
203  if (width(s) <= room) return s
204  let out = ''
205  let w = 0
206  for (const ch of s) {
207    const cw = width(ch)
208    if (w + cw > room) break
209    out += ch
210    w += cw
211  }
212  return out
213}
214
215/** 制表符展开成空格,其他控制字符换成替换符,保证一行只占一行。 */
216export function cleanLine(s: string): string {
217  return s.replace(/\t/g, '    ').replace(/[\u0000-\u001f\u007f]/g, '�')
218}
219
220/** 路径太宽时保留结尾,前面用省略号。 */
221export function fitPath(path: string, room: number): string {
222  if (width(path) <= room) return path
223  const chars = [...path]
224  let out = ''
225  for (let i = chars.length - 1; i >= 0 && width(out) + width(chars[i] ?? '') <= room - 1; i--) out = chars[i] + out
226  return `…${out}`
227}
228
229// —— 搜索与提交记录 ——
230
231/** 解析 `git grep -n -z`:每行是 `路径\0行号\0内容`。超过 limit 条只留前面的。 */
232export function parseGrep(out: string, limit: number): { hits: GrepHit[]; isCut: boolean } {
233  const hits: GrepHit[] = []
234  let isCut = false
235  for (const line of out.split('\n')) {
236    if (line === '') continue
237    const a = line.indexOf('\0')
238    const b = line.indexOf('\0', a + 1)
239    if (a < 0 || b < 0) continue
240    if (hits.length >= limit) {
241      isCut = true
242      break
243    }
244    hits.push({ path: line.slice(0, a), line: Number(line.slice(a + 1, b)), text: line.slice(b + 1) })
245  }
246  return { hits, isCut }
247}
248
249/** 搜索参数:按字面匹配;查询里没有大写字母时忽略大小写。未跟踪文件也搜,二进制文件跳过。 */
250export function grepArgv(query: string): string[] {
251  return ['grep', '-n', '-I', '-z', '--no-color', '--untracked', ...(/[A-Z]/.test(query) ? [] : ['-i']), '-F', '-e', query, '--']
252}
253
254/** 把 git 的秒级时间和时区(如 `+0800`)格式化成作者当地的 `YYYY-MM-DD HH:MM`。 */
255export function fmtGitTime(seconds: number, tz: string): string {
256  const m = /^([+-])(\d\d)(\d\d)$/.exec(tz)
257  const offset = m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 0
258  const d = new Date((seconds + offset * 60) * 1000)
259  const p = (n: number) => String(n).padStart(2, '0')
260  return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`
261}
262
263/** 解析 `git blame --porcelain -L n,n` 的一行结果。 */
264export function parseBlame(out: string): Blame | undefined {
265  const lines = out.split('\n')
266  const head = /^([0-9a-f]{40}) (\d+) /.exec(lines[0] ?? '')
267  const hash = head?.[1]
268  if (!hash) return undefined
269  const field = (name: string) => lines.find(l => l.startsWith(`${name} `))?.slice(name.length + 1) ?? ''
270  const isUncommitted = /^0+$/.test(hash)
271  return {
272    hash: hash.slice(0, 8),
273    author: field('author'),
274    time: fmtGitTime(Number(field('author-time')), field('author-tz')),
275    summary: field('summary'),
276    isUncommitted,
277    origLine: Number(head?.[2]),
278    origPath: field('filename'),
279  }
280}
281
282/** `git log` 用的格式:字段用 0x1f 分隔,避免和提交说明里的字符冲突。 */
283export const LOG_FORMAT = '--format=%h%x1f%an%x1f%ad%x1f%s'
284export const LOG_DATE = '--date=format:%Y-%m-%d %H:%M'
285
286export function parseLog(out: string): Commit[] {
287  return out.split('\n').flatMap(line => {
288    const [hash, author, time, subject] = line.split('\x1f')
289    return hash && author !== undefined && time !== undefined ? [{ hash, author, time, subject: subject ?? '' }] : []
290  })
291}
292
types/index.d.ts 122 lines
1// changes-dashboard 的 $.state 契约。
2
3/** 改动所在区域:已暂存、未暂存、未跟踪。 */
4export type FileArea = 'staged' | 'unstaged' | 'untracked'
5
6/** 侧栏里的一行。同一个文件既有暂存又有未暂存改动时,会出现两行。 */
7export type FileEntry = {
8  /** `${area}:${path}`,选择和按钮都靠它定位。 */
9  id: string
10  area: FileArea
11  /** 相对仓库根目录。 */
12  path: string
13  /** 重命名或复制前的路径,只在已暂存行上有。 */
14  origPath?: string
15  /** git status 的单字母状态:M A D R C T U ?。 */
16  code: string
17  /** 新增、删除行数;二进制文件或 git 没给出时为 null。 */
18  added: number | null
19  deleted: number | null
20  isBinary: boolean
21}
22
23export type Snapshot =
24  | { kind: 'loading' }
25  | { kind: 'not-git'; reason: string }
26  | { kind: 'error'; reason: string }
27  | { kind: 'ok'; root: string; branch: string; files: FileEntry[] }
28
29export type Detail =
30  | { id: string; kind: 'loading' }
31  | { id: string; kind: 'diff'; source: string; isCut: boolean }
32  | { id: string; kind: 'binary' }
33  | { id: string; kind: 'empty' }
34  | { id: string; kind: 'error'; reason: string }
35
36/** API 报告的四个 token 数。 */
37export type Usage4 = { input: number; output: number; cacheRead: number; cacheWrite: number }
38
39export type Phase = 'idle' | 'requesting' | 'tool' | 'done' | 'aborted' | 'refused' | 'error'
40
41/** 只统计主对话;子代理的请求和工具调用不计入。会话累计从本 mod 加载时开始。 */
42export type Stats = {
43  /** 最近一次主对话请求用的模型;还没有请求时是 /model 的值。 */
44  model: string | null
45  phase: Phase
46  turnStartedAt: number | null
47  /** 本轮结束后才有,取引擎报告的 durationMs。 */
48  turnDurationMs: number | null
49  turnRequests: number
50  turnTools: number
51  /** 本轮内 API 报告过的用量之和;一次都没报告时为 null。 */
52  turnUsage: Usage4 | null
53  last: {
54    model: string | null
55    usage: Usage4 | null
56    durationMs: number
57    stopReason: string | null
58    /** 这次响应要求执行的工具数。 */
59    tools: number
60  } | null
61  session: { requests: number; tools: number; usage: Usage4 | null }
62  /** 上下文占用,取自引擎的 session.usage / session.measure。 */
63  context: { tokens: number | null; window: number | null; percent: number | null }
64}
65
66/** 一处搜索结果;路径相对仓库根目录。 */
67export type GrepHit = { path: string; line: number; text: string }
68
69export type Search = {
70  query: string
71  status: 'idle' | 'running' | 'done' | 'error'
72  hits: GrepHit[]
73  /** 结果超过上限,只保留了前面的。 */
74  isCut: boolean
75  reason: string | null
76}
77
78/** 某一行最后一次被谁改:取自 git blame。 */
79export type Blame = {
80  hash: string
81  author: string
82  time: string
83  summary: string
84  isUncommitted: boolean
85  /** 这一行在那次提交里的行号和路径;工作区有未提交改动时,和搜索到的行号可能不同。 */
86  origLine: number
87  origPath: string
88}
89
90export type Commit = { hash: string; author: string; time: string; subject: string }
91
92/** 选中的搜索结果那一行的提交记录,从新到旧。 */
93export type History =
94  | { id: string; kind: 'loading' }
95  | { id: string; kind: 'ok'; blame: Blame | null; note: string | null; commits: Commit[] }
96  | { id: string; kind: 'error'; reason: string }
97
98declare module 'claude-code' {
99  interface PluginState {
100    'changes-dashboard': {
101      snapshot: Snapshot
102      selected: string | null
103      detail: Detail | null
104      /** 侧栏滚动位置:Diff 的首行(属于哪个文件)和文件列表的首行。 */
105      scroll: { id: string | null; diff: number; list: number }
106      stats: Stats
107      /** 请求进行中每秒更新一次。只有状态栏读它,侧栏不跟着重画。 */
108      now: number
109      /** 侧栏显示改动还是搜索。 */
110      mode: 'changes' | 'search'
111      search: Search
112      /** 选中的搜索结果,`${path}:${line}`。 */
113      hitSel: string | null
114      /** 搜索结果列表的首行。 */
115      hitScroll: number
116      history: History | null
117      /** 状态栏是否收成一行。默认展开,三种口径各一行。 */
118      compact: boolean
119    }
120  }
121}
122