Pannello con un tab per ogni file modificato da Claude e il diff evidenziato

Un pannello con una scheda per ogni file che Claude ha modificato in questa sessione: il diff evidenziato di ogni modifica e, a fine turno, il perché scritto da Claude. Serve a seguire cosa cambia senza aprire i file uno per uno.
/diff-checker apre il pannello e risponde «Pannello aperto: N file.» (oppure «Pannello aperto: nessuna modifica ancora.»).
| Tasto | Cosa fa |
|---|---|
1–9 | apre il file di quella scheda (solo le prime nove) |
e | apre tutte le modifiche del file |
c | chiude tutte le modifiche del file |
m | mostra o nasconde la «Panoramica» |
I tasti valgono quando il pannello ha il cursore; le frecce scorrono, Esc torna al prompt. Senza tasto: «⌫ Svuota» (chiede «Svuotare tutto?» e poi «Sì, svuota» o «Annulla»), «✕» accanto al nome di un worktree, che svuota solo le schede di quel worktree e ramo dopo la stessa conferma (compare quando le schede sono divise in più gruppi), «▸ apri» / «▾ chiudi» su ogni modifica, «Riprova» su una spiegazione fallita. Anche i nomi nella mappa e nella linea del tempo si possono premere.
Cosa si vede:
+ e −;La riga di stato mostra ◆ N file · +X −Y.
Edit e Write riuscito registra il diff. Un'azione negata o fallita non lascia traccia.Edit e Write restituiscono già.git -C <cartella> rev-parse --show-toplevel --abbrev-ref HEAD (timeout 5 secondi), per sapere worktree e ramo.$.model.fork sulla trascrizione della sessione stessa, senza strumenti. Manda al massimo 12 modifiche per domanda e 60 righe di diff per modifica, con id e percorso. Chiede la risposta in JSON. Al massimo due giri per fine turno. Ogni domanda consuma token del tuo account.git nel PATH, solo per raggruppare per worktree e ramo. Senza git le schede non sono raggruppate e sopra il diff resta il percorso completo.Edit e Write.@@ che ci sta./diff-checker si apre a ogni larghezza.claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install diff-checker@enrik-mods
Oppure, dal prompt di Claude Code: /plugin install diff-checker --marketplace Wowbagger1994/claude-mods.
Dalla cartella della mod:
claude plugin validate .
claude plugin test .
I test sono 63, in 5 file.
hooks/register.tsx 239 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ChangedFile, ExplanationState } from '../types'
5import { addEdit, mapEdit, toEditRecord, toFileEdit } from './diff'
6import { keptExpanded, withoutGroup } from './drawings'
7import { buildPrompt, parseExplanations, pendingEdits } from './explain'
8import type { RepoInfo } from './diff'
9import { dirOf, parseRevParse, revParseArgv } from './git'
10import type { Actions } from './view'
11import { emptyView, paneView } from './view'
12
13const PLUGIN = 'diff-checker'
14const PANE = 'diff-checker'
15const TITLE = 'Diff checker'
16/** Rounds of questions one end of turn may ask, so a reply that skips ids cannot loop. */
17const MAX_ASK_ROUNDS = 2
18
19const changes = atom({ plugin: 'diff-checker', key: 'changes' } as const, [])
20const selected = atom({ plugin: 'diff-checker', key: 'selected' } as const, null)
21const hasAutoOpened = atom({ plugin: 'diff-checker', key: 'hasAutoOpened' } as const, false)
22const expanded = atom({ plugin: 'diff-checker', key: 'expanded' } as const, {})
23const isOverviewOpen = atom({ plugin: 'diff-checker', key: 'isOverviewOpen' } as const, true)
24const confirmingClear = atom({ plugin: 'diff-checker', key: 'confirmingClear' } as const, null)
25/** `confirmingClear` for every file. */
26const ALL = '*'
27
28const GIT_TIMEOUT_MS = 5_000
29
30/** One question at a time: the module's own flag, reset by a reload. */
31let isAsking = false
32/** Asked once per folder per load: the module's own cache, rebuilt after a reload. */
33const repoCache = new Map<string, Promise<RepoInfo | null>>()
34
35const askGit = async ($: EngineInterface, dir: string): Promise<RepoInfo | null> => {
36 try {
37 const { exitCode, stdout } = await $.process.run(revParseArgv(dir), { timeoutMs: GIT_TIMEOUT_MS })
38 return parseRevParse(exitCode, stdout)
39 } catch {
40 return null
41 }
42}
43
44/** The worktree and branch a file lives in, or null outside git or when git cannot answer. */
45const repoOf = ($: EngineInterface, filePath: string): Promise<RepoInfo | null> => {
46 const dir = dirOf(filePath)
47 const known = repoCache.get(dir)
48 if (known !== undefined) return known
49
50 const asked = askGit($, dir)
51 repoCache.set(dir, asked)
52 return asked
53}
54
55const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE })
56
57/** Opens the pane by itself once per session, at the first change. */
58const autoOpenOnce = async ($: EngineInterface) => {
59 if (await read($, hasAutoOpened)) return
60 await update($, hasAutoOpened, () => true)
61 const opened = await openPane($)
62 if (!opened.isPlaced) $.ui.toast('Diff checker: allarga il terminale (144 colonne) o usa /diff-checker')
63}
64
65const showStatus = ($: EngineInterface, files: readonly ChangedFile[]) => {
66 if (files.length === 0) return $.ui.status(undefined)
67 const added = files.reduce((s, f) => s + f.edits.reduce((x, e) => x + e.added, 0), 0)
68 const removed = files.reduce((s, f) => s + f.edits.reduce((x, e) => x + e.removed, 0), 0)
69 $.ui.status(`◆ ${files.length} file · +${added} −${removed}`)
70}
71
72type Ran = { deny?: string; isError?: true; result?: unknown }
73
74const recordChange = async ($: EngineInterface, toolUseId: string | undefined, result: unknown) => {
75 const record = toEditRecord(result)
76 if (record === null) return
77
78 const at = await $.clock.now()
79 const repo = await repoOf($, record.filePath)
80 const edit = toFileEdit(toolUseId ?? `${record.filePath}:${at}`, record, at)
81 await update($, changes, list => addEdit(list, record, edit, repo))
82 await update($, selected, () => record.filePath)
83 showStatus($, await read($, changes))
84 await autoOpenOnce($)
85}
86
87/** The edit has already run when the diff is read: a failure here never changes its answer. */
88const recordSafely = async ($: EngineInterface, toolUseId: string | undefined, ran: Ran) => {
89 if (ran.deny !== undefined || ran.isError === true) return
90 try {
91 await recordChange($, toolUseId, ran.result)
92 } catch (error) {
93 $.ui.log(`${PLUGIN}: diff non registrato: ${String(error)}`, { to: 'debug' })
94 }
95}
96
97const setStates = (
98 $: EngineInterface,
99 ids: readonly string[],
100 state: ExplanationState,
101 texts: ReadonlyMap<string, string> = new Map(),
102) =>
103 update($, changes, files =>
104 ids.reduce(
105 (acc, id) => mapEdit(acc, id, e => ({ ...e, explanationState: state, explanation: texts.get(id) ?? e.explanation })),
106 files,
107 ),
108 )
109
110/** Asks Claude, over this session's transcript, why it made the edits still waiting for a reason. */
111export const explainPending = async ($: EngineInterface) => {
112 if (isAsking) return
113 isAsking = true
114 try {
115 for (let round = 0; round < MAX_ASK_ROUNDS; round += 1) {
116 const pending = pendingEdits(await read($, changes))
117 if (pending.length === 0) return
118 const ids = pending.map(p => p.edit.id)
119
120 await setStates($, ids, 'loading')
121 const reply = await $.model.fork({ prompt: buildPrompt(pending) })
122
123 if (!reply.isAnswered) {
124 await setStates($, ids, reply.reason === 'nothing-to-fork' ? 'pending' : 'failed')
125 return
126 }
127 const texts = parseExplanations(reply.text, ids)
128 await setStates($, ids.filter(id => texts.has(id)), 'done', texts)
129 await setStates($, ids.filter(id => !texts.has(id)), 'failed')
130 }
131 } catch (error) {
132 $.ui.log(`${PLUGIN}: spiegazioni non riuscite: ${String(error)}`, { to: 'debug' })
133 const stuck = (await read($, changes)).flatMap(f => f.edits).filter(e => e.explanationState === 'loading')
134 await setStates($, stuck.map(e => e.id), 'failed')
135 } finally {
136 isAsking = false
137 }
138}
139
140const clearAll = async ($: EngineInterface) => {
141 await update($, changes, () => [])
142 await update($, selected, () => null)
143 await update($, expanded, () => ({}))
144 await update($, confirmingClear, () => null)
145 showStatus($, [])
146}
147
148/** Empties one worktree's group, the files of the others kept, and what was open in them. */
149const clearGroup = async ($: EngineInterface, key: string) => {
150 const kept = withoutGroup(await read($, changes), key)
151 await update($, changes, () => kept)
152 await update($, selected, path => (kept.some(f => f.path === path) ? path : null))
153 await update($, expanded, map => keptExpanded(map, kept))
154 await update($, confirmingClear, () => null)
155 showStatus($, kept)
156}
157
158const actionsFor = ($: EngineInterface): Actions => ({
159 select: path => update($, selected, () => path),
160 toggleEdit: (id, isOpen) => update($, expanded, map => ({ ...map, [id]: isOpen })),
161 setAllEdits: (file, isOpen) =>
162 update($, expanded, map => ({ ...map, ...Object.fromEntries(file.edits.map(e => [e.id, isOpen])) })),
163 toggleOverview: () => update($, isOverviewOpen, isOpen => !isOpen),
164 askClear: () => update($, confirmingClear, () => ALL),
165 askClearGroup: key => update($, confirmingClear, () => key),
166 cancelClear: () => update($, confirmingClear, () => null),
167 confirmClear: () => clearAll($),
168 confirmClearGroup: key => clearGroup($, key),
169 retryExplanation: async id => {
170 await setStates($, [id], 'pending')
171 await explainPending($)
172 },
173})
174
175export const register: Register = on => {
176 on('session.start', async ($, e, next) => {
177 await $.command.register({
178 name: 'diff-checker',
179 description: 'Apre il pannello con i file modificati da Claude, i diff e il perché di ogni modifica',
180 })
181
182 return next(e)
183 })
184
185 on('command.run', { command: 'diff-checker' }, async $ => {
186 await openPane($)
187 const count = (await read($, changes)).length
188
189 return { text: count === 0 ? 'Pannello aperto: nessuna modifica ancora.' : `Pannello aperto: ${count} file.` }
190 })
191
192 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
193 const ran = await next(e)
194 await recordSafely($, e.tool_use_id, ran)
195
196 return ran
197 }).catch(($, e, next) => next(e))
198
199 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
200 const ran = await next(e)
201 await recordSafely($, e.tool_use_id, ran)
202
203 return ran
204 }).catch(($, e, next) => next(e))
205
206 // The main thread's turn is over and its transcript holds the reasons: ask outside this dispatch.
207 on('turn.complete', async ($, e, next) => {
208 const done = await next(e)
209 if (e.agentId === undefined && pendingEdits(await read($, changes)).length > 0) {
210 $.clock.after(0, () => void explainPending($))
211 }
212
213 return done
214 }).catch(($, e, next) => next(e))
215
216 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
217 const el = $.ui.resolve(e)
218 const files = await read($, changes)
219 const wanted = await read($, selected)
220 const current = files.find(f => f.path === wanted) ?? files[files.length - 1]
221 if (current === undefined) return emptyView(el)
222
223 return paneView(
224 el,
225 {
226 files,
227 current,
228 expanded: await read($, expanded),
229 isOverviewOpen: await read($, isOverviewOpen),
230 confirmingClear: await read($, confirmingClear),
231 isFocused: e.props.isFocused,
232 columns: Math.max(40, e.props.bodyColumns),
233 now: await $.clock.now(),
234 },
235 actionsFor($),
236 )
237 })
238}
239hooks/diff.ts 154 lines1import type { ChangedFile, FileEdit } from '../types'
2import { countLines, marksOf, shiftMarks } from './marks'
3import type { Hunk } from './marks'
4
5/** Edits kept per file: older ones fall off so one drawing stays well under 100k chars. */
6const MAX_EDITS_PER_FILE = 20
7/** A single edit's diff is cut past this many characters. */
8const MAX_DIFF_CHARS = 30_000
9/** Files kept in the pane; the oldest touched falls off. */
10const MAX_FILES = 40
11
12/** What Edit and Write report back that the pane needs. */
13export type EditRecord = {
14 filePath: string
15 isNew: boolean
16 hunks: Hunk[]
17 /** Lines in the file after the edit, when the result says enough to count them. */
18 lineCount: number | null
19}
20
21/** The worktree a file lives in, as git reports it. */
22export type RepoInfo = { root: string; branch: string | null }
23
24const isHunk = (value: unknown): value is Hunk => {
25 const h = value as Hunk
26 return (
27 typeof h === 'object' &&
28 h !== null &&
29 typeof h.oldStart === 'number' &&
30 typeof h.oldLines === 'number' &&
31 typeof h.newStart === 'number' &&
32 typeof h.newLines === 'number' &&
33 Array.isArray(h.lines) &&
34 h.lines.every(line => typeof line === 'string')
35 )
36}
37
38const signCount = (hunks: readonly Hunk[], sign: '+' | '-'): number =>
39 hunks.reduce((sum, h) => sum + h.lines.filter(l => l.startsWith(sign)).length, 0)
40
41const lineCountAfter = (r: Record<string, unknown>, hunks: readonly Hunk[]): number | null => {
42 if (typeof r.content === 'string') return countLines(r.content)
43 if (typeof r.originalFile === 'string') {
44 return countLines(r.originalFile) + signCount(hunks, '+') - signCount(hunks, '-')
45 }
46 return null
47}
48
49/**
50 * Reads an Edit or Write result into an EditRecord, or null when it holds
51 * nothing to show (an error, a staged edit, a write that changed nothing).
52 */
53export const toEditRecord = (result: unknown): EditRecord | null => {
54 const r = result as Record<string, unknown> | null
55 if (typeof r !== 'object' || r === null || typeof r.filePath !== 'string') return null
56 if (r.staged === true) return null
57
58 const hunks = Array.isArray(r.structuredPatch) ? r.structuredPatch.filter(isHunk) : []
59 if (hunks.length > 0) {
60 return { filePath: r.filePath, isNew: r.type === 'create', hunks, lineCount: lineCountAfter(r, hunks) }
61 }
62
63 if (r.type === 'create' && typeof r.content === 'string') {
64 const lines = r.content.replace(/\n$/, '').split('\n')
65 const hunk = { oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: lines.map(l => `+${l}`) }
66 return { filePath: r.filePath, isNew: true, hunks: [hunk], lineCount: lines.length }
67 }
68
69 return null
70}
71
72/** `Code` accepts tab and newline only: any other control character refuses the tree. */
73const sanitize = (line: string): string =>
74 // eslint-disable-next-line no-control-regex
75 line.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
76
77const header = (h: Hunk): string => `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`
78
79/** Turns one record into the unified diff `Code` draws, with its +/- counts. */
80export const toFileEdit = (id: string, record: EditRecord, at: number): FileEdit => {
81 const text = record.hunks.flatMap(h => [header(h), ...h.lines.map(sanitize)]).join('\n')
82
83 return {
84 id,
85 diff: text.length > MAX_DIFF_CHARS ? cutAtHunk(text) : text,
86 added: signCount(record.hunks, '+'),
87 removed: signCount(record.hunks, '-'),
88 at,
89 explanation: null,
90 explanationState: 'pending',
91 }
92}
93
94/** Cuts a long diff at the last hunk boundary before the limit, so it still parses. */
95const cutAtHunk = (text: string): string => {
96 const cut = text.lastIndexOf('\n@@', MAX_DIFF_CHARS)
97 return cut > 0 ? text.slice(0, cut) : text.slice(0, MAX_DIFF_CHARS)
98}
99
100/** Adds an edit to the list, moving its file to the end (most recent). Never mutates. */
101export const addEdit = (
102 files: readonly ChangedFile[],
103 record: EditRecord,
104 edit: FileEdit,
105 repo: RepoInfo | null,
106): ChangedFile[] => {
107 const previous = files.find(f => f.path === record.filePath)
108 const others = files.filter(f => f.path !== record.filePath)
109 const edits = [...(previous?.edits ?? []), edit].slice(-MAX_EDITS_PER_FILE)
110 const kept = new Set(edits.map(e => e.id))
111 const marks = [...shiftMarks(previous?.marks ?? [], record.hunks), ...marksOf(edit.id, record.hunks)]
112 const carried = previous?.lineCount == null ? null : previous.lineCount + edit.added - edit.removed
113
114 const file: ChangedFile = {
115 path: record.filePath,
116 isNew: previous?.isNew ?? record.isNew,
117 edits,
118 lineCount: record.lineCount ?? carried,
119 marks: marks.filter(m => kept.has(m.editId)),
120 root: repo?.root ?? previous?.root ?? null,
121 branch: repo?.branch ?? previous?.branch ?? null,
122 }
123
124 return [...others, file].slice(-MAX_FILES)
125}
126
127/** Replaces one edit wherever it is, through `fn`. Never mutates. */
128export const mapEdit = (
129 files: readonly ChangedFile[],
130 id: string,
131 fn: (edit: FileEdit) => FileEdit,
132): ChangedFile[] =>
133 files.map(file =>
134 file.edits.some(e => e.id === id) ? { ...file, edits: file.edits.map(e => (e.id === id ? fn(e) : e)) } : file,
135 )
136
137const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1) || path
138
139/** The tab label: the file name, with its folder when two tabs of the group share a name. */
140export const tabLabel = (path: string, group: readonly ChangedFile[]): string => {
141 const parts = path.split('/')
142 const name = baseName(path)
143 const isShared = group.some(f => f.path !== path && baseName(f.path) === name)
144
145 return isShared && parts.length > 1 ? `${parts[parts.length - 2]}/${name}` : name
146}
147
148/** The path as shown above the diff: inside its worktree when known, else with the home folder as ~. */
149export const shortPath = (path: string, root: string | null = null): string =>
150 root !== null && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path.replace(/^\/Users\/[^/]+/, '~')
151
152/** A worktree's short name: its folder's name. */
153export const worktreeName = (root: string): string => baseName(root)
154hooks/drawings.ts 145 lines1import type { ChangeKind, ChangedFile, FileEdit, Mark } from '../types'
2
3/** How a cell is painted: by what changed there, `older` for an earlier edit's lines, `idle` for none. */
4export type Tone = ChangeKind | 'older' | 'idle' | 'latest'
5
6/** A run of cells drawn in one tone, ready for a Text span. */
7export type Run = { text: string; tone: Tone }
8
9const MINUTE = 60_000
10const HOUR = 60 * MINUTE
11
12/** Joins neighbouring cells of one tone into runs, so a row is a handful of spans. */
13export const toRuns = (cells: readonly { glyph: string; tone: Tone }[]): Run[] =>
14 cells.reduce<Run[]>((runs, cell) => {
15 const last = runs[runs.length - 1]
16 return last !== undefined && last.tone === cell.tone
17 ? [...runs.slice(0, -1), { text: last.text + cell.glyph, tone: last.tone }]
18 : [...runs, { text: cell.glyph, tone: cell.tone }]
19 }, [])
20
21const KIND_RANK: Record<ChangeKind, number> = { removed: 1, added: 2, changed: 3 }
22
23/**
24 * The minimap: the whole file squeezed into `width` cells, left to right as top
25 * to bottom. A cell shows the strongest change in its slice of lines; the latest
26 * edit's in its own colour, earlier edits' dim.
27 */
28export const minimapCells = (
29 marks: readonly Mark[],
30 lineCount: number,
31 latestId: string,
32 width: number,
33): { glyph: string; tone: Tone }[] => {
34 const lines = Math.max(lineCount, 1, ...marks.map(m => m.end))
35 const cells = Math.max(1, Math.min(width, lines))
36
37 return Array.from({ length: cells }, (_, i) => {
38 const from = Math.floor((i * lines) / cells) + 1
39 const to = Math.floor(((i + 1) * lines) / cells)
40 const hits = marks.filter(m => m.start <= Math.max(to, from) && m.end >= from)
41 const latest = hits.filter(m => m.editId === latestId)
42 const strongest = [...latest].sort((a, b) => KIND_RANK[b.kind] - KIND_RANK[a.kind])[0]
43
44 if (strongest !== undefined) return { glyph: strongest.kind === 'removed' ? '▼' : '█', tone: strongest.kind }
45 if (hits.length > 0) return { glyph: '▆', tone: 'older' }
46 return { glyph: '─', tone: 'idle' }
47 })
48}
49
50/** Where an edit's weight lies: mostly added, mostly removed, or both. */
51export const editTone = (added: number, removed: number): ChangeKind => {
52 const total = added + removed
53 if (total === 0 || added / total >= 2 / 3) return 'added'
54 if (removed / total >= 2 / 3) return 'removed'
55 return 'changed'
56}
57
58/**
59 * One timeline row: the session from the first edit to the last across `width`
60 * cells, a dot where this file was edited, a digit where several edits fell in one cell.
61 */
62export const timelineCells = (
63 edits: readonly FileEdit[],
64 from: number,
65 to: number,
66 width: number,
67 latestId: string,
68): { glyph: string; tone: Tone }[] => {
69 const span = Math.max(to - from, 1)
70 const slots = Math.max(width, 1)
71 const at = (edit: FileEdit) => Math.min(slots - 1, Math.round(((edit.at - from) / span) * (slots - 1)))
72
73 return Array.from({ length: slots }, (_, i) => {
74 const here = edits.filter(e => at(e) === i)
75 if (here.length === 0) return { glyph: '·', tone: 'idle' }
76
77 const glyph = here.length === 1 ? '●' : here.length < 10 ? String(here.length) : '◉'
78 const isLatest = here.some(e => e.id === latestId)
79 const added = here.reduce((s, e) => s + e.added, 0)
80 const removed = here.reduce((s, e) => s + e.removed, 0)
81
82 return { glyph, tone: isLatest ? 'latest' : editTone(added, removed) }
83 })
84}
85
86/**
87 * The file map's block widths: each file's share of `width` by lines changed,
88 * no block under `minWidth`, the widths summing to `width` (largest remainder).
89 */
90export const treemapWidths = (weights: readonly number[], width: number, minWidth: number): number[] => {
91 if (weights.length === 0) return []
92 const floor = Math.min(minWidth, Math.floor(width / weights.length))
93 const spare = Math.max(0, width - floor * weights.length)
94 const total = weights.reduce((s, w) => s + Math.max(w, 1), 0)
95 const exact = weights.map(w => (Math.max(w, 1) / total) * spare)
96 const base = exact.map(Math.floor)
97 const left = spare - base.reduce((s, b) => s + b, 0)
98 const order = exact.map((x, i) => ({ i, frac: x - Math.floor(x) })).sort((a, b) => b.frac - a.frac)
99 const bonus = new Set(order.slice(0, left).map(o => o.i))
100
101 return base.map((b, i) => floor + b + (bonus.has(i) ? 1 : 0))
102}
103
104/** A bar of `cells` blocks, filled in proportion to `value` of `max`, at least one when value > 0. */
105export const changeBar = (value: number, max: number, cells: number): string => {
106 const filled = max <= 0 || value <= 0 ? 0 : Math.max(1, Math.round((value / max) * cells))
107 return '▰'.repeat(filled) + '▱'.repeat(Math.max(0, cells - filled))
108}
109
110/** "ora", "5 min fa", "2 h fa": coarse on purpose, the pane redraws only on change. */
111export const relativeTime = (at: number, now: number): string => {
112 const ago = Math.max(0, now - at)
113 if (ago < MINUTE) return 'ora'
114 if (ago < HOUR) return `${Math.floor(ago / MINUTE)} min fa`
115 return `${Math.floor(ago / HOUR)} h fa`
116}
117
118/** Files grouped by worktree in first-touched order; files outside git share the `null` group. */
119export const groupByRoot = (files: readonly ChangedFile[]): { root: string | null; files: ChangedFile[] }[] =>
120 files.reduce<{ root: string | null; files: ChangedFile[] }[]>((groups, file) => {
121 const group = groups.find(g => g.root === file.root)
122 return group === undefined
123 ? [...groups, { root: file.root, files: [file] }]
124 : groups.map(g => (g === group ? { ...g, files: [...g.files, file] } : g))
125 }, [])
126
127/** A group's address in a key or a confirmation: its root, `-` for the files outside git. */
128export const groupKey = (root: string | null): string => root ?? '-'
129
130/** The files of the other groups: a group cleared, the rest kept as they were. */
131export const withoutGroup = (files: readonly ChangedFile[], key: string): ChangedFile[] => files.filter(f => groupKey(f.root) !== key)
132
133/** The opened or closed edits that still belong to a file shown. */
134export const keptExpanded = (expanded: Readonly<Record<string, boolean>>, files: readonly ChangedFile[]): Record<string, boolean> => {
135 const ids = new Set(files.flatMap(f => f.edits.map(e => e.id)))
136 return Object.fromEntries(Object.entries(expanded).filter(([id]) => ids.has(id)))
137}
138
139/** Cuts a label to `width` cells with an ellipsis. */
140export const fit = (text: string, width: number): string =>
141 width <= 0 ? '' : text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
142
143/** Lines a file has changed over the session, the file map's weight. */
144export const churn = (file: ChangedFile): number => file.edits.reduce((s, e) => s + e.added + e.removed, 0)
145hooks/explain.ts 56 lines1import type { ChangedFile, FileEdit } from '../types'
2import { shortPath } from './diff'
3
4/** Diff lines sent per edit: enough to recall it, short enough to keep the question small. */
5const MAX_DIFF_LINES = 60
6/** Edits explained in one question; the rest wait for the next turn. */
7const MAX_EDITS_PER_ASK = 12
8/** An explanation is cut past this many characters. */
9const MAX_EXPLANATION_CHARS = 600
10
11export type PendingEdit = { file: ChangedFile; edit: FileEdit }
12
13/** Edits still waiting for an explanation, oldest first, at most one question's worth. */
14export const pendingEdits = (files: readonly ChangedFile[]): PendingEdit[] =>
15 files
16 .flatMap(file => file.edits.filter(e => e.explanationState === 'pending').map(edit => ({ file, edit })))
17 .sort((a, b) => a.edit.at - b.edit.at)
18 .slice(0, MAX_EDITS_PER_ASK)
19
20/** The one question asked over the session's own transcript. */
21export const buildPrompt = (pending: readonly PendingEdit[]): string => {
22 const blocks = pending.map(({ file, edit }) => {
23 const diff = edit.diff.split('\n').slice(0, MAX_DIFF_LINES).join('\n')
24 return `<edit id="${edit.id}" file="${shortPath(file.path, file.root)}">\n${diff}\n</edit>`
25 })
26
27 return [
28 'Domanda tecnica del pannello diff-checker, non dell’utente: non usare strumenti.',
29 'Per ognuna delle modifiche qui sotto, che hai fatto tu in questa sessione, spiega in italiano',
30 'PERCHÉ l’hai fatta: lo scopo e il motivo, in 1-3 frasi. Non ripetere cosa cambia riga per riga.',
31 'Puoi usare `codice` inline. Rispondi SOLO con un oggetto JSON: {"<id>": "<spiegazione>", ...}.',
32 '',
33 ...blocks,
34 ].join('\n')
35}
36
37/** Reads the reply's JSON object into id → explanation, ignoring anything else it says. */
38export const parseExplanations = (text: string, ids: readonly string[]): Map<string, string> => {
39 const start = text.indexOf('{')
40 const end = text.lastIndexOf('}')
41 if (start < 0 || end <= start) return new Map()
42
43 try {
44 const parsed: unknown = JSON.parse(text.slice(start, end + 1))
45 if (typeof parsed !== 'object' || parsed === null) return new Map()
46 const entries = Object.entries(parsed as Record<string, unknown>)
47 .filter((entry): entry is [string, string] => ids.includes(entry[0]) && typeof entry[1] === 'string')
48 .map(([id, why]) => [id, why.trim().slice(0, MAX_EXPLANATION_CHARS)] as [string, string])
49 .filter(([, why]) => why !== '')
50
51 return new Map(entries)
52 } catch {
53 return new Map()
54 }
55}
56hooks/git.ts 17 lines1import type { RepoInfo } from './diff'
2
3/** The folder a file is in: what git is asked from. */
4export const dirOf = (filePath: string): string => filePath.slice(0, filePath.lastIndexOf('/')) || '/'
5
6/** The argv that asks git for a folder's worktree root and branch, one per line. */
7export const revParseArgv = (dir: string): string[] => ['git', '-C', dir, 'rev-parse', '--show-toplevel', '--abbrev-ref', 'HEAD']
8
9/** Reads `git rev-parse --show-toplevel --abbrev-ref HEAD`; a detached HEAD has no branch. */
10export const parseRevParse = (exitCode: number, stdout: string): RepoInfo | null => {
11 if (exitCode !== 0) return null
12 const [root, branch] = stdout.trim().split('\n')
13 if (root === undefined || root === '') return null
14
15 return { root, branch: branch === undefined || branch === '' || branch === 'HEAD' ? null : branch }
16}
17hooks/view.tsx 372 lines1import type { ElementTable } from 'claude-code'
2
3import type { ChangedFile, FileEdit } from '../types'
4import { shortPath, tabLabel, worktreeName } from './diff'
5import type { Run, Tone } from './drawings'
6import {
7 changeBar,
8 churn,
9 editTone,
10 fit,
11 groupByRoot,
12 groupKey,
13 minimapCells,
14 relativeTime,
15 timelineCells,
16 toRuns,
17 treemapWidths,
18} from './drawings'
19
20/** Tabs 1..9 answer a digit while the pane holds the keyboard. */
21const MAX_HOTKEYS = 9
22/** No file-map block narrower than this: room for a name and its counts. */
23const MIN_BLOCK = 14
24const TIMELINE_LABEL = 16
25const BAR_CELLS = 4
26/** Columns the minimap leaves for its line numbers either side. */
27const MINIMAP_MARGIN = 12
28
29/** What a press does: the register module owns the state, the view only asks. */
30export type Actions = {
31 select: (path: string) => unknown
32 toggleEdit: (id: string, isOpen: boolean) => unknown
33 setAllEdits: (file: ChangedFile, isOpen: boolean) => unknown
34 toggleOverview: () => unknown
35 askClear: () => unknown
36 confirmClear: () => unknown
37 cancelClear: () => unknown
38 /** One group of tabs, by its `groupKey`. */
39 askClearGroup: (key: string) => unknown
40 confirmClearGroup: (key: string) => unknown
41 retryExplanation: (id: string) => unknown
42}
43
44export type ViewState = {
45 files: ChangedFile[]
46 current: ChangedFile
47 expanded: Record<string, boolean>
48 isOverviewOpen: boolean
49 /** `*` every file, a group's key, or null: what Svuota is asking to confirm. */
50 confirmingClear: string | null
51 isFocused: boolean
52 columns: number
53 now: number
54}
55
56const TONE_COLOR: Record<Tone, string | undefined> = {
57 added: 'success',
58 removed: 'error',
59 changed: 'warning',
60 latest: 'claude',
61 older: 'success',
62 idle: undefined,
63}
64
65const isDim = (tone: Tone) => tone === 'idle' || tone === 'older'
66
67const spans = (el: ElementTable, runs: readonly Run[]) =>
68 runs.map(run => (
69 <el.Text color={TONE_COLOR[run.tone]} dimColor={isDim(run.tone)}>
70 {run.text}
71 </el.Text>
72 ))
73
74const counts = (el: ElementTable, added: number, removed: number) => (
75 <el.Text>
76 <el.Text color="success">+{added}</el.Text> <el.Text color="error">−{removed}</el.Text>
77 </el.Text>
78)
79
80const header = (el: ElementTable, s: ViewState, act: Actions) => {
81 const added = s.files.reduce((sum, f) => sum + f.edits.reduce((x, e) => x + e.added, 0), 0)
82 const removed = s.files.reduce((sum, f) => sum + f.edits.reduce((x, e) => x + e.removed, 0), 0)
83
84 return (
85 <el.Box flexDirection="row" justifyContent="space-between">
86 <el.Text>
87 <el.Text bold color="claude">◆ Diff checker</el.Text>
88 <el.Text dimColor>
89 {' '}
90 {s.files.length} file ·{' '}
91 </el.Text>
92 {counts(el, added, removed)}
93 </el.Text>
94 <el.Box flexDirection="row" columnGap={2}>
95 <el.Button
96 key="overview"
97 label={s.isOverviewOpen ? '▾ Panoramica' : '▸ Panoramica'}
98 hotkey="m"
99 plain
100 dimColor
101 onPress={act.toggleOverview}
102 />
103 {s.confirmingClear === '*' ? (
104 <el.Box flexDirection="row" columnGap={1}>
105 <el.Text color="warning">Svuotare tutto?</el.Text>
106 <el.Button key="clear-yes" label="Sì, svuota" variant="primary" onPress={act.confirmClear} />
107 <el.Button key="clear-no" label="Annulla" onPress={act.cancelClear} />
108 </el.Box>
109 ) : (
110 <el.Button key="clear" label="⌫ Svuota" plain dimColor onPress={act.askClear} />
111 )}
112 </el.Box>
113 </el.Box>
114 )
115}
116
117const fileMap = (el: ElementTable, s: ViewState, act: Actions) => {
118 const byWeight = [...s.files].sort((a, b) => churn(b) - churn(a))
119 const perRow = Math.max(1, Math.floor(s.columns / MIN_BLOCK))
120 const rows = Array.from({ length: Math.ceil(byWeight.length / perRow) }, (_, i) =>
121 byWeight.slice(i * perRow, (i + 1) * perRow),
122 )
123
124 return (
125 <el.Box flexDirection="column">
126 <el.Text dimColor>MAPPA DEI FILE · grandezza = righe cambiate</el.Text>
127 {rows.map((row, r) => {
128 const widths = treemapWidths(row.map(churn), s.columns, MIN_BLOCK)
129 return (
130 <el.Box key={`map-row:${r}`} flexDirection="row">
131 {row.map((file, i) => {
132 const width = widths[i] ?? MIN_BLOCK
133 const added = file.edits.reduce((x, e) => x + e.added, 0)
134 const removed = file.edits.reduce((x, e) => x + e.removed, 0)
135 const isCurrent = file.path === s.current.path
136 return (
137 <el.Box
138 key={`map:${file.path}`}
139 width={width}
140 flexDirection="column"
141 borderStyle={isCurrent ? 'bold' : 'round'}
142 borderColor={isCurrent ? 'claude' : TONE_COLOR[editTone(added, removed)]}
143 paddingX={1}
144 >
145 <el.Button
146 key={`map-open:${file.path}`}
147 label={fit(tabLabel(file.path, s.files), width - 4)}
148 plain
149 onPress={() => act.select(file.path)}
150 />
151 {counts(el, added, removed)}
152 </el.Box>
153 )
154 })}
155 </el.Box>
156 )
157 })}
158 </el.Box>
159 )
160}
161
162const timeline = (el: ElementTable, s: ViewState, act: Actions) => {
163 const all = s.files.flatMap(f => f.edits)
164 const from = Math.min(...all.map(e => e.at))
165 const to = Math.max(...all.map(e => e.at))
166 const latest = all.reduce((a, b) => (b.at > a.at ? b : a))
167 const width = Math.max(10, s.columns - TIMELINE_LABEL - 1)
168
169 return (
170 <el.Box flexDirection="column">
171 <el.Text dimColor>
172 LINEA DEL TEMPO · dalla prima modifica ({relativeTime(from, s.now)}) all’ultima ({relativeTime(to, s.now)})
173 </el.Text>
174 {s.files.map(file => (
175 <el.Box key={`time:${file.path}`} flexDirection="row">
176 <el.Box width={TIMELINE_LABEL}>
177 <el.Button
178 key={`time-open:${file.path}`}
179 label={fit(tabLabel(file.path, s.files), TIMELINE_LABEL - 1)}
180 plain
181 dimColor={file.path !== s.current.path}
182 onPress={() => act.select(file.path)}
183 />
184 </el.Box>
185 <el.Text>{spans(el, toRuns(timelineCells(file.edits, from, to, width, latest.id)))}</el.Text>
186 </el.Box>
187 ))}
188 </el.Box>
189 )
190}
191
192const groupLabel = (group: { root: string | null; files: ChangedFile[] }) =>
193 `${group.root === null ? 'fuori da git' : worktreeName(group.root)}${group.files[0]?.branch ? ` ⎇ ${group.files[0].branch}` : ''}`
194
195/** A group's name, and its own ✕: it empties that worktree's tabs alone, after asking. */
196const groupHeader = (el: ElementTable, s: ViewState, group: { root: string | null; files: ChangedFile[] }, act: Actions) => {
197 const key = groupKey(group.root)
198 if (s.confirmingClear === key) {
199 return (
200 <el.Box key={`clear-group-ask:${key}`} flexDirection="row" columnGap={1}>
201 <el.Text color="warning">{`Svuotare ${groupLabel(group)} (${group.files.length} file)?`}</el.Text>
202 <el.Button key="clear-group-yes" label="Sì, svuota" variant="primary" onPress={() => act.confirmClearGroup(key)} />
203 <el.Button key="clear-group-no" label="Annulla" onPress={act.cancelClear} />
204 </el.Box>
205 )
206 }
207 return (
208 <el.Box key={`group-name:${key}`} flexDirection="row">
209 <el.Text color="claude">{`${groupLabel(group)}:`}</el.Text>
210 <el.Button key={`clear-group:${key}`} label="✕" plain dimColor onPress={() => act.askClearGroup(key)} />
211 </el.Box>
212 )
213}
214
215const tabs = (el: ElementTable, s: ViewState, act: Actions) => {
216 const groups = groupByRoot(s.files)
217 const isGrouped = groups.length > 1
218 const maxChurn = Math.max(...s.files.map(churn))
219 const order = groups.flatMap(g => g.files.map(f => f.path))
220
221 return (
222 <el.Box flexDirection="column">
223 {groups.map(group => (
224 <el.Box key={`group:${group.root ?? '-'}`} flexDirection="row" flexWrap="wrap" columnGap={1}>
225 {isGrouped && groupHeader(el, s, group, act)}
226 {group.files.map(file => {
227 const index = order.indexOf(file.path)
228 const isCurrent = file.path === s.current.path
229 return (
230 <el.Button
231 key={`tab:${file.path}`}
232 label={`${isCurrent ? '●' : '◇'} ${tabLabel(file.path, group.files)} ${changeBar(churn(file), maxChurn, BAR_CELLS)}`}
233 hotkey={index < MAX_HOTKEYS ? String(index + 1) : undefined}
234 variant={isCurrent ? 'primary' : undefined}
235 dimColor={!isCurrent}
236 onPress={() => act.select(file.path)}
237 />
238 )
239 })}
240 </el.Box>
241 ))}
242 </el.Box>
243 )
244}
245
246const fileHeader = (el: ElementTable, s: ViewState, act: Actions) => {
247 const file = s.current
248 const latest = file.edits[file.edits.length - 1]
249 const added = file.edits.reduce((x, e) => x + e.added, 0)
250 const removed = file.edits.reduce((x, e) => x + e.removed, 0)
251 const lines = file.lineCount ?? Math.max(1, ...file.marks.map(m => m.end))
252 const cells = minimapCells(file.marks, lines, latest?.id ?? '', Math.max(10, s.columns - MINIMAP_MARGIN))
253 const where = [
254 file.root === null ? null : worktreeName(file.root),
255 file.branch === null ? null : `⎇ ${file.branch}`,
256 file.isNew ? 'nuovo file' : null,
257 `${file.edits.length} ${file.edits.length === 1 ? 'modifica' : 'modifiche'}`,
258 ].filter(Boolean)
259
260 return (
261 <el.Box flexDirection="column">
262 <el.Box flexDirection="row" justifyContent="space-between">
263 <el.Text>
264 <el.Text bold>{shortPath(file.path, file.root)}</el.Text>
265 <el.Text dimColor> · {where.join(' · ')} · </el.Text>
266 {counts(el, added, removed)}
267 </el.Text>
268 <el.Box flexDirection="row" columnGap={2}>
269 <el.Button key="open-all" label="apri tutte" hotkey="e" plain dimColor onPress={() => act.setAllEdits(file, true)} />
270 <el.Button key="close-all" label="chiudi tutte" hotkey="c" plain dimColor onPress={() => act.setAllEdits(file, false)} />
271 </el.Box>
272 </el.Box>
273 <el.Text>
274 <el.Text dimColor>riga 1 </el.Text>
275 {spans(el, toRuns(cells))}
276 <el.Text dimColor>
277 {' '}
278 {file.lineCount === null ? `~${lines}` : lines}
279 </el.Text>
280 </el.Text>
281 </el.Box>
282 )
283}
284
285const explanation = (el: ElementTable, edit: FileEdit, act: Actions, isOpen: boolean) => {
286 if (edit.explanationState === 'done' && edit.explanation !== null) {
287 return isOpen ? (
288 <el.Markdown key={`why:${edit.id}`} text={edit.explanation} />
289 ) : (
290 <el.Text dimColor wrap="truncate-end">
291 {edit.explanation.replace(/\s+/g, ' ')}
292 </el.Text>
293 )
294 }
295 if (!isOpen) return null
296 if (edit.explanationState === 'failed') {
297 return (
298 <el.Box flexDirection="row" columnGap={1}>
299 <el.Text color="warning">Spiegazione non disponibile.</el.Text>
300 <el.Button key={`retry:${edit.id}`} label="Riprova" plain onPress={() => act.retryExplanation(edit.id)} />
301 </el.Box>
302 )
303 }
304 return (
305 <el.Text dimColor italic>
306 {edit.explanationState === 'loading' ? 'Claude sta scrivendo il perché…' : 'Il perché arriva a fine turno.'}
307 </el.Text>
308 )
309}
310
311const editCard = (el: ElementTable, s: ViewState, act: Actions, edit: FileEdit, number: number, isLatest: boolean) => {
312 const isOpen = s.expanded[edit.id] ?? isLatest
313
314 return (
315 <el.Box
316 key={`edit:${edit.id}`}
317 flexDirection="column"
318 borderStyle="round"
319 borderColor={isLatest ? 'claude' : 'subtle'}
320 paddingX={1}
321 >
322 <el.Box flexDirection="row" justifyContent="space-between">
323 <el.Text>
324 <el.Text bold>#{number}</el.Text>
325 <el.Text dimColor>
326 {' · '}
327 {isLatest ? 'ultima · ' : ''}
328 {relativeTime(edit.at, s.now)} ·{' '}
329 </el.Text>
330 {counts(el, edit.added, edit.removed)}
331 </el.Text>
332 <el.Button
333 key={`toggle:${edit.id}`}
334 label={isOpen ? '▾ chiudi' : '▸ apri'}
335 plain
336 dimColor
337 onPress={() => act.toggleEdit(edit.id, !isOpen)}
338 />
339 </el.Box>
340 {explanation(el, edit, act, isOpen)}
341 {isOpen && <el.Code source={edit.diff} format="diff" path={s.current.path} wrap="truncate-end" />}
342 </el.Box>
343 )
344}
345
346/** The whole pane for a session with at least one change. */
347export const paneView = (el: ElementTable, s: ViewState, act: Actions) => {
348 const newestFirst = [...s.current.edits].reverse()
349
350 return (
351 <el.Box flexDirection="column" gap={1}>
352 {header(el, s, act)}
353 {s.isOverviewOpen && fileMap(el, s, act)}
354 {s.isOverviewOpen && timeline(el, s, act)}
355 {tabs(el, s, act)}
356 {fileHeader(el, s, act)}
357 {newestFirst.map((edit, i) => editCard(el, s, act, edit, s.current.edits.length - i, i === 0))}
358 {s.isFocused && (
359 <el.Text dimColor>1–9 file · e/c apri/chiudi tutte · m panoramica · frecce per scorrere · Esc torna al prompt</el.Text>
360 )}
361 </el.Box>
362 )
363}
364
365/** The pane before anything changed. */
366export const emptyView = (el: ElementTable) => (
367 <el.Box flexDirection="column">
368 <el.Text bold color="claude">◆ Diff checker</el.Text>
369 <el.Text dimColor>Nessuna modifica in questa sessione: il pannello si riempie quando Claude modifica o crea un file.</el.Text>
370 </el.Box>
371)
372hooks/marks.ts 89 lines1import type { ChangeKind, Mark } from '../types'
2
3/** The hunk shape Edit and Write report in `structuredPatch`. */
4export type Hunk = {
5 oldStart: number
6 oldLines: number
7 newStart: number
8 newLines: number
9 lines: string[]
10}
11
12/** One run of changed lines inside a hunk, in the old file's and the new file's coordinates. */
13type Op = {
14 oldStart: number
15 oldCount: number
16 newStart: number
17 newCount: number
18}
19
20/** Splits hunks into their runs of -/+ lines, context lines left out. */
21export const toOps = (hunks: readonly Hunk[]): Op[] =>
22 hunks.flatMap(hunk => {
23 const ops: Op[] = []
24 let oldLine = hunk.oldStart
25 let newLine = hunk.newStart
26 let open: Op | null = null
27
28 for (const line of hunk.lines) {
29 const sign = line[0]
30 if (sign === '-' || sign === '+') {
31 open = open ?? { oldStart: oldLine, oldCount: 0, newStart: newLine, newCount: 0 }
32 if (sign === '-') {
33 open = { ...open, oldCount: open.oldCount + 1 }
34 oldLine += 1
35 } else {
36 open = { ...open, newCount: open.newCount + 1 }
37 newLine += 1
38 }
39 continue
40 }
41 if (open !== null) ops.push(open)
42 open = null
43 oldLine += 1
44 newLine += 1
45 }
46 if (open !== null) ops.push(open)
47
48 return ops
49 })
50
51const kindOf = (op: Op): ChangeKind => (op.newCount === 0 ? 'removed' : op.oldCount === 0 ? 'added' : 'changed')
52
53/** The marks one edit leaves, in the new file's lines. A removal marks the line it left off at. */
54export const marksOf = (editId: string, hunks: readonly Hunk[]): Mark[] =>
55 toOps(hunks).map(op => {
56 const start = Math.max(1, op.newStart)
57 const end = op.newCount === 0 ? start : start + op.newCount - 1
58
59 return { editId, start, end, kind: kindOf(op) }
60 })
61
62const overlaps = (mark: Mark, op: Op): boolean =>
63 op.oldCount > 0 && mark.start <= op.oldStart + op.oldCount - 1 && mark.end >= op.oldStart
64
65/** True when the op sits wholly above the mark. A pure insertion goes in before old line `oldStart`. */
66const isAbove = (op: Op, mark: Mark): boolean =>
67 op.oldCount === 0 ? op.oldStart <= mark.start : op.oldStart + op.oldCount - 1 < mark.start
68
69/**
70 * Moves earlier marks to where a new edit leaves their lines: shifted by the
71 * lines added or taken out above them, dropped when the edit rewrote them.
72 */
73export const shiftMarks = (marks: readonly Mark[], hunks: readonly Hunk[]): Mark[] => {
74 const ops = toOps(hunks)
75
76 return marks
77 .filter(mark => !ops.some(op => overlaps(mark, op)))
78 .map(mark => {
79 const delta = ops
80 .filter(op => isAbove(op, mark))
81 .reduce((sum, op) => sum + op.newCount - op.oldCount, 0)
82
83 return { ...mark, start: Math.max(1, mark.start + delta), end: Math.max(1, mark.end + delta) }
84 })
85}
86
87/** Lines of a text, a final newline not counted as a line of its own. */
88export const countLines = (text: string): number => (text === '' ? 0 : text.replace(/\n$/, '').split('\n').length)
89types/index.d.ts 56 lines1/** What a block of changed lines did: lines added, lines replaced, or lines taken out at a point. */
2export type ChangeKind = 'added' | 'changed' | 'removed'
3
4/** Lines of the file as it stands now (1-based, inclusive) that one edit touched. */
5export type Mark = {
6 editId: string
7 start: number
8 end: number
9 kind: ChangeKind
10}
11
12/** Where an edit's explanation stands: waiting for the turn to end, being asked, ready, or failed. */
13export type ExplanationState = 'pending' | 'loading' | 'done' | 'failed'
14
15/** One edit Claude made to a file, as a unified diff ready for `Code`. */
16export type FileEdit = {
17 id: string
18 diff: string
19 added: number
20 removed: number
21 /** Milliseconds since the epoch. */
22 at: number
23 explanation: string | null
24 explanationState: ExplanationState
25}
26
27/** A file Claude touched in this session, its edits oldest first. */
28export type ChangedFile = {
29 path: string
30 isNew: boolean
31 edits: FileEdit[]
32 /** Lines in the file now; null when the engine did not say and nothing earlier did. */
33 lineCount: number | null
34 /** Changed lines in today's coordinates, every edit's, shifted as later edits moved them. */
35 marks: Mark[]
36 /** The git worktree the file lives in, and its branch; null outside git. */
37 root: string | null
38 branch: string | null
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 'diff-checker': {
44 /** `changes`, not `files`: version 0.1 kept a smaller shape under `files`. */
45 changes: ChangedFile[]
46 selected: string | null
47 hasAutoOpened: boolean
48 /** Edits the person opened or closed, by id; the latest of a file is open unless closed. */
49 expanded: Record<string, boolean>
50 isOverviewOpen: boolean
51 /** What Svuota is asking to confirm: `*` every file, else one group by its root (`-` outside git); null: nothing. */
52 confirmingClear: string | null
53 }
54 }
55}
56