側邊面板:即時列出本 session 用 Edit/Write 改過的檔案,點開看逐次或累計 diff

個人專用的 Claude Code mods,本身就是一個本機 plugin 市集。
git clone https://github.com/nozomichi00/claude-code-mods $HOME\claude-code-mods
powershell -ExecutionPolicy Bypass -File $HOME\claude-code-mods\install.ps1
腳本會把這個資料夾註冊成 claude-code-mods 市集,並以 user 範圍安裝下表所有 mod,重複執行也沒關係。裝完重開 Claude Code(或在執行中的 session 打 /reload-plugins)。
市集來源是資料夾,Claude Code 直接讀這個資料夾。改完程式碼執行 /reload-plugins 即可生效,不用重裝。
| Mod | 說明 |
|---|---|
| usage-meter | 輸入框上方的圓角框用量列:Context、5H 額度(重置時間與倒數)、7D 額度(重置倒數,例如 3d4h)、累計 Token(↑ 輸入 ↓ 輸出),最右邊是 Cache 倒數。1/8 格精度的膠囊進度條,>50% 黃、>80% 紅;依寬度自動切換三種版面。<br>Context 右邊常駐 [交接] 按鈕(或打 /handoff):用 fork 寫交接摘要(目前目標、已完成、尚未完成、改過的檔案、注意事項),存到 ~/.claude/handoffs/<專案>-<時間>.md,執行 /clear,再把摘要當新對話第一句送出。摘要沒寫成就不清空;改過的檔案取自 Edit/Write 工具紀錄。 |
| session-changes | 側邊面板(/changes 開關;全螢幕版面下 session 一開始就自動打開,中途關掉後該 session 不再自動開)。列出本 session 用 Edit/Write 改過的檔案,最新的在上,附 +增 −刪、修改次數與時間,子代理改的標 [agent]。點檔案看 diff,可切換「逐次」(每次修改的片段)與「累計」(與本 session 第一次修改前相比)。/clear 會清空。用 Bash 或編輯器改的檔案不會列出。 |
usage-meter 畫在輸入框上方;session-changes 是另開的側邊面板。
claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>hooks/register.tsx 236 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { unifiedBetween } from './diff'
5import {
6 byRecent, clipDiff, clockTime, displayPath, editsInBudget, fileKey, fitStart, hasAgentEdit, recordEdit, totals,
7} from './changes'
8import type { EditRecord } from './changes'
9import type { SessionChangesDiffMode, SessionChangesFile, SessionChangesTotal, SessionChangesView } from '../types'
10
11const PANE = 'session-changes'
12const TITLE = '變更'
13const COMMAND = 'changes'
14
15// 與 usage-meter 相同的 Catppuccin Macchiato 色系
16const ACCENT = '#8AADF4'
17const GREEN = '#A6DA95'
18const RED = '#ED8796'
19
20const files = atom({ plugin: 'session-changes', key: 'files' } as const, [])
21const view = atom({ plugin: 'session-changes', key: 'view' } as const, { kind: 'list' })
22const total = atom({ plugin: 'session-changes', key: 'total' } as const, null)
23const cwd = atom({ plugin: 'session-changes', key: 'cwd' } as const, '')
24const hasAutoOpened = atom({ plugin: 'session-changes', key: 'hasAutoOpened' } as const, false)
25
26// 版面要等輸入框上方那一列第一次繪製才知道;繪製中不能寫狀態,所以排到繪製之後再開
27let isOpenScheduled = false
28
29/** 全螢幕版面下 session 一開始就打開;使用者在這個 session 關掉後不再自動開。 */
30async function autoOpen($: EngineInterface): Promise<void> {
31 if (await read($, hasAutoOpened)) return
32 await update($, hasAutoOpened, () => true)
33 await $.ui.open({ id: PANE, title: TITLE })
34}
35
36async function computeTotal($: EngineInterface, key: string): Promise<void> {
37 const file = (await read($, files)).find(one => one.key === key)
38 if (file === undefined) return
39 const result = await totalOf($, file)
40 // 讀檔期間使用者可能已切到別的檔案或模式,過時的結果不寫入
41 const latest = await read($, view)
42 if (latest.kind !== 'diff' || latest.mode !== 'total' || latest.key !== key) return
43 await update($, total, () => result)
44}
45
46async function totalOf($: EngineInterface, file: SessionChangesFile): Promise<SessionChangesTotal> {
47 const empty = { key: file.key, diff: '', added: 0, removed: 0 }
48 if (file.base === null) return { ...empty, note: '第一次修改前的原檔太大,無法計算累計 diff' }
49 let current: string
50 try {
51 current = await $.fs.read(file.path)
52 } catch {
53 return { ...empty, note: '檔案已不存在,或超過 4 MiB 無法讀取' }
54 }
55 const diff = unifiedBetween(file.base, current)
56 if (diff === null) return { ...empty, note: '差異太大,無法計算累計 diff' }
57 if (diff.text === '') return { ...empty, note: '與本 session 第一次修改前相同' }
58 return { key: file.key, diff: diff.text, added: diff.added, removed: diff.removed }
59}
60
61async function onEdit($: EngineInterface, record: EditRecord): Promise<void> {
62 await update($, files, list => recordEdit(list, record))
63 const current = await read($, view)
64 if (current.kind === 'diff' && current.mode === 'total' && current.key === fileKey(record.path)) {
65 await computeTotal($, current.key)
66 }
67}
68
69async function showDiff($: EngineInterface, key: string, mode: SessionChangesDiffMode): Promise<void> {
70 await update($, view, (): SessionChangesView => ({ kind: 'diff', key, mode }))
71 if (mode === 'total') await computeTotal($, key)
72}
73
74async function reset($: EngineInterface): Promise<void> {
75 await update($, files, () => [])
76 await update($, view, () => ({ kind: 'list' }))
77 await update($, total, () => null)
78}
79
80export const register: Register = on => {
81 on('session.start', async ($, e, next) => {
82 await $.command.register({ name: COMMAND, description: '開關「變更」側邊面板:本 session 改過的檔案與 diff' })
83 await update($, cwd, () => e.cwd)
84 return next(e)
85 })
86
87 on('session.end', async ($, e, next) => {
88 if (e.reason === 'clear') await reset($)
89 return next(e)
90 })
91
92 on('command.run', { command: COMMAND }, async $ => {
93 const pane = (await $.ui.panes()).find(one => one.id === PANE)
94 if (pane?.isPlaced === true) {
95 await $.ui.close({ id: PANE })
96 return { text: '已關閉變更面板。' }
97 }
98 await update($, hasAutoOpened, () => true)
99 await $.ui.open({ id: PANE, title: TITLE })
100 return { text: '已打開變更面板。' }
101 })
102
103 // ── 記錄修改 ──────────────────────────────────────────────────
104 // 工具執行完才記;失敗、被擋下、留給機器擁有者審核(staged)的都不算。
105 // 記錄出錯時 .catch 交回工具原本的結果(已執行過的不會重跑),不影響工具本身
106
107 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
108 const ran = await next(e)
109 if (ran.deny !== undefined || ran.isError === true || ran.result.staged === true) return ran
110 await onEdit($, {
111 path: ran.result.filePath, tool: 'Edit', at: await $.clock.now(), agentId: e.agentId,
112 before: ran.result.originalFile, isCreated: false, patch: ran.result.structuredPatch,
113 })
114 return ran
115 }).catch(($, e, next) => next(e))
116
117 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
118 const ran = await next(e)
119 if (ran.deny !== undefined || ran.isError === true) return ran
120 const isCreated = ran.result.type === 'create'
121 await onEdit($, {
122 path: ran.result.filePath, tool: 'Write', at: await $.clock.now(), agentId: e.agentId,
123 before: isCreated ? '' : ran.result.originalFile, isCreated, patch: ran.result.structuredPatch,
124 content: ran.result.content,
125 })
126 return ran
127 }).catch(($, e, next) => next(e))
128
129 // 只是讀版面資訊,畫面交給下層照常畫;主畫面版面會擠掉輸入框上方的空間,不自動開
130 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
131 if (e.viewport?.isFullscreen === true && !isOpenScheduled) {
132 isOpenScheduled = true
133 $.clock.after(0, () => autoOpen($))
134 }
135 return next(e)
136 })
137
138 // ── 面板 ──────────────────────────────────────────────────────
139
140 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
141 const { Box, Text, Button, Code } = $.ui.resolve(e)
142 const columns = Math.max(20, e.props.bodyColumns)
143 const [fileList, current, root] = await Promise.all([read($, files), read($, view), read($, cwd)])
144 const opened = current.kind === 'diff' ? fileList.find(file => file.key === current.key) : undefined
145
146 if (current.kind === 'diff' && opened !== undefined) {
147 const sum = totals(opened)
148 const back = () => update($, view, () => ({ kind: 'list' }))
149 const header = (
150 <Box flexDirection="column">
151 <Box flexDirection="row" gap={1}>
152 <Button key="back" onPress={back}>← 返回</Button>
153 <Button key="mode-each" variant={current.mode === 'each' ? 'primary' : 'secondary'}
154 onPress={() => showDiff($, opened.key, 'each')}>逐次</Button>
155 <Button key="mode-total" variant={current.mode === 'total' ? 'primary' : 'secondary'}
156 onPress={() => showDiff($, opened.key, 'total')}>累計</Button>
157 </Box>
158 <Text bold wrap="truncate-start">{fitStart(displayPath(opened.path, root), columns)}</Text>
159 </Box>
160 )
161
162 if (current.mode === 'total') {
163 const result = await read($, total)
164 const isReady = result !== null && result.key === opened.key
165 const clipped = isReady ? clipDiff(result.diff) : { text: '', isClipped: false, isPlain: false }
166 return (
167 <Box flexDirection="column">
168 {header}
169 {!isReady && <Text dimColor>計算中…</Text>}
170 {isReady && result.note !== undefined && <Text dimColor>{result.note}</Text>}
171 {isReady && result.note === undefined && (
172 <Text>
173 <Text dimColor>與第一次修改前相比 </Text>
174 <Text color={GREEN}>+{result.added}</Text> <Text color={RED}>−{result.removed}</Text>
175 </Text>
176 )}
177 {clipped.text !== '' && <Code format={clipped.isPlain ? 'source' : 'diff'} source={clipped.text} path={opened.path} />}
178 {clipped.isClipped && <Text dimColor>diff 太長,後面省略</Text>}
179 </Box>
180 )
181 }
182
183 const { shown, skipped } = editsInBudget(opened.edits)
184 return (
185 <Box flexDirection="column">
186 {header}
187 <Text>
188 <Text dimColor>共 {opened.edits.length} 次修改 </Text>
189 <Text color={GREEN}>+{sum.added}</Text> <Text color={RED}>−{sum.removed}</Text>
190 </Text>
191 {skipped > 0 && <Text dimColor>較早的 {skipped} 次修改省略</Text>}
192 {shown.map(i => {
193 const edit = opened.edits[i]!
194 const clipped = clipDiff(edit.diff)
195 return (
196 <Box key={`edit-${i}`} flexDirection="column" marginTop={1}>
197 <Text>
198 <Text color={ACCENT}>第 {i + 1} 次</Text>
199 <Text dimColor> · {clockTime(edit.at)} · {edit.tool}{edit.agentId === undefined ? '' : ' · agent'} </Text>
200 <Text color={GREEN}>+{edit.added}</Text> <Text color={RED}>−{edit.removed}</Text>
201 </Text>
202 {edit.note !== undefined && <Text dimColor>{edit.note}</Text>}
203 {clipped.text !== '' && <Code format={clipped.isPlain ? 'source' : 'diff'} source={clipped.text} path={opened.path} />}
204 {clipped.isClipped && <Text dimColor>diff 太長,後面省略</Text>}
205 </Box>
206 )
207 })}
208 </Box>
209 )
210 }
211
212 // 清單:改過的檔案
213 const recent = byRecent(fileList)
214
215 return (
216 <Box flexDirection="column">
217 <Text bold>已改檔案 ({recent.length})</Text>
218 {recent.length === 0 && <Text dimColor>尚未用 Edit/Write 改過檔案</Text>}
219 {recent.map(file => {
220 const sum = totals(file)
221 const tail = ` +${sum.added} −${sum.removed}${file.edits.length > 1 ? ` ×${file.edits.length}` : ''} ${clockTime(file.lastAt)}${hasAgentEdit(file) ? ' [agent]' : ''}`
222 const name = fitStart(displayPath(file.path, root), Math.max(8, columns - tail.length - 3))
223 return (
224 <Button key={`file:${file.key}`} plain onPress={() => showDiff($, file.key, 'each')}>
225 <Text color={file.isCreated ? GREEN : ACCENT}>{file.isCreated ? 'A' : 'M'}</Text> {name}
226 <Text color={GREEN}> +{sum.added}</Text>
227 <Text color={RED}> −{sum.removed}</Text>
228 <Text dimColor>{file.edits.length > 1 ? ` ×${file.edits.length}` : ''} {clockTime(file.lastAt)}{hasAgentEdit(file) ? ' [agent]' : ''}</Text>
229 </Button>
230 )
231 })}
232 </Box>
233 )
234 })
235}
236hooks/diff.ts 153 lines1// 行為單位的 diff:Myers 演算法找最短編輯路徑,再切成 unified diff 片段。
2// 累計 diff 用它比對「第一次修改前」與「現在」的檔案內容。
3
4export type DiffOp = { kind: ' ' | '-' | '+'; text: string }
5
6export type UnifiedDiff = { text: string; added: number; removed: number }
7
8const CONTEXT_LINES = 3
9// 超過就放棄計算,避免巨大檔案或整檔改寫卡住面板
10const MAX_EDIT_DISTANCE = 4_000
11const MAX_TOTAL_LINES = 60_000
12
13// Code 元件只接受 tab 與換行兩種控制字元
14const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f]/g
15
16export function cleanLine(line: string): string {
17 return line.replace(CONTROL_CHARS, '')
18}
19
20/** 要畫成一行的外來文字(路徑):換行改空白,再去掉控制字元。 */
21export function oneLine(text: string): string {
22 return cleanLine(text.replace(/\r?\n/g, ' '))
23}
24
25export function splitLines(text: string): string[] {
26 if (text === '') return []
27 const lines = text.split(/\r?\n/)
28 return lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines
29}
30
31/** 回傳逐行操作;差異太大算不完時回傳 null。 */
32export function diffLines(a: readonly string[], b: readonly string[]): DiffOp[] | null {
33 if (a.length + b.length > MAX_TOTAL_LINES) return null
34 let start = 0
35 while (start < a.length && start < b.length && a[start] === b[start]) start++
36 let endA = a.length
37 let endB = b.length
38 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
39 endA--
40 endB--
41 }
42 const middle = myers(a.slice(start, endA), b.slice(start, endB))
43 if (middle === null) return null
44 const same = (lines: readonly string[]): DiffOp[] => lines.map(text => ({ kind: ' ', text }))
45 return [...same(a.slice(0, start)), ...middle, ...same(a.slice(endA))]
46}
47
48function myers(a: readonly string[], b: readonly string[]): DiffOp[] | null {
49 const n = a.length
50 const m = b.length
51 if (n + m === 0) return []
52 const max = Math.min(n + m, MAX_EDIT_DISTANCE)
53 const offset = max + 1
54 const v = new Int32Array(2 * max + 3)
55 // trace[d]:第 d 步之前的 v,只留 k ∈ [-(d+1), d+1] 這段窗口
56 const trace: Int32Array[] = []
57 for (let d = 0; d <= max; d++) {
58 trace.push(v.slice(offset - d - 1, offset + d + 2))
59 for (let k = -d; k <= d; k += 2) {
60 const isDown = k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!)
61 let x = isDown ? v[offset + k + 1]! : v[offset + k - 1]! + 1
62 let y = x - k
63 while (x < n && y < m && a[x] === b[y]) {
64 x++
65 y++
66 }
67 v[offset + k] = x
68 if (x >= n && y >= m) return backtrack(trace, a, b, d)
69 }
70 }
71 return null
72}
73
74function backtrack(trace: readonly Int32Array[], a: readonly string[], b: readonly string[], depth: number): DiffOp[] {
75 const ops: DiffOp[] = []
76 let x = a.length
77 let y = b.length
78 for (let d = depth; d > 0; d--) {
79 const prev = trace[d]!
80 const at = (k: number): number => prev[k + d + 1]!
81 const k = x - y
82 const isDown = k === -d || (k !== d && at(k - 1) < at(k + 1))
83 const prevK = isDown ? k + 1 : k - 1
84 const prevX = at(prevK)
85 const prevY = prevX - prevK
86 const midX = isDown ? prevX : prevX + 1
87 while (x > midX) {
88 ops.push({ kind: ' ', text: a[x - 1]! })
89 x--
90 y--
91 }
92 if (isDown) ops.push({ kind: '+', text: b[prevY]! })
93 else ops.push({ kind: '-', text: a[prevX]! })
94 x = prevX
95 y = prevY
96 }
97 while (x > 0) {
98 ops.push({ kind: ' ', text: a[x - 1]! })
99 x--
100 }
101 return ops.reverse()
102}
103
104/** 把逐行操作切成帶 @@ 標頭的片段,前後各留 CONTEXT_LINES 行上下文。 */
105export function toUnified(ops: readonly DiffOp[]): UnifiedDiff {
106 const changes = ops.flatMap((op, i) => (op.kind === ' ' ? [] : [i]))
107 if (changes.length === 0) return { text: '', added: 0, removed: 0 }
108
109 // oldBefore[i]/newBefore[i]:第 i 個操作之前,新舊檔各走了幾行
110 const oldBefore: number[] = []
111 const newBefore: number[] = []
112 let oldCount = 0
113 let newCount = 0
114 for (const op of ops) {
115 oldBefore.push(oldCount)
116 newBefore.push(newCount)
117 if (op.kind !== '+') oldCount++
118 if (op.kind !== '-') newCount++
119 }
120 oldBefore.push(oldCount)
121 newBefore.push(newCount)
122
123 const groups: Array<[number, number]> = []
124 for (const i of changes) {
125 const last = groups[groups.length - 1]
126 if (last !== undefined && i - last[1] <= CONTEXT_LINES * 2 + 1) last[1] = i
127 else groups.push([i, i])
128 }
129
130 const hunks = groups.map(([first, lastChange]) => {
131 const from = Math.max(0, first - CONTEXT_LINES)
132 const to = Math.min(ops.length, lastChange + CONTEXT_LINES + 1)
133 const oldLines = oldBefore[to]! - oldBefore[from]!
134 const newLines = newBefore[to]! - newBefore[from]!
135 const oldStart = oldLines === 0 ? oldBefore[from]! : oldBefore[from]! + 1
136 const newStart = newLines === 0 ? newBefore[from]! : newBefore[from]! + 1
137 const body = ops.slice(from, to).map(op => op.kind + cleanLine(op.text))
138 return [`@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`, ...body].join('\n')
139 })
140
141 return {
142 text: hunks.join('\n'),
143 added: ops.filter(op => op.kind === '+').length,
144 removed: ops.filter(op => op.kind === '-').length,
145 }
146}
147
148/** 兩份內容的 unified diff;算不出來回傳 null。 */
149export function unifiedBetween(before: string, after: string): UnifiedDiff | null {
150 const ops = diffLines(splitLines(before), splitLines(after))
151 return ops === null ? null : toUnified(ops)
152}
153hooks/changes.ts 175 lines1// 純資料處理:記錄 Edit/Write、路徑與寬度計算。
2
3import { cleanLine, oneLine, splitLines } from './diff'
4import type { SessionChangesEdit, SessionChangesFile } from '../types'
5
6export type PatchHunk = {
7 oldStart: number
8 oldLines: number
9 newStart: number
10 newLines: number
11 lines: readonly string[]
12}
13
14export type EditRecord = {
15 path: string
16 tool: 'Edit' | 'Write'
17 at: number
18 agentId?: string
19 /** 這次修改前的內容;新檔為 '',拿不到為 null。 */
20 before: string | null
21 isCreated: boolean
22 patch: readonly PatchHunk[]
23 /** Write 建新檔時工具不一定給 patch,用寫入內容補出整檔新增。 */
24 content?: string
25}
26
27// 一個畫面最多畫約 10 萬字元,diff 留一點空間給標題與按鈕
28export const DIFF_BUDGET = 90_000
29
30/** 同一檔案的不同寫法(斜線、Windows 磁碟路徑大小寫)視為同一個。 */
31export function fileKey(path: string): string {
32 const slashed = path.replace(/\\/g, '/')
33 return /^[a-zA-Z]:\//.test(slashed) ? slashed.toLowerCase() : slashed
34}
35
36/** 在專案資料夾底下的顯示相對路徑,其餘顯示完整路徑。 */
37export function displayPath(path: string, cwd: string): string {
38 const slashed = oneLine(path.replace(/\\/g, '/'))
39 const root = fileKey(cwd).replace(/\/+$/, '')
40 return root !== '' && fileKey(path).startsWith(root + '/') ? slashed.slice(root.length + 1) : slashed
41}
42
43export function patchToDiff(patch: readonly PatchHunk[]): Pick<SessionChangesEdit, 'diff' | 'added' | 'removed'> {
44 const hunks = patch.map(h => [
45 `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`,
46 ...h.lines.map(line => cleanLine(line)),
47 ].join('\n'))
48 const lines = patch.flatMap(h => h.lines)
49 return {
50 diff: hunks.join('\n'),
51 added: lines.filter(line => line.startsWith('+')).length,
52 removed: lines.filter(line => line.startsWith('-')).length,
53 }
54}
55
56function createdDiff(content: string): Pick<SessionChangesEdit, 'diff' | 'added' | 'removed'> {
57 const lines = splitLines(content)
58 if (lines.length === 0) return { diff: '', added: 0, removed: 0 }
59 return {
60 diff: [`@@ -0,0 +1,${lines.length} @@`, ...lines.map(line => '+' + cleanLine(line))].join('\n'),
61 added: lines.length,
62 removed: 0,
63 }
64}
65
66function emptyNote(record: EditRecord): string {
67 if (record.before === null) return '原檔太大,工具沒有提供 diff'
68 return record.isCreated ? '新建的空檔案' : '內容沒有變動'
69}
70
71function toEdit(record: EditRecord): SessionChangesEdit {
72 const fromPatch = record.patch.length > 0 || record.content === undefined || !record.isCreated
73 const body = fromPatch ? patchToDiff(record.patch) : createdDiff(record.content ?? '')
74 const note = body.diff === '' ? emptyNote(record) : undefined
75 return {
76 at: record.at,
77 tool: record.tool,
78 ...(record.agentId === undefined ? {} : { agentId: record.agentId }),
79 ...body,
80 ...(note === undefined ? {} : { note }),
81 }
82}
83
84/** 加入一次修改:已記錄的檔案往後接,新檔案以這次修改前的內容作累計基準。 */
85export function recordEdit(files: readonly SessionChangesFile[], record: EditRecord): SessionChangesFile[] {
86 const key = fileKey(record.path)
87 const edit = toEdit(record)
88 const existing = files.find(file => file.key === key)
89 if (existing === undefined) {
90 const created: SessionChangesFile = {
91 key,
92 path: record.path,
93 isCreated: record.isCreated,
94 base: record.isCreated ? '' : record.before,
95 edits: [edit],
96 lastAt: record.at,
97 }
98 return [...files, created]
99 }
100 return files.map(file => (file.key === key ? { ...file, edits: [...file.edits, edit], lastAt: record.at } : file))
101}
102
103/** 最近修改的排最前面。 */
104export function byRecent(files: readonly SessionChangesFile[]): SessionChangesFile[] {
105 return [...files].sort((a, b) => b.lastAt - a.lastAt)
106}
107
108export function totals(file: SessionChangesFile): { added: number; removed: number } {
109 return file.edits.reduce((sum, edit) => ({ added: sum.added + edit.added, removed: sum.removed + edit.removed }), { added: 0, removed: 0 })
110}
111
112export function hasAgentEdit(file: SessionChangesFile): boolean {
113 return file.edits.some(edit => edit.agentId !== undefined)
114}
115
116/** 逐次檢視:由新往舊放,超出預算的較早修改省略。回傳依時間排序的 index 與省略數。 */
117export function editsInBudget(edits: readonly SessionChangesEdit[], budget = DIFF_BUDGET): { shown: number[]; skipped: number } {
118 const shown: number[] = []
119 let used = 0
120 for (let i = edits.length - 1; i >= 0; i--) {
121 used += edits[i]!.diff.length
122 if (used > budget && shown.length > 0) break
123 shown.unshift(i)
124 }
125 return { shown, skipped: edits.length - shown.length }
126}
127
128export type ClippedDiff = { text: string; isClipped: boolean; isPlain: boolean }
129
130/** 單一 diff 超出預算時,在片段邊界截斷。 */
131export function clipDiff(diff: string, budget = DIFF_BUDGET): ClippedDiff {
132 if (diff.length <= budget) return { text: diff, isClipped: false, isPlain: false }
133 const cut = diff.lastIndexOf('\n@@', budget)
134 if (cut > 0) return { text: diff.slice(0, cut), isClipped: true, isPlain: false }
135 // 單一片段就超過:片段標頭的行數不再相符、無法當 diff 解析,改成純文字顯示
136 const lineEnd = diff.lastIndexOf('\n', budget)
137 return { text: diff.slice(0, lineEnd > 0 ? lineEnd : budget), isClipped: true, isPlain: true }
138}
139
140// ── 寬度 ──────────────────────────────────────────────────────────
141
142/** 終端機顯示寬度:中日韓全形字算 2 欄。 */
143export function charWidth(char: string): number {
144 const code = char.codePointAt(0) ?? 0
145 const isWide = (code >= 0x1100 && code <= 0x115f) || (code >= 0x2e80 && code <= 0xa4cf)
146 || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff)
147 || (code >= 0xfe30 && code <= 0xfe4f) || (code >= 0xff00 && code <= 0xff60)
148 || (code >= 0xffe0 && code <= 0xffe6) || (code >= 0x1f300 && code <= 0x1faff)
149 || (code >= 0x20000 && code <= 0x3fffd)
150 return isWide ? 2 : 1
151}
152
153export function textWidth(text: string): number {
154 return [...text].reduce((sum, char) => sum + charWidth(char), 0)
155}
156
157/** 太長就從前面截掉,保留檔名那一端。 */
158export function fitStart(text: string, columns: number): string {
159 if (textWidth(text) <= columns) return text
160 if (columns <= 1) return '…'
161 const chars = [...text]
162 let width = 1
163 let from = chars.length
164 while (from > 0 && width + charWidth(chars[from - 1]!) <= columns) {
165 from--
166 width += charWidth(chars[from]!)
167 }
168 return '…' + chars.slice(from).join('')
169}
170
171export function clockTime(at: number): string {
172 const date = new Date(at)
173 return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
174}
175types/index.d.ts 52 lines1/** 一次 Edit/Write:當次的 diff(unified 片段)與增刪行數。 */
2export type SessionChangesEdit = {
3 at: number
4 tool: 'Edit' | 'Write'
5 /** 子代理改的才有,主對話為 undefined。 */
6 agentId?: string
7 diff: string
8 added: number
9 removed: number
10 /** 工具沒給 diff 時的說明(例如原檔太大)。 */
11 note?: string
12}
13
14/** 一個改過的檔案,key 為正規化路徑(斜線統一、Windows 磁碟路徑轉小寫)。 */
15export type SessionChangesFile = {
16 key: string
17 path: string
18 isCreated: boolean
19 /** 本 session 第一次修改前的內容,累計 diff 的基準;拿不到(原檔太大)為 null。 */
20 base: string | null
21 edits: SessionChangesEdit[]
22 lastAt: number
23}
24
25export type SessionChangesDiffMode = 'each' | 'total'
26
27export type SessionChangesView =
28 | { kind: 'list' }
29 | { kind: 'diff'; key: string; mode: SessionChangesDiffMode }
30
31/** 累計 diff 的計算結果,對應 view 裡那個檔案。 */
32export type SessionChangesTotal = {
33 key: string
34 diff: string
35 added: number
36 removed: number
37 note?: string
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'session-changes': {
43 files: SessionChangesFile[]
44 view: SessionChangesView
45 total: SessionChangesTotal | null
46 cwd: string
47 /** 已自動開過面板;使用者關掉後不再自動打開。 */
48 hasAutoOpened: boolean
49 }
50 }
51}
52