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.

A Claude Code mod that captures every file Claude has edited this session and lets you revert any one of them. Two surfaces:
▶ 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.[ 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.
▶ File history: 4 edits (3 files) [ View ]
[ View ] button (hotkey v).N edits (M files); pluralises correctly (1 edit, 2 edits).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.┌─ 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 ] │
└───────────────────────────────────────────────────────────────────────┘
Edit or Write Claude issued, newest first.● (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").$.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.| Hook | Why | ||
|---|---|---|---|
session.start | Reset 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:
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.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.
For v1, configuration is module-local constants in hooks/history.ts / hooks/register.tsx:
| Constant | Default | Purpose |
|---|---|---|
MAX_ENTRIES | 200 | Hard 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). |
MultiEdit or NotebookEdit — only Edit and Write are recorded. MultiEdit is not declared in this build's BuiltinToolInputs.$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./clear wipe history.git stash / git checkout; revert is a plain file write (or rm).file_path.[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.[ 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.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.hooks/register.tsx 1143 lines1// 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 lines1// 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 lines1// 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}