SLOPSHOPPER

diff-checker

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

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diff-checker
│ ┃ Diff checker ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Diff checker │ ┃ Nessuna modifica in questa sessione: il ⏺ Read(src/auth.ts) │ ┃ pannello si riempie quando Claude modifica o ⎿ Read 6 lines │ ┃ crea un file. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /diff-checker │ ⎿ diff-checker: Pannello aperto: nessuna modifica ancora. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Diff checker
◆ Diff checker Nessuna modifica in questa sessione: il pannello si riempie quando Claude modifica o crea un file.
README

diff-checker

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.

Come si usa

/diff-checker apre il pannello e risponde «Pannello aperto: N file.» (oppure «Pannello aperto: nessuna modifica ancora.»).

TastoCosa fa
1–9apre il file di quella scheda (solo le prime nove)
eapre tutte le modifiche del file
cchiude tutte le modifiche del file
mmostra 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:

  • l'intestazione con il totale dei file e delle righe + e −;
  • la Panoramica: la «MAPPA DEI FILE», con blocchi grandi quanto le righe cambiate, e la «LINEA DEL TEMPO» delle modifiche di ogni file;
  • le schede dei file, raggruppate per worktree git e ramo quando sono più di uno;
  • per il file scelto: percorso, ramo, numero di modifiche e una minimappa del file che segna le righe toccate;
  • le modifiche, dalla più recente. È aperta solo l'ultima: le altre si aprono a mano.

La riga di stato mostra ◆ N file · +X −Y.

Cosa fa da sola

  • Dopo ogni Edit e Write riuscito registra il diff. Un'azione negata o fallita non lascia traccia.
  • Alla prima modifica della sessione apre il pannello una volta sola. Se il terminale è stretto mostra «Diff checker: allarga il terminale (144 colonne) o usa /diff-checker».
  • A fine turno della conversazione principale chiede a Claude perché ha fatto le modifiche ancora senza spiegazione. Il turno di un subagente non fa partire la domanda. Mentre aspetta, la modifica dice «Il perché arriva a fine turno.» e poi «Claude sta scrivendo il perché…».

Cosa legge e cosa manda

  • File: non legge né scrive file. Usa il risultato che Edit e Write restituiscono già.
  • Processi: per ogni cartella, una volta per caricamento, git -C <cartella> rev-parse --show-toplevel --abbrev-ref HEAD (timeout 5 secondi), per sapere worktree e ramo.
  • Modello: $.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.
  • Variabili d'ambiente: nessuna.
  • Stato della sessione: i file toccati con i loro diff, le spiegazioni, le righe segnate, worktree e ramo; il file scelto; le modifiche aperte o chiuse; se la Panoramica è aperta; se il pannello si è già aperto da solo.

Requisiti

  • 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.

Limiti

  • Guarda solo gli strumenti Edit e Write.
  • Tiene al massimo 40 file e 20 modifiche per file: le più vecchie escono.
  • Un diff più lungo di 30.000 caratteri viene tagliato all'ultimo blocco @@ che ci sta.
  • Una spiegazione si ferma a 600 caratteri.
  • Il pannello si apre da solo da 144 colonne in su (da 110 se l'hai già aperto tu e non l'hai chiuso a mano); con /diff-checker si apre a ogni larghezza.
  • Se Claude non risponde a un id, quella modifica passa a «Spiegazione non disponibile.».

Installazione

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.

Sviluppo

Dalla cartella della mod:

claude plugin validate .
claude plugin test .

I test sono 63, in 5 file.

Source 8 files
hooks/register.tsx 239 lines
1import { 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}
239
hooks/diff.ts 154 lines
1import 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)
154
hooks/drawings.ts 145 lines
1import 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)
145
hooks/explain.ts 56 lines
1import 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}
56
hooks/git.ts 17 lines
1import 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}
17
hooks/view.tsx 372 lines
1import 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)
372
hooks/marks.ts 89 lines
1import 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)
89
types/index.d.ts 56 lines
1/** 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