SLOPSHOPPER

session-changes

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

newpanebandguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-changes
│ ┃ 變更 ✕ › fix the failing auth test and add an audit log call │ ┃ 已改檔案 (0) │ ┃ 尚未用 Edit/Write 改過檔案 ⏺ 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 │ ⎿ session-changes: 已打開變更面板。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 變更
已改檔案 (0) 尚未用 Edit/Write 改過檔案
README

claude-code-mods

個人專用的 Claude Code mods,本身就是一個本機 plugin 市集。

安裝

  1. 先裝好 Claude Code、Git。
  2. 把 repo clone 到使用者資料夾,執行安裝腳本:
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 即可生效,不用重裝。

Mods

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>
Source 4 files
hooks/register.tsx 236 lines
1import { 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}
236
hooks/diff.ts 153 lines
1// 行為單位的 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}
153
hooks/changes.ts 175 lines
1// 純資料處理:記錄 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}
175
types/index.d.ts 52 lines
1/** 一次 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