SLOPSHOPPER

cc-file-history-mod

AbovePrompt band (auto-shows the edit count) + Pane (file-grouped list with [Revert]). Click the band's [View] (or /file-history) to open the Pane and revert.

newpanebandguardcommandtoast
v0.2.0Apache-2.0updated 2026-10-09kukaka/cc-mods/cc-file-history-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-file-history-mod
│ ┃ File history ✕ › fix the failing auth test and add an audit log call │ ┃ 📂 File history 3 files, 3 edits [ Close ] │ ┃ 🅣 audit.ts src/ ● ⏺ Read(src/auth.ts) │ ┃ ● 0s ago write [ Revert ] ⎿ Read 6 lines │ ┃ 🅣 cache.ts src/ ● ⏺ Update(src/auth.ts) │ ┃ ● 0s ago write [ Revert ] ⎿ Added 2 lines, removed 1 line │ ┃ 🅣 auth.ts src/ M ⏺ Bash(bun test) │ ┃ M 0s ago edit [ Show ] [ Revert ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /file-history │ ⎿ cc-file-history-mod: file-history: opened. │ │ ⟨Claude Code's own drawing⟩ ▶ File history: 3 edits (3 files) [ View ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▶ File history: 3 edits (3 files) [ View ]
Pane · File history
📂 File history 3 files, 3 edits [ Close ] 🅣 audit.ts src/ ● ● 0s ago write [ Revert ] 🅣 cache.ts src/ ● ● 0s ago write [ Revert ] 🅣 auth.ts src/ M M 0s ago edit [ Show ] [ Revert ]
README

cc-file-history-mod

A Claude Code mod that captures every file Claude has edited this session and lets you revert any one of them. Two surfaces:

  • AbovePrompt band — auto-shown once the first edit lands. A single row reading ▶ File history: N edits (M files) with a [ View ] button (hotkey v). Click or press v to open the Pane. The band hides while the Pane is open, and hides entirely when there are no edits to show.
  • Pane (opened on demand by the band's [ View ] button or /file-history) — the full file-grouped list with one [Revert] per edit. Has the engine's dark chrome (a fixed engine choice, not plugin-controllable), so on a light terminal it looks wrong; we open it explicitly only when the user actually wants to look at the full list or revert.

The slash command is a secondary surface — the band is the entry point:

/file-history

/file-history toggles the Pane: first call opens it, second closes. Closing via the engine's [X] / Esc also dismisses it; the on('ui.close', ...) hook keeps our local flag in sync so the next /file-history always flips the right way. /clear / /resume / /fork resets the edits list and the band hides until the next one.

What you see

Band

▶ File history: 4 edits (3 files)            [ View ]
  • A single row above the prompt. Magenta text + a [ View ] button (hotkey v).
  • Counts N edits (M files); pluralises correctly (1 edit, 2 edits).
  • Hidden while the Pane is open (it would just duplicate the chrome) and when there are no edits to show.
  • Plain Box({ flexDirection: 'row', gap: 2, paddingX: 1, children: [Text, Button] }) shape — no flexWrap, no flexGrow: 1 spacers, no nested Boxes. An earlier build tried those and rendered inconsistently (sometimes occluding cc-context-mod); the flat shape stays out of their way.

Pane

┌─ File history ──────────────────────── 2 files, 4 edits ─── [ Close ] ┐
│ src/hooks/register.tsx  3 edits                                       │
│ ●  10:42:13  write  (created)        Show    [ Revert ]                │
│ M  10:40:02  edit  (82 lines before) Show    [ Revert ]                │
│ M  10:38:51  edit  (78 lines before) Show    [ Revert ]                │
│ src/hooks/history.ts  1 edit                                          │
│ M  10:41:10  edit  (54 lines before) Show    [ Revert ]                │
└───────────────────────────────────────────────────────────────────────┘
  • One group per file, sorted by most recent edit first.
  • Each row is one Edit or Write Claude issued, newest first.
  • VSCode-style status to the left of each row:
  • ● (green) = brand-new file (the Write created it; revert will rm).
  • M (yellow) = modification to an existing file (Edit / Write).
  • × (red) = deletion — Bash tool ran rm (or equivalent). Revert writes the captured pre-deletion content back.
  • Show / Hide expands a row to a real unified diff (before → after) rendered with the engine's syntax highlighter. Computed lazily on first expand and cached; reopening a row uses the cached diff. Diffs over 10 000 characters are truncated with a trailing …(truncated) marker.
  • Revert restores the file to the state it was in before that one tool call. Pressing it raises a confirmation dialog first; choose Revert to proceed, Cancel to keep the file as-is. For Write that created a brand-new file, Revert deletes the file (the pre-edit state was "did not exist").
  • Paths are project-relative. File rows show paths under the session's $.session.cwd() (e.g. src/hooks/register.tsx), not the absolute paths the engine hands us in tool.call.file_path. Files edited outside the project root (a Bash rm /tmp/x.ts, say) keep their absolute form so you can still tell where they live. Grouping follows the relative form, so two records of the same file collapse regardless of how the tool call spelled the path. Revert itself still uses the absolute path stored on the record — display is the only thing that changes.

How it works

HookWhy
session.startReset history, register /file-history.
`classic.SessionStart { clear \resume \fork }`Same reset on /clear, /resume, /branch.
command.run { command: 'file-history' }Open / close the Pane via $.ui.open / $.ui.close with PANE_ID. Secondary surface — the band's [ View ] is the entry point.
tool.call { tool: 'Edit' }Snapshot before via $.fs.read, run the edit, snapshot after, record the row.
tool.call { tool: 'Write' }Same. If $.fs.read of the pre-write path rejects, set beforeExists = false so Revert falls back to rm.
tool.call { tool: 'Bash' }Record file deletions. Two paths to the deletion set: (a) result.result.bashEditDiff.files[].deleted: true (catches shell-internal deletes our parse can't see — mv a /dev/null, find -delete, globs the shell expanded), (b) $.fs.stat probe of every candidate we pre-read (the reliable path for plain rm path in this build — the engine doesn't populate bashEditDiff for rm). Pre-read candidate content (via parseRmCandidates + $.fs.read) is what makes Revert-able.
ui.render { component: 'AbovePrompt' }Render the band tree (▶ File history: N edits (M files) [ View ]). Yields next(e) when there are no edits or the Pane is open.
ui.render { component: 'Pane', requestId: PANE_ID }Render the file-grouped edit list with [Show]/[Hide] and [Revert] per row.
ui.close { id: PANE_ID }Keep paneOpen in sync when the engine closes the Pane (Escape, X). Without this, X / Escape would leave the flag stale and the next /file-history would fight itself.

A Revert is a Button onPress closure — each button captures its edit ID in JS scope, calls $.ui.ask for confirmation, then either $.fs.write(path, before) (the common case) or $.process.run(['rm', path]) (for a brand-new file). On success the entry is removed from the list and a toast says reverted <basename>. On failure a 6-second toast shows the error.

The snapshot is the file's full content read via $.fs.read just before the tool call runs; the after snapshot is read right after. This makes Revert exact — even for an Edit that touches one line, we restore the entire file's pre-edit text — and gives the diff display real before/after content to compare, not just "what was here before".

The diff is rendered with the engine's Code { format: 'diff' }. Two paths produce the unified-diff text:

  • Edit — old_string / new_string are already in the tool.call input, so we don't $.fs.read the file at all. The hunk formatter (hunkDiffText in hooks/register.tsx) turns the pair into one minimal unified-diff hunk (@@ -1,N +1,M @@ + - / + lines). No diff -u shell out for Edits — these are tiny hunks.
  • Write — the input has the new content, but no old content. We $.fs.read the file before next(e) for the before snapshot; the after is the input's content directly (no second $.fs.read). The full before/after pair is shelled to diff -u like before — these can be arbitrarily large.

Edit's Revert uses a different strategy from Write's: with no full before snapshot, we read the file at Revert time and find-and-replace new_string → old_string (global, so a replace_all Edit's many occurrences all revert). If a later edit has rewritten that region and new_string no longer appears, Revert refuses and toasts the error rather than silently leaving the file in a wrong state. Write's Revert writes the full before back, exactly as before.

The history is session-scoped and lives in module-local state. A session.start, /clear, /resume, /branch, or a hot reload wipes it. There is no persistence across sessions — by design, snapshots could be large and stale across checkpoints.

Configure

For v1, configuration is module-local constants in hooks/history.ts / hooks/register.tsx:

ConstantDefaultPurpose
MAX_ENTRIES200Hard cap on total entries. Drop the oldest when over.
PANE_ID'cc-file-history-mod-pane'The pane's id (one per id; reopening retitles).

Limitations (v1)

  • No MultiEdit or NotebookEdit — only Edit and Write are recorded. MultiEdit is not declared in this build's BuiltinToolInputs.
  • Bash deletions: globs and $VAR expansions — the parser sees the literal *.txt / $FOO, so we read the wrong path (or nothing) before Bash runs. bashEditDiff does report the expanded files as deleted, but we have no content for them (the file is gone by the time we get the diff). Result: the deletion is logged with no Revert-able content. Walk-cwd-and-expand-glob, or shell-var tracing, would fix this — not implemented.
  • No cross-session persistence — reloads and /clear wipe history.
  • No multi-edit undo — one revert per record. Reverting in reverse order is the user's job.
  • No git integration — we don't run git stash / git checkout; revert is a plain file write (or rm).
  • No path-traversal guard — we trust the model's file_path.
  • Pane chrome is dark — the engine controls pane chrome color, so the Pane is dark on light terminals. Open it when you actually want the full list or to revert; the engine's chrome is the price of having multiple Buttons and a [Revert] confirmation dialog.
  • paneOpen flag can lag — if you close the Pane via the engine's [X] or Esc, our on('ui.close', ...) hook flips paneOpen to false immediately, so the next /file-history opens rather than fighting the engine. Both $.ui.open and $.ui.close are safe to call against a missing/already-placed Pane.
  • Opening the Pane on narrow terminals (< 110 cols) — when the user clicks [ View ] or runs /file-history on a narrow terminal, the engine returns isPlaced: false with reason below 110 columns. The band stays visible and tells them so via a 6-second toast. We don't try to draw the file-grouped list inside the band on narrow terminals — too cramped for [Revert] Buttons. Widen the terminal (or dock the terminal fullscreen so the band docks a Pane) and the next click / /file-history will place the Pane.

Develop

This mod lives in a marketplace folder, so:

# From /Users/lixinghui/Documents/code/fe/testground/cc-mods
claude plugin validate ./cc-file-history-mod
claude plugin test ./cc-file-history-mod

End-to-end:

claude --plugin-dir ./cc-file-history-mod
# inside: ask Claude to Edit a file, then type /file-history, then [Show] /
# [Revert] on any edit.
Source 3 files
hooks/register.tsx 1143 lines
1// cc-file-history-mod
2//
3// Captures every Edit / Write Claude issues this session and lets the user
4// revert any one of them. Two surfaces:
5//
6//   AbovePrompt band — auto-shown once the first edit lands. A single row
7//   reading "▶ File history: N edits (M files)" with a [View] button. Click
8//   it (or press `v`) to open the Pane. The band hides while the Pane is
9//   open, and hides entirely when there are no edits to show.
10//
11//   Pane — opened on demand by the band's [View] or the /file-history slash
12//   command. Has the engine's dark chrome (a fixed engine choice, not
13//   plugin-controllable), so on a light terminal it looks wrong; we open
14//   it explicitly only when the user actually wants the full list or to
15//   revert. Close button + Escape + engine X return to the band.
16//
17// Slash command: /file-history — toggle the Pane (secondary to the band).
18//
19// State is module-local. A session.start, classic.SessionStart
20// { clear|resume|fork }, or hot reload resets it. `paneOpen` is kept in
21// sync with the engine via the on('ui.close', ...) hook, so X / Escape
22// closing the Pane updates the flag immediately and the next /file-history
23// doesn't fight itself.
24
25import type { EngineInterface, Register } from 'claude-code'
26
27import {
28  basename,
29  cap,
30  formatTime,
31  groupByFile,
32  parentPath,
33  relativePath,
34  relativeTime,
35  type EditKind,
36  type EditRecord,
37} from './history'
38
39// ---------------------------------------------------------------------------
40// State — all module-local (resets on hot reload).
41// ---------------------------------------------------------------------------
42
43let edits: EditRecord[] = []
44let nextId = 1
45let paneOpen = false
46// Which edit rows the user has opened in the Pane, and the diff text we
47// already computed for them. The diff cache keeps `diff -u` from running
48// every time the Pane redraws.
49const expanded = new Set<number>()
50const diffCache = new Map<number, string | null>()
51
52// Pane id must be 1-64 of letters, digits, `_` or `-` (host check).
53const PANE_ID = 'cc-file-history-mod-pane'
54
55// Cached Windows Terminal probe. `WT_SESSION` is set by Windows Terminal
56// and unset on every other terminal we test on (macOS Terminal, iTerm,
57// ConEmu, mintty, etc.). The AbovePrompt coexistence handler branches on
58// this — only WT needs the column-wrap-around-`others` workaround; on
59// every other terminal, a column-shaped first child of the column
60// container stretches to fill available vertical room on its own,
61// pushing the band off-screen (the same renderer bug, just with a
62// column trigger instead of a row-with-wrap trigger).
63let isWindowsTerminal: boolean | null = null
64
65async function probeWindowsTerminal($: EngineInterface): Promise<boolean> {
66  if (isWindowsTerminal !== null) return isWindowsTerminal
67  try {
68    // $.env.get requires a string literal — see cc-context-mod's use of
69    // "ANTHROPIC_API_KEY" / "MINIMAX_SUBSCRIPTION_KEY" for the pattern.
70    // Returns `undefined` (not throw) when the var is unset, so a truthy
71    // check is correct.
72    isWindowsTerminal = Boolean(await $.env.get('WT_SESSION'))
73  } catch {
74    isWindowsTerminal = false
75  }
76  return isWindowsTerminal
77}
78
79// Cached session cwd for project-relative display paths. Probed in
80// session.start so the render hot path stays sync; if it never resolves
81// (e.g. hot-reloaded module before session.start re-fires) we fall back to
82// absolute paths via `relativePath(_, null)`. The session cwd is the
83// directory the host launched in and moves only via `/cd` or worktree
84// changes — shell `cd` does not affect it (matches the engine docs for
85// `$.session.cwd()`).
86let cwdCache: string | null = null
87
88async function probeCwd($: EngineInterface): Promise<void> {
89  if (cwdCache !== null) return
90  try {
91    const c = await $.session.cwd()
92    if (typeof c === 'string' && c.length > 0) cwdCache = c
93  } catch {
94    /* keep null — renderPane will fall back to absolute paths */
95  }
96}
97
98function resetState() {
99  edits = []
100  nextId = 1
101  paneOpen = false
102  expanded.clear()
103  diffCache.clear()
104}
105
106// ---------------------------------------------------------------------------
107// Snapshot / record helper (shared by Edit and Write hooks).
108// ---------------------------------------------------------------------------
109
110type FileEditInput = {
111  tool_use_id: string
112  file_path: string
113}
114
115/** Edit-tool input carries the diff hunk directly. */
116type EditToolInput = FileEditInput & {
117  old_string: string
118  new_string: string
119  replace_all?: boolean
120}
121
122/** Write-tool input carries the new content; the old content we have to read. */
123type WriteToolInput = FileEditInput & {
124  content: string
125}
126
127/** Bash-tool input — just the command line and an optional timeout. */
128type BashToolInput = {
129  tool_use_id: string
130  command: string
131  timeout?: number
132}
133
134/** Subset of BashResult we actually consume (`bashEditDiff` only). */
135type BashResultShape = {
136  bashEditDiff?: {
137    files?: {
138      filePath: string
139      created?: true
140      deleted?: true
141    }[]
142    unavailable?: true
143    skipped?: true
144  }
145}
146
147type NextResult = {
148  result?: unknown
149  isError?: boolean
150  deny?: unknown
151}
152
153async function recordTool<E extends FileEditInput>(
154  $: EngineInterface,
155  e: E,
156  kind: EditKind,
157  next: (e: E) => Promise<NextResult>,
158) {
159  // Snapshot strategy differs by tool:
160  //   Edit  — `old_string` / `new_string` come in `e`; we don't read the
161  //           file at all. The hunk is enough for the diff display, and
162  //           Revert does a `new_string → old_string` find-and-replace on
163  //           the current file at Revert time (one read, deferred).
164  //   Write — input has the new `content`, but no old content. We `$.fs.read`
165  //           before the tool runs for `before` (used by Revert and by the
166  //           full-file diff). For `after` we use `content` directly — no
167  //           second read.
168  let before: string | undefined
169  let beforeExists = true
170  let hunkBefore: string | undefined
171  let hunkAfter: string | undefined
172  if (kind === 'write') {
173    try {
174      before = await $.fs.read(e.file_path)
175    } catch {
176      before = undefined
177      beforeExists = false
178    }
179  } else {
180    const editE = e as unknown as EditToolInput
181    hunkBefore = editE.old_string
182    hunkAfter = editE.new_string
183  }
184
185  const result = await next(e)
186
187  // Drop denied / errored calls — we don't want a Revert button on them.
188  if (!result || result.isError || result.deny) return result
189
190  // Canonicalise the path so two spellings of the same file collapse into
191  // one group in the panel (macOS case aliases, ./ etc.).
192  let resolved = e.file_path
193  try {
194    const s = await $.fs.stat(e.file_path, { resolve: true })
195    if (s && typeof s.realPath === 'string' && s.realPath.length > 0) {
196      resolved = s.realPath
197    }
198  } catch {
199    /* keep original */
200  }
201
202  // For Write, the input's `content` IS the post-write file. For Edit, we
203  // don't track a full `after`; the hunk is enough for the diff display.
204  let after: string | undefined
205  if (kind === 'write') {
206    after = (e as unknown as WriteToolInput).content
207  }
208
209  edits = cap<EditRecord>(
210    [
211      ...edits,
212      {
213        id: nextId++,
214        filePath: resolved,
215        kind,
216        toolUseId: e.tool_use_id,
217        ts: Date.now(),
218        beforeExists,
219        before,
220        after,
221        hunkBefore,
222        hunkAfter,
223        applied: true,
224      },
225    ],
226    200,
227  )
228  // The AbovePrompt band refreshes via the invalidate below — it's the user's
229  // signal that an edit just happened. The Pane is opt-in: a band [View]
230  // click or a /file-history call is what opens it. The previous auto-open
231  // on the first edit is gone; the band replaced that role.
232  $.ui.invalidate('ui.render')
233  return result
234}
235
236// ---------------------------------------------------------------------------
237// Bash tool — record file deletions.
238//
239// Strategy:
240//     1. parse `rm` / `unlink` / `rmdir` paths out of `command` so we know
241//        which files to `$.fs.read` BEFORE Bash runs (Revert needs the
242//        content; once Bash is done the file is gone).
243//     2. run `next(e)` to let Bash execute.
244//     3. read `result.bashEditDiff` for the engine's authoritative list of
245//        files actually deleted/created/modified by the command. This
246//        catches paths our parser missed (globs the shell expanded,
247//        shell variables, `mv a /dev/null`, `find -delete`, etc.).
248//     4. union of parse-read files + bashEditDiff-deleted files = what we
249//        record. Files we couldn't read (globs, vars we didn't expand,
250//        shell-built-in deletes) are skipped — they'd have no content
251//        for Revert anyway.
252//
253// Known limitations:
254//   - Globs in the command: parse sees the literal `*.txt`, can't read
255//     it. bashEditDiff lists the expanded paths, but we have no content.
256//     To support globs we'd have to walk the cwd and expand before Bash
257//     runs; deferred for now.
258//   - Variable expansion (`rm $FOO`): same — the engine sees the
259//     expanded form, but we'd need to know the variable.
260//   - The diff-side fallback (reading `-`-prefixed lines from
261//     bashEditDiff's hunks) is NOT implemented — for pure `rm` of a file
262//     bashEditDiff would have all lines as `-`, but partial edits
263//     (sed, etc.) wouldn't. Out of scope; the union approach covers the
264//     common patterns.
265// ---------------------------------------------------------------------------
266
267/** Tokenise a shell-style command on whitespace, respecting "..." and '...'. */
268function tokenizeCommand(command: string): string[] {
269  // Match runs of non-whitespace/non-quote, or quoted strings.
270  return command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
271}
272
273function stripQuote(s: string): string {
274  return s.replace(/^['"]|['"]$/g, '')
275}
276
277/** Strip flags from an rm command and return the literal-path candidates. */
278function parseRmCandidates(command: string): string[] {
279  const tokens = tokenizeCommand(command)
280  const out: string[] = []
281  for (let i = 0; i < tokens.length; i++) {
282    const head = stripQuote(tokens[i])
283    if (head !== 'rm' && head !== 'unlink' && head !== 'rmdir') continue
284    // After `rm`, walk args. Flags (-r, -rf, -i, --) are skipped; `--`
285    // ends option parsing; everything else is a candidate path.
286    for (i = i + 1; i < tokens.length; i++) {
287      const t = stripQuote(tokens[i])
288      if (t === '--') break
289      if (t.startsWith('-')) continue
290      out.push(t)
291    }
292  }
293  return out
294}
295
296async function recordBash(
297  $: EngineInterface,
298  e: BashToolInput,
299  next: (e: BashToolInput) => Promise<NextResult>,
300) {
301  // Pre-snapshot content for every parse candidate so we have it if Bash
302  // actually deletes one of them. Read failures (file doesn't exist, perms)
303  // are silent — we only care about files we successfully captured.
304  const candidates = parseRmCandidates(e.command)
305  const preContent = new Map<string, string>()
306  for (const p of candidates) {
307    try {
308      const content = await $.fs.read(p)
309      preContent.set(p, content)
310    } catch {
311      /* not a regular file, or didn't exist; nothing to revert */
312    }
313  }
314
315  const result = await next(e)
316
317  // Drop denied / errored calls.
318  if (!result || result.isError || result.deny) return result
319
320  // Two paths to the deletion set:
321//
322//   1. Engine-reported: `result.result.bashEditDiff.files[].deleted: true`.
323//      Captures delete paths our parse can't see (`mv a /dev/null`,
324//      `find -delete`, shell-internal deletes), but in practice (this
325//      build) the engine does NOT populate bashEditDiff for plain `rm` —
326//      we get files=[] or no field at all. Treat it as bonus.
327//
328//   2. Stat probe: for every candidate we pre-read, `$.fs.stat` after
329//      `next(e)`. If the path now errors, Bash deleted it. This is what
330//      actually catches `rm path` in this build; the engine layer is a
331//      no-op in the common case.
332//
333// We don't change Parse-side handling for Bash edits (sed, awk, etc.):
334// those land in bashEditDiff as `modified` and we deliberately don't
335// record them — the Edit/Write hooks own those.
336  const bashResult = result as { result?: BashResultShape }
337  const bashEditDiff = bashResult.result?.bashEditDiff
338
339  const deletedFromEngine = new Set<string>()
340  if (bashEditDiff && !bashEditDiff.unavailable && !bashEditDiff.skipped) {
341    for (const f of bashEditDiff.files ?? []) {
342      if (f.deleted) deletedFromEngine.add(f.filePath)
343    }
344  }
345
346  const deletedFromStat = new Set<string>()
347  for (const path of preContent.keys()) {
348    try {
349      await $.fs.stat(path)
350      // still exists → not deleted
351    } catch {
352      deletedFromStat.add(path)
353    }
354  }
355
356  const deletedPaths = new Set<string>([...deletedFromEngine, ...deletedFromStat])
357
358  if (deletedPaths.size === 0) {
359    $.ui.invalidate('ui.render')
360    return result
361  }
362
363  // Canonicalise every deleted path through the same `$.fs.stat` we use
364  // for Edit/Write so two spellings of the same file collapse into one
365  // group in the Pane.
366  const resolvedByOriginal = new Map<string, string>()
367  for (const p of deletedPaths) {
368    try {
369      const s = await $.fs.stat(p, { resolve: true })
370      resolvedByOriginal.set(p, s?.realPath ?? p)
371    } catch {
372      resolvedByOriginal.set(p, p)
373    }
374  }
375
376  const newRecords: EditRecord[] = []
377  for (const original of deletedPaths) {
378    const resolved = resolvedByOriginal.get(original) ?? original
379    const content = preContent.get(original)
380    // We can only Revert what we captured beforehand; skip files we have
381    // no content for (globs the shell expanded, $VAR expansions, etc.).
382    if (content === undefined) continue
383    newRecords.push({
384      id: nextId++,
385      filePath: resolved,
386      kind: 'delete',
387      toolUseId: e.tool_use_id,
388      ts: Date.now(),
389      beforeExists: true,
390      before: content,
391      after: undefined,
392      applied: true,
393    })
394  }
395
396  if (newRecords.length > 0) {
397    edits = cap<EditRecord>([...edits, ...newRecords], 200)
398  }
399  $.ui.invalidate('ui.render')
400  return result
401}
402
403// ---------------------------------------------------------------------------
404// Diff helper — produces the unified-diff text for a record and caches it.
405//
406//   Edit: hunkBefore / hunkAfter come straight from the tool-call input, so
407//         we just format them as one unified-diff hunk here. No `$.fs.read`,
408//         no `diff -u` shell out — these are tiny (a few lines).
409//
410//   Write: full before (from `$.fs.read`) vs full after (from the tool
411//          input's `content`) — large and arbitrary, so we shell out to
412//          GNU `diff -u` like before.
413// ---------------------------------------------------------------------------
414
415async function unifiedDiff(
416  $: EngineInterface,
417  rec: EditRecord,
418): Promise<string | null> {
419  if (diffCache.has(rec.id)) return diffCache.get(rec.id) ?? null
420
421  if (rec.kind === 'edit') {
422    const before = rec.hunkBefore
423    const after = rec.hunkAfter
424    if (typeof before !== 'string' || typeof after !== 'string') {
425      diffCache.set(rec.id, null)
426      $.ui.invalidate('ui.render')
427      return null
428    }
429    if (before === after) {
430      diffCache.set(rec.id, '')
431      $.ui.invalidate('ui.render')
432      return ''
433    }
434    const text = hunkDiffText(before, after)
435    diffCache.set(rec.id, text)
436    $.ui.invalidate('ui.render')
437    return text
438  }
439
440  if (rec.kind === 'delete') {
441    // No hunk data on a delete record (we only have full `before`); render
442    // the deletion as a "removed-everything" diff against /dev/null. Empty
443    // file → nothing to show, cache the empty string so we don't redo.
444    if (typeof rec.before !== 'string') {
445      diffCache.set(rec.id, null)
446      $.ui.invalidate('ui.render')
447      return null
448    }
449    if (rec.before.length === 0) {
450      diffCache.set(rec.id, '')
451      $.ui.invalidate('ui.render')
452      return ''
453    }
454    const lines = rec.before.split('\n')
455    if (lines[lines.length - 1] === '') lines.pop()
456    const header =
457      `--- a/${basename(rec.filePath)}\n` +
458      `+++ /dev/null\n` +
459      `@@ -1,${lines.length} +0,0 @@`
460    const body = lines.map((l) => `-${l}`).join('\n')
461    const text = `${header}\n${body}`
462    diffCache.set(rec.id, text)
463    $.ui.invalidate('ui.render')
464    return text
465  }
466
467  // Write: shell out `diff -u`. Same as the pre-hunk code path.
468  if (typeof rec.before !== 'string' || typeof rec.after !== 'string') {
469    diffCache.set(rec.id, null)
470    $.ui.invalidate('ui.render')
471    return null
472  }
473  if (rec.before === rec.after) {
474    diffCache.set(rec.id, '')
475    $.ui.invalidate('ui.render')
476    return ''
477  }
478  // Write to sibling temp files next to the target so permissions match.
479  // Names carry the edit id so two concurrent shows never collide and a
480  // crash leaves a discoverable suffix.
481  const tag = `.cc-fh-diff-${rec.id}`
482  const beforePath = `${rec.filePath}${tag}.before`
483  const afterPath = `${rec.filePath}${tag}.after`
484  try {
485    await $.fs.write(beforePath, rec.before)
486    await $.fs.write(afterPath, rec.after)
487    const { exitCode, stdout } = await $.process.run([
488      'diff',
489      '-u',
490      '--label',
491      `a/${basename(rec.filePath)}`,
492      '--label',
493      `b/${basename(rec.filePath)}`,
494      beforePath,
495      afterPath,
496    ])
497    if (exitCode > 1) return null
498    diffCache.set(rec.id, stdout)
499    return stdout
500  } catch {
501    return null
502  } finally {
503    // Best-effort cleanup; ignore failures (file already gone, etc.).
504    void $.process.run(['rm', '-f', beforePath, afterPath])
505    // Force a redraw — showDiff already invalidated when the row opened, but
506    // that draw saw "computing diff…"; this one shows the result.
507    $.ui.invalidate('ui.render')
508  }
509}
510
511// ---------------------------------------------------------------------------
512// ---------------------------------------------------------------------------
513// Hunk formatter — turn an Edit's (old_string, new_string) pair into one
514// minimal unified-diff hunk. The engine's `Code { format: 'diff' }` parses
515// these (see `.claude-plugin/types/claude-code/index.d.ts:1553`): a hunk
516// header line followed by `-`/`+` lines, no context needed.
517// ---------------------------------------------------------------------------
518
519function hunkDiffText(before: string, after: string): string {
520  const splitLines = (s: string): string[] => {
521    const lines = s.split('\n')
522    // Drop the trailing empty entry a final '\n' creates, so a 2-line file
523    // "a\nb\n" comes out as ["a", "b"], not ["a", "b", ""]. Without this
524    // the unified diff gains a phantom blank line on each side.
525    if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
526    return lines
527  }
528  const beforeLines = splitLines(before)
529  const afterLines = splitLines(after)
530  const header = `@@ -1,${beforeLines.length} +1,${afterLines.length} @@`
531  const body = [
532    ...beforeLines.map((l) => `-${l}`),
533    ...afterLines.map((l) => `+${l}`),
534  ].join('\n')
535  return `${header}\n${body}`
536}
537
538// ---------------------------------------------------------------------------
539// Revert helper — closure for each rendered Revert button.
540// ---------------------------------------------------------------------------
541
542async function revertById($: EngineInterface, id: number) {
543  const rec = edits.find((r) => r.id === id)
544  if (!rec || !rec.applied) return
545
546  const choice = await $.ui.ask(
547    `Revert ${rec.kind} on ${relativePath(rec.filePath, cwdCache)}?`,
548    ['Revert', 'Cancel'],
549  )
550  if (choice !== 'Revert') return
551
552  try {
553    if (rec.kind === 'edit') {
554      // Edit record has no full `before` snapshot — we deferred reads at
555      // Edit time. To revert, read the current file and find-and-replace
556      // new_string → old_string (split().join() does global, so a `replace_all`
557      // Edit's many occurrences all get reverted). If new_string isn't in
558      // the file any more, a later edit must have rewritten that region;
559      // fail loudly rather than silently leave the file in a wrong state.
560      if (typeof rec.hunkBefore !== 'string' || typeof rec.hunkAfter !== 'string') {
561        throw new Error('edit record missing hunkBefore / hunkAfter')
562      }
563      const current = await $.fs.read(rec.filePath)
564      if (!current.includes(rec.hunkAfter)) {
565        throw new Error(
566          'new_string no longer in file (a later edit rewrote this region?)',
567        )
568      }
569      const reverted = current.split(rec.hunkAfter).join(rec.hunkBefore)
570      await $.fs.write(rec.filePath, reverted)
571    } else if (rec.beforeExists) {
572      await $.fs.write(rec.filePath, rec.before!)
573    } else {
574      // Brand-new file from Write — remove it to restore "did not exist".
575      const { exitCode } = await $.process.run(['rm', rec.filePath])
576      if (exitCode !== 0) throw new Error(`rm exited ${exitCode}`)
577    }
578    edits = edits.filter((r) => r.id !== id)
579    $.ui.toast(`reverted ${basename(rec.filePath)}`)
580  } catch (err) {
581    const msg = String((err as { message?: string })?.message ?? err)
582    $.ui.toast(`revert failed: ${msg}`, { timeoutMs: 6000 })
583  }
584  $.ui.invalidate('ui.render')
585}
586
587// ---------------------------------------------------------------------------
588// Pane render (the full list with per-edit Revert buttons and per-row diff).
589//
590// Layout aims to mirror VSCode's Source Control view: each file is a
591// VSCode-style row (icon + basename + dim parent path + right-aligned status
592// character), with the per-edit rows indented under it. No empty rows
593// between elements — gap: 0 over the file group, gap: 0 between groups, so
594// the eye scans a tight list. The header still carries the [Close] button
595// (engine-controlled Pane chrome anchors it; `pane.children[0]` is the
596// header row, asserted by tests/register.test.ts).
597// ---------------------------------------------------------------------------
598
599type PaneRenderEvent = {
600  surface: string
601  props?: { bodyColumns?: number }
602}
603
604// VSCode-style icon picker — based on the file extension. Falls back to 📄
605// for anything not matched. Modern terminals (Windows Terminal, macOS
606// Terminal, iTerm2, GNOME Terminal) all render these emoji by default; if a
607// user's terminal lacks an emoji font the glyphs may show as ? boxes, but the
608// row layout stays intact.
609const fileEmoji = (path: string): string => {
610  const base = basename(path).toLowerCase()
611  if (base === 'package.json' || base === 'pyproject.toml') return '📦'
612  if (base.endsWith('.lock') || base.endsWith('.lockb')) return '🔒'
613  const ext = (base.split('.').pop() ?? '').toLowerCase()
614  switch (ext) {
615    case 'py': return '🐍'
616    case 'ts': return '🅣'
617    case 'tsx': return '🅴'
618    case 'js':
619    case 'jsx':
620    case 'mjs':
621    case 'cjs': return '🅙'
622    case 'json': return '{}'
623    case 'toml':
624    case 'yaml':
625    case 'yml': return '⚙'
626    case 'md':
627    case 'mdx': return '📋'
628    case 'css':
629    case 'scss':
630    case 'less': return '🎨'
631    case 'html':
632    case 'htm': return '🌐'
633    case 'sh':
634    case 'bash':
635    case 'zsh': return '🐚'
636    default: return '📄'
637  }
638}
639
640function renderPane($: EngineInterface, e: PaneRenderEvent) {
641  type El = (props: Record<string, unknown> & { children?: unknown }) => unknown
642  const { Box, Text, Button, Code } = $.ui.resolve(e as never) as {
643    Box: El
644    Text: El
645    Button: El
646    Code: El
647  }
648
649  const groups = groupByFile(edits, cwdCache)
650  const totalFiles = groups.length
651  const totalEdits = edits.length
652
653  const closePane = async () => {
654    paneOpen = false
655    await $.ui.close({ id: PANE_ID })
656  }
657
658  // Header — 📂 folder icon + title + counts + flexGrow spacer + Close button.
659  //
660  // Theme-adaptive text. The Pane chrome used to be a fixed dark fill
661  // (engine-controlled) so `color: 'white'` was the safe pick across
662  // terminal themes — but newer engines theme the chrome to match the
663  // terminal, making pinned white invisible on light mode. We now:
664  //   * Pin a bright accent (`color: 'magenta'`, `bold: true`) for the
665  //     title — bright magenta stays high-contrast on both chrome
666  //     variants, and matches the band's accent for visual consistency.
667  //   * Use `dimColor: true` (no `color`) for secondary text. `dimColor`
668  //     renders a dimmed version of the engine's default text colour,
669  //     which the engine adapts to the terminal theme — same trick
670  //     cc-context-mod uses for its non-accent text in the AbovePrompt
671  //     band, where it stays readable on both backgrounds.
672  //
673  // Button colour note: ButtonProps doesn't expose `color` (only
674  // dimColor / variant / plain / hover); `color: 'white'` is silently
675  // dropped. The escape is `variant: 'primary'`, which paints the label
676  // in the engine's accent colour — bright in both light and dark
677  // terminals, so it always contrasts with the chrome. We mark every
678  // Button primary; Close / Show / Revert are the only pressable leaves
679  // on each surface. The status-char accents (red / yellow / green)
680  // already sit on the bright side of the palette and need no override.
681  const header = Box({
682    flexDirection: 'row',
683    gap: 1,
684    children: [
685      Text({ children: '📂' }),
686      Text({ bold: true, color: 'magenta', children: 'File history' }),
687      Text({
688        dimColor: true,
689        children: `${totalFiles} file${totalFiles === 1 ? '' : 's'}, ${totalEdits} edit${totalEdits === 1 ? '' : 's'}`,
690      }),
691      Box({ flexGrow: 1 }),
692      Button({ key: 'close', label: 'Close', variant: 'primary', onPress: closePane }),
693    ],
694  })
695
696  if (totalEdits === 0) {
697    return Box({
698      flexDirection: 'column',
699      paddingX: 1,
700      gap: 1,
701      children: [
702        header,
703        Text({ dimColor: true, children: 'no edits captured yet' }),
704      ],
705    })
706  }
707
708  const statusChar = (rec: EditRecord): { char: string; color: string } => {
709    if (rec.kind === 'delete') return { char: '×', color: 'red' }
710    return rec.beforeExists
711      ? { char: 'M', color: 'yellow' }
712      : { char: '●', color: 'green' }
713  }
714
715const kindLabel = (rec: EditRecord): string => {
716  if (rec.kind === 'edit') return 'edit'
717  if (rec.kind === 'delete') return 'delete'
718  return 'write'
719}
720
721  const toggleExpand = (id: number) => {
722    if (expanded.has(id)) {
723      expanded.delete(id)
724    } else {
725      expanded.add(id)
726    }
727    $.ui.invalidate('ui.render')
728  }
729
730  const groupBoxes = groups.map((g) => {
731    // Most-recent record drives the file-row status. groupByFile sorts
732    // records newest-first, so `g.records[0]` is the latest. When the
733    // latest is a delete, the file itself is gone — strikethrough the
734    // filename so the file row reads as "deleted" without resorting to
735    // a separate icon.
736    const latest = g.records[0]!
737    const fileStatus = statusChar(latest)
738    const isDeleted = latest.kind === 'delete'
739    const fileBase = basename(g.filePath)
740    const parent = parentPath(g.filePath)
741
742    const fileRow = Box({
743      flexDirection: 'row',
744      gap: 1,
745      children: [
746        Text({ children: fileEmoji(g.filePath) }),
747        Text({
748          bold: true,
749          ...(isDeleted ? { color: 'gray', strikethrough: true } : {}),
750          children: fileBase,
751        }),
752        ...(parent
753          ? [Text({ dimColor: true, children: parent })]
754          : []),
755        Box({ flexGrow: 1 }),
756        Text({ color: fileStatus.color, bold: true, children: fileStatus.char }),
757      ],
758    })
759
760    const recordRows = g.records.map((rec) => {
761      const revert = () => {
762        void revertById($, rec.id)
763      }
764      const isOpen = expanded.has(rec.id)
765      const showDiff = () => {
766        toggleExpand(rec.id)
767        // Kick off the diff (lazily) so the next redraw has something to
768        // show. We don't await — toggleExpand's invalidate handles the
769        // redraw, and the diff lands as soon as `diff -u` returns.
770        if (expanded.has(rec.id)) {
771          void unifiedDiff($, rec)
772        }
773      }
774      const status = statusChar(rec)
775      const rowChildren: unknown[] = []
776
777      rowChildren.push(Text({ color: status.color, children: status.char }))
778      rowChildren.push(Text({ dimColor: true, children: relativeTime(rec.ts) }))
779      rowChildren.push(Text({ dimColor: true, children: kindLabel(rec) }))
780
781      if (rec.beforeExists && typeof rec.before === 'string') {
782        const lines = rec.before.split('\n').length
783        rowChildren.push(
784          Text({
785            dimColor: true,
786            children: `(${lines} line${lines === 1 ? '' : 's'} before)`,
787          }),
788        )
789      }
790
791      rowChildren.push(Box({ flexGrow: 1 }))
792
793      // [Show] / [Hide] — only when we have something to diff against. Three
794      // independent sources cover all rows: Edit has hunkBefore/hunkAfter
795      // from the tool input; Write has before (file read) + after (input
796      // content); Delete has `before` only (file content pre-deletion), and
797      // `unifiedDiff` renders that as a "removed-everything" diff against
798      // /dev/null.
799      const hasHunk =
800        typeof rec.hunkBefore === 'string' && typeof rec.hunkAfter === 'string'
801      const hasFull =
802        typeof rec.before === 'string' && typeof rec.after === 'string'
803      const hasDelete = rec.kind === 'delete' && typeof rec.before === 'string'
804      const canDiff = hasHunk || hasFull || hasDelete
805      if (canDiff) {
806        rowChildren.push(
807          Button({
808            key: `diff-${rec.id}`,
809            label: isOpen ? 'Hide' : 'Show',
810            variant: 'primary',
811            onPress: showDiff,
812          }),
813        )
814      } else if (rec.beforeExists && typeof rec.before === 'string') {
815        // Have before but no after — the file read after `next(e)` failed.
816        // Surface it explicitly so the user knows why the toggle is missing.
817        rowChildren.push(Text({ dimColor: true, children: '(no diff: after missing)' }))
818      } else if (rec.kind === 'edit') {
819        // Edit without a hunk shouldn't happen (input always carries it), but
820        // be defensive — no message to avoid a misleading "missing".
821        rowChildren.push(Text({ dimColor: true, children: '(no diff)' }))
822      }
823
824      if (rec.applied) {
825        rowChildren.push(
826          Button({ key: `revert-${rec.id}`, label: 'Revert', variant: 'primary', onPress: revert }),
827        )
828      } else {
829        rowChildren.push(Text({ dimColor: true, children: '(failed)' }))
830      }
831
832      const mainRow = Box({ flexDirection: 'row', gap: 1, children: rowChildren })
833
834      if (!isOpen) return mainRow
835
836      // Expanded diff section. We pull from the cache synchronously (it may
837      // be `undefined` while `diff -u` is still running, or `null` if it
838      // failed, or `''` if before === after).
839      const diffText = diffCache.get(rec.id)
840      const diffBody: unknown[] = []
841      if (diffText === undefined) {
842        diffBody.push(Text({ dimColor: true, children: 'computing diff…' }))
843      } else if (diffText === null) {
844        diffBody.push(Text({ dimColor: true, children: 'diff unavailable' }))
845      } else if (diffText === '') {
846        diffBody.push(Text({ dimColor: true, children: '(no textual change)' }))
847      } else {
848        // Code truncates at 10000 chars; we slice to the start so a giant
849        // diff still draws something. The end-of-input marker tells the
850        // user the rest was dropped.
851        const MAX = 10_000
852        const truncated = diffText.length > MAX
853        diffBody.push(
854          Code({
855            key: `diff-code-${rec.id}`,
856            source: truncated ? `${diffText.slice(0, MAX)}\n…(truncated)` : diffText,
857            format: 'diff',
858            path: rec.filePath,
859          }),
860        )
861      }
862
863      return Box({
864        flexDirection: 'column',
865        gap: 1,
866        paddingLeft: 2,
867        children: [mainRow, ...diffBody],
868      })
869    })
870
871    // VSCode-style: no empty row between file row and its edit rows. Each
872    // edit row carries paddingLeft: 2 to indent under the filename.
873    return Box({
874      flexDirection: 'column',
875      gap: 0,
876      children: [fileRow, ...recordRows],
877    })
878  })
879
880  // No gap between groups either — matches VSCode's flat file list.
881  return Box({
882    flexDirection: 'column',
883    paddingX: 1,
884    gap: 0,
885    children: [header, ...groupBoxes],
886  })
887}
888
889// ---------------------------------------------------------------------------
890// AbovePrompt band — single-row hint with a [View] button.
891//
892//   1. Empty state (no edits captured yet) → no tree; the handler yields
893//      via next(e) so cc-context-mod / cc-notify-mod / the engine's own
894//      surfaces can still draw their band.
895//   2. Pane already open → no tree; the band would just duplicate the
896//      Pane's title bar.
897//   3. Otherwise → "▶ File history: N edits (M files)" + [View].
898//
899// Layout note (why this is so plain): an earlier AbovePrompt build in this
900// mod tried nested Boxes / flexWrap / flexGrow:1 spacers and rendered
901// inconsistently — sometimes occluding cc-context-mod, sometimes
902// disappearing. The replay-theater mod (claude-code-playground) uses the
903// flat shape below and works. Keep it flat. If a future feature wants
904// richer band content (file basenames, +N/-M, etc.), prefer more Text
905// children in this row before reaching for nested Boxes — flexWrap and
906// flexGrow:1 stay banned here.
907// ---------------------------------------------------------------------------
908
909type BandRenderEvent = {
910  surface: string
911  props?: { bodyColumns?: number }
912}
913
914function renderBand(
915  $: EngineInterface,
916  e: BandRenderEvent,
917): unknown | null {
918  type El = (props: Record<string, unknown> & { children?: unknown }) => unknown
919  const { Box, Text, Button } = $.ui.resolve(e) as {
920    Box: El
921    Text: El
922    Button: El
923  }
924
925  // Empty + Pane-open cases both yield — the handler `next(e)`s for us.
926  if (edits.length === 0 || paneOpen) return null
927
928  const totalFiles = groupByFile(edits).length
929  const totalEdits = edits.length
930
931  const openPane = async () => {
932    try {
933      const r = await $.ui.open({
934        id: PANE_ID,
935        title: 'File history',
936        focus: true,
937        closeOnEscape: true,
938      })
939      paneOpen = r.isPlaced === true
940      if (!r.isPlaced) {
941        const reason = 'reason' in r ? String(r.reason) : 'unknown'
942        const hint = reason.includes('below 110 columns')
943          ? ' — type /file-history once to unlock'
944          : ''
945        $.ui.toast(
946          `file-history: pane open refused${hint}: ${reason}`,
947          { timeoutMs: 6000 },
948        )
949      }
950    } catch (err) {
951      const msg = String((err as { message?: string })?.message ?? err)
952      $.ui.toast(`file-history: pane open failed: ${msg}`, {
953        timeoutMs: 4000,
954      })
955    }
956    // Re-draw the band now that `paneOpen` flipped — if it didn't place,
957    // the band stays visible; if it did, the band yields and the Pane
958    // renders. The hotkey `v` also reaches the band, so this is the path
959    // for both click and keyboard.
960    $.ui.invalidate('ui.render')
961  }
962
963  return Box({
964    flexDirection: 'row',
965    gap: 2,
966    paddingX: 1,
967    children: [
968      Text({
969        color: 'magenta',
970        bold: true,
971        children: `▶ File history: ${totalEdits} edit${totalEdits === 1 ? '' : 's'} (${totalFiles} file${totalFiles === 1 ? '' : 's'})`,
972      }),
973      Button({
974        key: 'open-pane',
975        label: 'View',
976        hotkey: 'v',
977        variant: 'primary',
978        onPress: openPane,
979      }),
980    ],
981  })
982}
983
984// ---------------------------------------------------------------------------
985// Register.
986// ---------------------------------------------------------------------------
987
988export const register: Register = (on) => {
989  on('session.start', async ($, e, next) => {
990    resetState()
991    // Eagerly probe Windows Terminal so the render hot path stays sync.
992    await probeWindowsTerminal($)
993    // Same trick for cwd — groupByFile needs it for relative-path grouping.
994    await probeCwd($)
995    await $.command.register({
996      name: 'file-history',
997      description: 'Open / close the file-edit history pane',
998    })
999    return next(e)
1000  })
1001
1002  on(
1003    'classic.SessionStart',
1004    { source: ['clear', 'resume', 'fork'] },
1005    async ($, e, next) => {
1006      resetState()
1007      return next(e)
1008    },
1009  )
1010
1011  on('command.run', { command: 'file-history' }, async ($) => {
1012    // Pane toggle. `paneOpen` is best-effort — closing via the engine's X
1013    // or Esc doesn't reach us, so the next /file-history after that will
1014    // think the Pane is still open and try to close a now-missing one.
1015    // `$.ui.close` and `$.ui.open` are both safe to call against a
1016    // not-placed / already-placed Pane respectively, so the user just sees
1017    // one "useless" round-trip and we're back in sync.
1018    if (paneOpen) {
1019      try {
1020        await $.ui.close({ id: PANE_ID })
1021        paneOpen = false
1022        return { text: 'file-history: closed.' }
1023      } catch (err) {
1024        const msg = String((err as { message?: string })?.message ?? err)
1025        return { text: `file-history: close error: ${msg}` }
1026      }
1027    }
1028    try {
1029      const r = await $.ui.open({
1030        id: PANE_ID,
1031        title: 'File history',
1032        focus: true,
1033        closeOnEscape: true,
1034      })
1035      paneOpen = r.isPlaced === true
1036      return r.isPlaced
1037        ? { text: 'file-history: opened.' }
1038        : { text: 'file-history: open refused.' }
1039    } catch (err) {
1040      const msg = String((err as { message?: string })?.message ?? err)
1041      return { text: `file-history: open error: ${msg}` }
1042    }
1043  })
1044
1045  on('tool.call', { tool: 'Edit' }, async ($, e, next) =>
1046    recordTool($, e, 'edit', next) as never,
1047  )
1048
1049  on('tool.call', { tool: 'Write' }, async ($, e, next) =>
1050    recordTool($, e, 'write', next) as never,
1051  )
1052
1053  on('tool.call', { tool: 'Bash' }, async ($, e, next) =>
1054    recordBash($, e as BashToolInput, next as never) as never,
1055  )
1056
1057  // Pane: opened on demand by the band's [View] button or /file-history.
1058  // Engine-controlled dark chrome is the cost of an interactive sidebar
1059  // with multiple Buttons — the user pays for the chrome only when they
1060  // choose to look at the list or revert.
1061  on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => {
1062    return renderPane($, e as never) as never
1063  })
1064
1065  // AbovePrompt band: auto-shown once an edit lands.
1066//
1067// Coexistence: the band is shared across all mods — every plugin that
1068// returns a tree contributes a row. The engine uses ONLY the LAST tree
1069// returned, replacing whatever earlier mods drew (per the Claude Code
1070// plugin docs at code.claude.com/docs/<lang>/plugins/mods/interface).
1071// To preserve what plugins after ours draw, the docs say: put
1072// `await next(e)` as a child of a Box in OUR tree. So we await next(e),
1073// then wrap the result + our band in a column.
1074//
1075// When we have no band to add (zero edits, or the Pane is currently
1076// open), pass through next(e) alone so cc-context-mod (or whoever else
1077// already drew) keeps their tree unmolested.
1078//
1079// Layout is plain — no `flexWrap`, no `flexGrow: 1`, no nested Boxes
1080// beyond the outer column. An earlier build tried fancier layouts and
1081// rendered inconsistently (sometimes occluding cc-context-mod).
1082  on(
1083    'ui.render',
1084    { component: 'AbovePrompt' },
1085    async ($, e, next) => {
1086      type BoxEl = (props: Record<string, unknown> & { children?: unknown }) => unknown
1087      const { Box } = $.ui.resolve(e) as { Box: BoxEl }
1088      const ourBand = renderBand($, e as never)
1089      if (ourBand === null) {
1090        // No band of our own — yield via next(e) so the next plugin's
1091        // tree (typically cc-context-mod) is preserved as-is.
1092        return (await next(e)) as never
1093      }
1094      // We have a band to draw. Pull next(e) to keep the other plugin's
1095      // tree, then compose ours below it in a column.
1096      //
1097      // `next(e)` may throw "no implementation for ui.render" if no other
1098      // AbovePrompt handler is registered. Catch and treat as "no others".
1099      let others: unknown = null
1100      try {
1101        others = await next(e)
1102      } catch {
1103        others = null
1104      }
1105      // Element-shaped = plain object with a string `type` field. Test
1106      // stubs sometimes return engine internals; drop those.
1107      const looksLikeElement =
1108        others !== null &&
1109        others !== undefined &&
1110        typeof others === 'object' &&
1111        typeof (others as { type?: unknown }).type === 'string'
1112      if (!looksLikeElement) return ourBand as never
1113      // Coexistence with another AbovePrompt mod (typically cc-context-mod):
1114      // compose `others` (their tree) and `ourBand` into a column. Two shapes:
1115      //   * WT (cached in session.start): `others` is row-with-wrap — a
1116      //     column-shape child of a column container would be safe, but a
1117      //     row-with-wrap one is not; wrap it in an inner column Box to
1118      //     isolate the wrap behavior.
1119      //   * everything else (macOS, iTerm, ConEmu, mintty): a column-shape
1120      //     first child of a column container fills vertical room — direct
1121      //     nesting is the only shape that works. (The commit message of
1122      //     4792958 claimed "macOS is unaffected", which the renderer now
1123      //     proves wrong.)
1124      const children = isWindowsTerminal
1125        ? [Box({ flexDirection: 'column', children: [others] }), ourBand]
1126        : [others, ourBand]
1127      return Box({ flexDirection: 'column', children }) as never
1128    },
1129  )
1130
1131  // Keep `paneOpen` in sync when the engine closes our Pane (Escape, X,
1132  // or an engine-driven focus shift). The toggle call goes to /
1133  // file-history, so X / Escape updating `paneOpen` immediately keeps the
1134  // next /file-history from fighting itself. AbovePrompt has no engine close
1135  // event, but the band never holds an open/closed flag of its own.
1136  on('ui.close', ($, e, next) => {
1137    if (e.id === PANE_ID) {
1138      paneOpen = false
1139      $.ui.invalidate('ui.render')
1140    }
1141    return next(e)
1142  })
1143}
hooks/history.ts 194 lines
1// Pure helpers for cc-file-history-mod. No `$.` imports — every function here
2// is engine-independent and exercised directly by tests/history.test.ts.
3//
4// The shape of an EditRecord is the contract between the hooks module
5// (register.tsx) and the rendering code: an in-memory list of these is the
6// mod's only state, and history.* is what the renderer reads.
7
8export const MAX_ENTRIES = 200
9
10export type EditKind = 'edit' | 'write' | 'delete'
11
12export type EditRecord = {
13  /** Monotonic, scoped to the session; used as a React-style key in JSX. */
14  id: number
15  /** Canonicalised via $.fs.stat(path, { resolve: true }) when available. */
16  filePath: string
17  kind: EditKind
18  /** The `tool_use_id` `tool.call` carried; preserved for debugging. */
19  toolUseId: string
20  /** Wall-clock ms at the moment the tool call began. */
21  ts: number
22  /** False for a `Write` that created a brand-new file. */
23  beforeExists: boolean
24  /**
25   * Full file content before the edit. Always populated for `Write` (we
26   * `$.fs.read` it before the tool runs); `undefined` for `Edit` because the
27   * engine's `tool.call` input already has the diff hunk (`hunkBefore` /
28   * `hunkAfter`) and reading the full file just for Revert is a cost we
29   * skip — Edit's Revert does a `new_string → old_string` find-and-replace
30   * on the current file instead. `undefined` when `!beforeExists`.
31   */
32  before: string | undefined
33  /**
34   * Full file content right after `next(e)` returned. Set for `Write` from
35   * the input's `content` (no second `$.fs.read`); `undefined` for `Edit`
36   * (the engine's input has the hunk; full file is irrelevant).
37   */
38  after?: string | undefined
39  /**
40   * The changed region only — what `old_string` / `new_string` carry in the
41   * `Edit` tool input. We form a minimal unified-diff hunk from these in
42   * `unifiedDiff` so we don't shell out `diff -u` for `Edit`. Set for `Edit`
43   * only; `undefined` for `Write` (its full before/after diff uses `before` /
44   * `after`).
45   */
46  hunkBefore?: string | undefined
47  hunkAfter?: string | undefined
48  /** True once `next(e)` returned without `isError` / `deny`. */
49  applied: boolean
50}
51
52// --- list ops -------------------------------------------------------------
53
54/** Keep the last `max` entries; original ordering preserved. */
55export function cap<T>(records: readonly T[], max: number): T[] {
56  return records.length <= max ? records.slice() : records.slice(-max)
57}
58
59/**
60 * Strip the `cwd + '/'` prefix from `p` to produce a project-relative path.
61 * Returns `p` unchanged when it doesn't sit under `cwd`, when `cwd` is empty
62 * / null, or when `p` equals `cwd` exactly (the latter can't happen for a
63 * file, but we still want a sensible fallback rather than `''`).
64 *
65 * Both `p` and `cwd` are canonicalised through `canonicalPath` first so a
66 * mixed-separator `cwd` (`./foo` / `a\b`) doesn't break the prefix check.
67 *
68 * This is a *display / grouping* helper only. The stored `EditRecord.filePath`
69 * stays absolute because `$.fs.read` / `$.fs.write` and the Revert path need
70 * an absolute target. Two records that share the same relative form collapse
71 * into one group in `groupByFile`.
72 */
73export function relativePath(
74  p: string,
75  cwd: string | null | undefined,
76): string {
77  if (!cwd) return p
78  const normP = canonicalPath(p)
79  const normCwd = canonicalPath(cwd)
80  // Require both to be absolute (or both relative). `cwd = './work/proj'`
81  // against `p = '/work/proj/...'` is ambiguous — the leading `./` doesn't
82  // appear in p, so we'd never match. Fall through to absolute p in that
83  // case rather than silently produce a wrong relative path.
84  if (normP.startsWith('/') !== normCwd.startsWith('/')) return normP
85  if (normP === normCwd) return '.'
86  const prefix = normCwd.endsWith('/') ? normCwd : normCwd + '/'
87  if (normP.startsWith(prefix)) return normP.slice(prefix.length)
88  return normP
89}
90
91/**
92 * Group records by file path, file groups sorted by most-recent record first,
93 * records within a group sorted most-recent first.
94 *
95 * When `cwd` is provided, the group key is the cwd-relative form so two
96 * records of the same file (regardless of absolute-path spelling) collapse
97 * into one group, and `g.filePath` carries the relative form for display.
98 * Without `cwd`, behaviour is unchanged: absolute paths, canonicalised only.
99 */
100export function groupByFile(
101  records: readonly EditRecord[],
102  cwd?: string | null,
103): Array<{ filePath: string; records: EditRecord[] }> {
104  const byFile = new Map<string, EditRecord[]>()
105  for (const r of records) {
106    const key = relativePath(canonicalPath(r.filePath), cwd)
107    const list = byFile.get(key) ?? []
108    list.push(r)
109    byFile.set(key, list)
110  }
111  const groups: Array<{ filePath: string; records: EditRecord[] }> = []
112  for (const [filePath, recs] of byFile.entries()) {
113    recs.sort((a, b) => b.ts - a.ts)
114    groups.push({ filePath, records: recs })
115  }
116  groups.sort((a, b) => {
117    const at = a.records[0]?.ts ?? 0
118    const bt = b.records[0]?.ts ?? 0
119    return bt - at
120  })
121  return groups
122}
123
124// --- path / display helpers -----------------------------------------------
125
126/** Normalise separators and collapse `./` for use as a grouping key. */
127export function canonicalPath(p: string): string {
128  return p.replace(/\\/g, '/').replace(/\/\.\//g, '/')
129}
130
131/** Last segment of a path, regardless of separator. */
132export function basename(p: string): string {
133  const norm = p.replace(/\\/g, '/')
134  const i = norm.lastIndexOf('/')
135  return i < 0 ? norm : norm.slice(i + 1)
136}
137
138/**
139 * Everything before the basename, with a trailing `/`. Empty string when the
140 * path has no parent (basename only) — callers decide whether to omit.
141 */
142export function parentPath(p: string): string {
143  const norm = p.replace(/\\/g, '/')
144  const i = norm.lastIndexOf('/')
145  if (i < 0) return ''
146  return norm.slice(0, i + 1)
147}
148
149/**
150 * Cheap "+N / -M" line-count delta without invoking a real diff. Suitable
151 * for a small inline label; the renderer still shows the full content
152 * snapshot on revert.
153 *
154 * Empty / undefined input counts as 0 lines; a non-empty string ending in
155 * `\n` is one line shorter than its `split('\n').length` (the trailing
156 * empty entry) — we don't bother correcting that, the label is approximate.
157 */
158export function diffStats(
159  before: string | undefined,
160  after: string | undefined,
161): { added: number; removed: number } {
162  const b = before ? before.split('\n').length : 0
163  const a = after ? after.split('\n').length : 0
164  const delta = a - b
165  if (delta === 0) return { added: 0, removed: 0 }
166  return delta > 0 ? { added: delta, removed: 0 } : { added: 0, removed: -delta }
167}
168
169/** `HH:MM:SS` from a wall-clock ms. */
170export function formatTime(ts: number): string {
171  const d = new Date(ts)
172  const pad = (n: number) => String(n).padStart(2, '0')
173  return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
174}
175
176/**
177 * Compact human-readable "N s/min/hr/d ago" relative to `now` (default
178 * `Date.now()`). Falls back to `formatTime(ts)` for clock skew (future
179 * timestamps) and anything older than a week, where the absolute time reads
180 * more usefully than "8d ago".
181 */
182export function relativeTime(ts: number, now: number = Date.now()): string {
183  const deltaMs = now - ts
184  if (deltaMs < 0) return formatTime(ts)
185  const sec = Math.floor(deltaMs / 1000)
186  if (sec < 60) return `${sec}s ago`
187  const min = Math.floor(sec / 60)
188  if (min < 60) return `${min} min ago`
189  const hr = Math.floor(min / 60)
190  if (hr < 24) return `${hr} hr ago`
191  const day = Math.floor(hr / 24)
192  if (day < 7) return `${day}d ago`
193  return formatTime(ts)
194}
types/index.d.ts 15 lines
1// Plugin state contract for cc-file-history-mod.
2//
3// The engine uses this file to type every `$.state.get` / `$.state.set` call
4// in hooks/register.tsx (and to verify each value's key on plugin load via
5// `claude plugin validate`). This plugin declares no `$.state` values — all
6// state (edits, expanded set, diff cache, paneOpen) is module-local and
7// resets on hot reload.
8
9declare module 'claude-code' {
10  interface PluginState {
11    'cc-file-history-mod': {
12      // intentionally empty: no persisted state
13    }
14  }
15}