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

Claude Code 终端版的 mod。需要 Claude Code 2.1.291 或更新版本。
在 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" } }
]
}
/clear 后清零。hooks/register.tsx 983 lines1import { 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}
983hooks/git.ts 292 lines1// 纯函数:解析 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}
292types/index.d.ts 122 lines1// 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