SLOPSHOPPER

files-touched

Every file Claude changed this session, at a glance: a one-line band above the prompt, /touched for a +/- table, and a pane that drops @file references into…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · files-touched
│ ┃ files-touched ✕ › fix the failing auth test and add an audit log call │ ┃ 3 files · +14 −1 · press one to @mention it │ ┃ [ Copy paths ] [ Clear ] [ Close ] ⏺ Read(src/auth.ts) │ ┃ 1: src/cache.ts +7 ⎿ Read 6 lines │ ┃ 2: src/audit.ts +5 ⏺ Update(src/auth.ts) │ ┃ 3: src/auth.ts +2 −1 ⎿ 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 │ │ › /touched │ ⎿ files-touched: **3 files changed** · +14 −1 · 3 edits │ ⎿ files-touched: │ ⎿ files-touched: | File | Status | Edits | Added | Removed | Last │ ⎿ files-touched: | --- | --- | ---: | ---: | ---: | --- | │ ⎿ files-touched: | `src/cache.ts` | modified | 1 | +7 | −0 | 01:53 │ ⎿ files-touched: | `src/audit.ts` | modified | 1 | +5 | −0 | 01:53 │ │ ✎ 3 files · cache.ts +7 · audit.ts +5 · auth.ts +2 −1 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✎ 3 files · cache.ts +7 · audit.ts +5 · auth.ts +2 −1 ⟨Claude Code's own drawing⟩
Pane · files-touched
3 files · +14 −1 · press one to @mention it [ Copy paths ] [ Clear ] [ Close ] 1: src/cache.ts +7 2: src/audit.ts +5 3: src/auth.ts +2 −1
README

✎ files-touched

Every file Claude changed this session, at a glance. One line above the prompt, a +/− table on demand, and one keypress to @mention a file.

<img src="../../../assets/screens/hero.svg" alt="files-touched in a real Claude Code session" width="100%">

✎ 4 files · register.ts +12 −3 · README.md +40 · a/index.ts +2 · +1 more
╭──────────────────────────────────────────────────────────────╮
│ > _                                                          │
╰──────────────────────────────────────────────────────────────╯

/touched
  4 files changed · +57 −5 · 7 edits
  | File              | Status   | Edits | Added | Removed | Last  |
  | hooks/register.ts | modified |     3 |   +12 |      −3 | 14:52 |
  | README.md         | created  |     1 |   +40 |      −0 | 14:50 |
  | gen/schema.ts     | modified (bash) | 1 | +9 |      −2 | 14:49 |
  …

After a long session it's hard to say what Claude actually changed. files-touched keeps a running ledger of every successful Edit, Write and NotebookEdit, and, in a git repo, of every file a Bash command changed (cat > f <<EOF, sed -i, printf >>, codegen, formatters): which files, how many edits, and how many lines added and removed.

Features

  • Band above the prompt: ✎ N files plus the most recently changed files and their +/−, sized to the terminal's width. It ends in +N more when they don't all fit, and hides until a file is touched.
  • /touched: a markdown table of every file, newest first, with created/modified, edit count, lines added and removed, and the last time it was touched.
  • /touched pane: a pane with one button per file. Press one (or its digit, 1–9) to insert @path into your prompt, so you can say "review @src/auth.ts" without typing the path. Copy paths (y) and Clear (c) are there too.
  • /touched copy: puts the list of paths on your clipboard, one per line.
  • Accurate counts: it reads the tool's own structured patch when there is one, so a rewritten file counts the lines that changed, not the whole file.
  • Bash changes too: in a git repo it compares the dirty files (git status, git hash-object, git diff --numstat) before and after each Bash command, so heredocs, sed -i, formatters and generators show up, marked (bash). A command Bash ran read-only reuses the last look, so a turn of ls and grep runs git once. Committing or staging a file doesn't count as changing it.
  • Honest: failed, denied and held (staged) edits don't count. Paths are relative to your project root.

Install

/plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install files-touched@awesome-claude-mods

Requires Claude Code 2.1.287 or later.

Usage

CommandWhat it does
/touchedPrint the table
/touched paneOpen the pane of files, each pressable to @mention it
/touched copyCopy the paths to the clipboard
/touched clearStart the list over

Configuration

OptionDefaultWhat it does
bandtrueShow the one-line list above the prompt

How it works

Event / APIWhy
tool.callAfter next(e), reads each successful Edit, MultiEdit, Write and NotebookEdit and its result's patch. Around each Bash call, compares git's view of the dirty files
$.process.run (git)git --no-optional-locks status --porcelain=v2, hash-object --stdin-paths and diff --numstat, 3 s timeout each, never taking the index lock
turn.startForgets the last look at the tree, since you may have edited files between turns
$.session.rootMakes paths relative to the project
$.state (files)Holds the list across hot reloads and redraws the band. Resets with /clear.
ui.render on AbovePromptThe band. It keeps whatever other mods draw there below its own line.
ui.render on PaneThe pane of files
$.prompt.fill, $.ui.copy@mention a file, copy the paths

Test it

claude plugin test mods/awareness/files-touched   # 41 tests

Limitations

  • Bash changes are only seen inside a git repo, and only for the first 200 changed paths. A command started in the background (run_in_background) is checked when it returns, so later writes are missed.
  • Line counts for Bash changes are measured against HEAD, so they're approximate when a file was already dirty. Across a checkout, reset or rebase that moves HEAD, a change counts as an edit with +0 −0. Your own edits made during a running turn can be attributed to Claude's next Bash command.
  • Notebook edits count the cell's source lines, not a diff.
  • The list resets on /clear, /resume and /branch, like everything in $.state.
Source 4 files
hooks/register.tsx 288 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5  MAX_PATHS,
6  buildSnapshot,
7  compareSnapshots,
8  countLines,
9  hashablePaths,
10  lineDelta,
11  parseHashes,
12  parseNumstat,
13  parseRepo,
14  parseStatus,
15  shownPath,
16  type Snapshot,
17} from './bash'
18import { TRACKED, bandLine, byRecent, changeOf, delta, listText, mention, merge, relativeTo, tableText, totals, type Change } from './changes'
19
20const PANE = 'files-touched'
21const TITLE = 'Files touched'
22
23// $.state, so /clear, /resume and /branch start the list over.
24const files = atom({ plugin: 'files-touched', key: 'files' } as const, [])
25
26// What a Bash command changed is read off git: the dirty tree before and after it.
27type Repo = { root: string; top: string; prefix: string }
28let repoFor: { root: string; repo: Repo | null } | undefined
29// The tree as of the last look; dropped when a file tool or the person may have changed it since.
30let lastTree: Snapshot | undefined
31// Line counts of untracked files as last seen, for the delta of the next change to them.
32const untrackedLines = new Map<string, number>()
33const GIT_TIMEOUT_MS = 3000
34
35async function remember($: EngineInterface, tool: string, input: Record<string, unknown>, result: unknown) {
36  const change = changeOf(tool, input, result)
37  if (change === undefined) return
38  const root = await $.session.root()
39  const now = await $.clock.now()
40  await update($, files, list => merge(list, { ...change, path: relativeTo(change.path, root) }, now))
41  if (tool === 'Write' && typeof input.content === 'string' && repoFor?.repo) {
42    const top = repoFor.repo.top.replace(/\/+$/, '')
43    if (change.path.startsWith(`${top}/`)) untrackedLines.set(change.path.slice(top.length + 1), countLines(input.content))
44  }
45}
46
47/** Runs one git command in the repo's top folder; undefined when it fails, times out or isn't there. */
48async function runGit($: EngineInterface, cwd: string, argv: readonly string[], stdin?: string): Promise<string | undefined> {
49  try {
50    const out = await $.process.run(['git', '--no-optional-locks', ...argv], {
51      cwd,
52      timeoutMs: GIT_TIMEOUT_MS,
53      ...(stdin === undefined ? {} : { stdin }),
54    })
55    return out.exitCode === 0 && !out.isStdoutTruncated ? out.stdout : undefined
56  } catch {
57    return undefined
58  }
59}
60
61/** The git repo the session's root is in, looked up once per root; null outside one. */
62async function findRepo($: EngineInterface): Promise<Repo | null> {
63  const root = await $.session.root()
64  if (repoFor?.root === root) return repoFor.repo
65  const out = await runGit($, root, ['rev-parse', '--show-toplevel', '--show-prefix'])
66  const found = out === undefined ? null : parseRepo(out)
67  repoFor = { root, repo: found === null ? null : { root, ...found } }
68  return repoFor.repo
69}
70
71async function takeSnapshot($: EngineInterface, where: Repo): Promise<Snapshot | undefined> {
72  const raw = await runGit($, where.top, ['status', '--porcelain=v2', '-z', '--branch', '--untracked-files=all'])
73  if (raw === undefined) return undefined
74  const status = parseStatus(raw)
75  const paths = hashablePaths(status)
76  const hashed = paths.length === 0 ? '' : await runGit($, where.top, ['hash-object', '--stdin-paths'], `${paths.join('\n')}\n`)
77  const numstat = status.head === null || status.entries.size === 0 ? '' : await runGit($, where.top, ['diff', '--numstat', '-z', 'HEAD', '--'])
78  return buildSnapshot(status, hashed === undefined ? undefined : parseHashes(paths, hashed), parseNumstat(numstat ?? ''))
79}
80
81/** Lines of an untracked file now, read from disk; 0 when it can't be read. */
82async function linesNow($: EngineInterface, where: Repo, path: string): Promise<number> {
83  try {
84    const text = await $.fs.read(`${where.top.replace(/\/+$/, '')}/${path}`)
85    return countLines(typeof text === 'string' ? text : '')
86  } catch {
87    return 0
88  }
89}
90
91/** What the Bash command between `before` and `after` changed, as ledger changes. */
92async function bashChanges($: EngineInterface, where: Repo, before: Snapshot, after: Snapshot): Promise<Change[]> {
93  const differences = compareSnapshots(before, after)
94  const out: Change[] = []
95  const isHeadMoved = before.head !== after.head
96
97  // A path that went clean was either committed or staged as it was (same content: not this
98  // command's edit), reverted (different content), or, untracked, deleted.
99  const cleaned = differences.filter(d => d.kind === 'cleaned').map(d => d.path)
100  const stillThere: string[] = []
101  for (const path of cleaned) {
102    const then = before.entries.get(path)!
103    if (then.untracked && !(await $.fs.exists(`${where.top.replace(/\/+$/, '')}/${path}`))) {
104      out.push({ path, added: 0, removed: untrackedLines.get(path) ?? 0, created: false, bash: true, deleted: true })
105      untrackedLines.delete(path)
106    } else if (!then.untracked) {
107      stillThere.push(path)
108    }
109  }
110  if (stillThere.length > 0) {
111    const hashed = await runGit($, where.top, ['hash-object', '--stdin-paths'], `${stillThere.join('\n')}\n`)
112    const now = hashed === undefined ? undefined : parseHashes(stillThere, hashed)
113    for (const path of stillThere) {
114      const then = before.entries.get(path)!
115      if (now === undefined || now.get(path) === then.hash) continue
116      // Back to HEAD's content: the lines it had added are gone, the ones it had removed are back.
117      out.push({ path, added: isHeadMoved ? 0 : then.removed, removed: isHeadMoved ? 0 : then.added, created: false, bash: true })
118    }
119  }
120
121  for (const { path, kind } of differences) {
122    if (kind === 'cleaned') continue
123    const now = after.entries.get(path)!
124    const then = before.entries.get(path)
125    if (now.untracked) {
126      const lines = await linesNow($, where, path)
127      // New: every line is added. Seen before: the change in length, when its old length is known.
128      const known = then === undefined ? 0 : untrackedLines.get(path)
129      untrackedLines.set(path, lines)
130      const added = known === undefined ? 0 : Math.max(0, lines - known)
131      const removed = known === undefined ? 0 : Math.max(0, known - lines)
132      out.push({ path, added, removed, created: then === undefined, bash: true })
133      continue
134    }
135    // Line counts against HEAD mean nothing across a checkout or reset.
136    const counts = isHeadMoved ? { added: 0, removed: 0 } : lineDelta(then, now)
137    out.push({ path, ...counts, created: false, bash: true, deleted: now.deleted })
138  }
139
140  return out.slice(0, MAX_PATHS)
141}
142
143/** The tree before a Bash command: the last look when nothing has touched it since, else a new one. */
144async function treeBefore($: EngineInterface): Promise<{ where: Repo; tree: Snapshot } | undefined> {
145  const where = await findRepo($)
146  if (where === null) return undefined
147  if (lastTree === undefined) lastTree = await takeSnapshot($, where)
148  return lastTree === undefined ? undefined : { where, tree: lastTree }
149}
150
151async function afterBash($: EngineInterface, before: { where: Repo; tree: Snapshot }) {
152  const after = await takeSnapshot($, before.where)
153  lastTree = after
154  if (after === undefined) return
155  const changes = await bashChanges($, before.where, before.tree, after)
156  if (changes.length === 0) return
157  const now = await $.clock.now()
158  await update($, files, list =>
159    changes.reduce((acc, change) => merge(acc, { ...change, path: shownPath(change.path, before.where.top, before.where.prefix) }, now), list),
160  )
161}
162
163async function copyList($: EngineInterface, surface?: Parameters<EngineInterface['ui']['copy']>[0]['surface']) {
164  const list = await read($, files)
165  if (list.length === 0) return 'No files changed yet this session.'
166  const copied = await $.ui.copy(surface === undefined ? { text: listText(list) } : { text: listText(list), surface })
167  return copied.isCopied
168    ? `Copied ${list.length} path${list.length === 1 ? '' : 's'} to the clipboard.`
169    : `Could not copy: ${copied.reason}.`
170}
171
172export const register: Register = (on, options) => {
173  on('session.start', async ($, e, next) => {
174    await $.command.register({
175      name: 'touched',
176      description: 'Files Claude changed this session: /touched [pane|copy|clear]',
177      argumentHint: '[pane|copy|clear]',
178      immediate: true,
179    })
180    return next(e)
181  })
182
183  // Between turns the person may have edited files themselves: look again before the next command.
184  on('turn.start', ($, e, next) => {
185    lastTree = undefined
186    return next(e)
187  })
188
189  on('tool.call', async ($, e, next) => {
190    const tool = String(e.tool)
191    if (tool === 'Bash') {
192      const before = await treeBefore($).catch(() => undefined)
193      const ran = await next(e)
194      // A command Bash ran read-only changed nothing: the last look still stands.
195      if (before === undefined || ran.deny !== undefined || ran.isReadOnly === true) return ran
196      await afterBash($, before).catch(() => {
197        lastTree = undefined
198      })
199      return ran
200    }
201
202    const ran = await next(e)
203    if (TRACKED.has(tool) && ran.deny === undefined && ran.isError !== true) {
204      lastTree = undefined
205      await remember($, tool, e as unknown as Record<string, unknown>, ran.result)
206    }
207    return ran
208  })
209
210  on('command.run', { command: 'touched' }, async ($, e) => {
211    const arg = e.args.trim().toLowerCase()
212    if (arg === 'clear' || arg === 'reset') {
213      await update($, files, () => [])
214      return { text: 'Cleared the list of touched files.' }
215    }
216    if (arg === 'copy') return { text: await copyList($) }
217    if (arg === 'pane' || arg === 'open') {
218      const opened = await $.ui.open({ id: PANE, title: TITLE })
219      return opened.isPlaced ? {} : { text: `The pane is waiting for room: ${opened.reason}.` }
220    }
221    if (arg === 'close') {
222      await $.ui.close({ id: PANE })
223      return {}
224    }
225    return { text: tableText(await read($, files)) }
226  })
227
228  if (options.band !== false) {
229    on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
230      if (e.props.hasSurvey) return next(e)
231      const list = await read($, files)
232      if (list.length === 0) return next(e)
233
234      const { Box, Text } = $.ui.resolve(e)
235      const theirs = await next(e)
236      return (
237        <Box flexDirection="column">
238          <Text dimColor wrap="truncate-end">
239            {bandLine(list, Math.max(10, e.props.bodyColumns))}
240          </Text>
241          {theirs}
242        </Box>
243      )
244    })
245  }
246
247  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
248    const { Box, Button, Text } = $.ui.resolve(e)
249    const list = byRecent(await read($, files))
250    const sum = totals(list)
251    const width = Math.max(20, e.props.bodyColumns)
252
253    return (
254      <Box flexDirection="column">
255        <Text bold wrap="truncate-end">
256          {list.length === 0
257            ? 'No files changed yet.'
258            : `${list.length} file${list.length === 1 ? '' : 's'} · +${sum.added} −${sum.removed} · press one to @mention it`}
259        </Text>
260        <Box flexDirection="row" columnGap={1}>
261          <Button key="copy" label="Copy paths" hotkey="y" onPress={press => copyList($, press.surface).then(text => $.ui.toast(text))} />
262          <Button key="clear" label="Clear" hotkey="c" onPress={() => update($, files, () => [])} />
263          <Button key="close" label="Close" hotkey="x" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
264        </Box>
265        {list.map((file, i) => {
266          const tag = `${file.created ? 'new ' : ''}${delta(file)}`
267          const room = Math.max(4, width - tag.length - 6)
268          const shown = file.path.length > room ? `…${file.path.slice(-(room - 1))}` : file.path
269          return (
270            <Box key={`row:${file.path}`} flexDirection="row" columnGap={1}>
271              <Button
272                key={`file:${file.path}`}
273                label={shown}
274                plain
275                {...(i < 9 ? { hotkey: String(i + 1) } : {})}
276                onPress={() => $.prompt.fill({ text: mention(file.path), mode: 'insert' })}
277              />
278              <Text color={file.created ? 'green' : undefined} dimColor={!file.created}>
279                {tag}
280              </Text>
281            </Box>
282          )
283        })}
284      </Box>
285    )
286  })
287}
288
hooks/bash.ts 192 lines
1// Pure helpers for spotting what a Bash command changed, from `git` output
2// taken before and after it. No `$`: register.tsx runs git and hands the text here.
3
4/** How many changed paths a snapshot watches; the rest of a huge dirty tree is ignored. */
5export const MAX_PATHS = 200
6
7export type StatusEntry = {
8  /** Index and work-tree codes from porcelain v2 (`.` for unchanged). */
9  x: string
10  y: string
11  untracked: boolean
12}
13
14export type Status = {
15  /** HEAD's commit, or null before the first commit. */
16  head: string | null
17  entries: Map<string, StatusEntry>
18}
19
20export type Entry = {
21  /** Content identity: the work-tree blob's hash, or a status stand-in when git couldn't hash it. */
22  hash: string
23  untracked: boolean
24  deleted: boolean
25  /** Staged, with the work tree matching the index (`M.`, `A.`). */
26  isIndexOnly: boolean
27  /** Lines added and removed against HEAD (tracked files). */
28  added: number
29  removed: number
30}
31
32/** The dirty part of a work tree at one moment. */
33export type Snapshot = {
34  head: string | null
35  entries: Map<string, Entry>
36}
37
38/** Paths git reports that a ledger of the person's files should never list. */
39export function isIgnoredPath(path: string): boolean {
40  return /(^|\/)node_modules\//.test(path) || /(^|\/)\.git\//.test(path) || path.includes('\n')
41}
42
43/** Reads `git status --porcelain=v2 -z --branch --untracked-files=all`. */
44export function parseStatus(stdout: string): Status {
45  const tokens = stdout.split('\0')
46  const entries = new Map<string, StatusEntry>()
47  let head: string | null = null
48
49  for (let i = 0; i < tokens.length; i++) {
50    const token = tokens[i] ?? ''
51    if (token === '') continue
52    if (token.startsWith('# branch.oid ')) {
53      const oid = token.slice('# branch.oid '.length).trim()
54      head = /^[0-9a-f]{7,64}$/.test(oid) ? oid : null
55      continue
56    }
57    if (token.startsWith('#')) continue
58    const kind = token[0]
59    if (kind === '?') {
60      const path = token.slice(2)
61      if (!isIgnoredPath(path)) entries.set(path, { x: '?', y: '?', untracked: true })
62      continue
63    }
64    // `1 XY sub mH mI mW hH hI path`, `2 XY sub mH mI mW hH hI Xscore path\0orig`, `u XY sub m1 m2 m3 mW h1 h2 h3 path`
65    const fieldsBeforePath = kind === '1' ? 8 : kind === '2' ? 9 : kind === 'u' ? 10 : -1
66    if (fieldsBeforePath < 0) continue
67    const parts = token.split(' ')
68    const xy = parts[1] ?? '..'
69    const path = parts.slice(fieldsBeforePath).join(' ')
70    if (kind === '2') i++ // the next token is the rename's original path
71    if (path !== '' && !isIgnoredPath(path)) entries.set(path, { x: xy[0] ?? '.', y: xy[1] ?? '.', untracked: false })
72  }
73
74  return { head, entries }
75}
76
77/** Reads `git diff --numstat -z`: lines added and removed per path; binary files count 0. */
78export function parseNumstat(stdout: string): Map<string, { added: number; removed: number }> {
79  const out = new Map<string, { added: number; removed: number }>()
80  const tokens = stdout.split('\0')
81  for (let i = 0; i < tokens.length; i++) {
82    const token = tokens[i] ?? ''
83    const match = token.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
84    if (match === null) continue
85    const added = match[1] === '-' ? 0 : Number(match[1])
86    const removed = match[2] === '-' ? 0 : Number(match[2])
87    let path = match[3] ?? ''
88    // A rename is `added\tremoved\t\0from\0to`.
89    if (path === '') {
90      path = tokens[i + 2] ?? ''
91      i += 2
92    }
93    if (path !== '') out.set(path, { added, removed })
94  }
95  return out
96}
97
98/** Pairs `git hash-object --stdin-paths` output with the paths it was given; undefined when they don't line up. */
99export function parseHashes(paths: readonly string[], stdout: string): Map<string, string> | undefined {
100  const hashes = stdout.split('\n').map(line => line.trim()).filter(line => line !== '')
101  if (hashes.length !== paths.length || hashes.some(h => !/^[0-9a-f]{40,64}$/.test(h))) return undefined
102  return new Map(paths.map((path, i) => [path, hashes[i]!]))
103}
104
105/** The paths of a status that exist on disk and can be hashed, in a stable order, capped. */
106export function hashablePaths(status: Status): string[] {
107  return [...status.entries.entries()]
108    .filter(([, entry]) => entry.y !== 'D' && !(entry.x === 'D' && entry.y === '.'))
109    .map(([path]) => path)
110    .sort()
111    .slice(0, MAX_PATHS)
112}
113
114/** One snapshot from a status, the work-tree hashes and the numstat against HEAD. */
115export function buildSnapshot(
116  status: Status,
117  hashes: ReadonlyMap<string, string> | undefined,
118  numstat: ReadonlyMap<string, { added: number; removed: number }>,
119): Snapshot {
120  const entries = new Map<string, Entry>()
121  const watched = [...status.entries.keys()].sort().slice(0, MAX_PATHS)
122  for (const path of watched) {
123    const entry = status.entries.get(path)!
124    const deleted = entry.y === 'D' || (entry.x === 'D' && entry.y === '.')
125    const lines = numstat.get(path) ?? { added: 0, removed: 0 }
126    entries.set(path, {
127      hash: deleted ? 'deleted' : (hashes?.get(path) ?? `status:${entry.x}${entry.y}`),
128      untracked: entry.untracked,
129      deleted,
130      isIndexOnly: !entry.untracked && !deleted && entry.y === '.',
131      added: lines.added,
132      removed: lines.removed,
133    })
134  }
135  return { head: status.head, entries }
136}
137
138export type Difference = {
139  path: string
140  /** `new`: dirty now, clean before. `changed`: dirty both times, different content. `cleaned`: dirty before, clean or gone now. */
141  kind: 'new' | 'changed' | 'cleaned'
142}
143
144/**
145 * Which watched paths a command may have changed. A path that only got
146 * staged while HEAD moved (a `git reset --soft`) is left out, and a path that
147 * went clean needs one more look, which `cleaned` asks for.
148 */
149export function compareSnapshots(before: Snapshot, after: Snapshot): Difference[] {
150  const isHeadMoved = before.head !== after.head
151  const out: Difference[] = []
152  for (const [path, now] of after.entries) {
153    const then = before.entries.get(path)
154    if (then === undefined) {
155      if (!(isHeadMoved && now.isIndexOnly)) out.push({ path, kind: 'new' })
156    } else if (then.hash !== now.hash || then.deleted !== now.deleted) {
157      out.push({ path, kind: 'changed' })
158    }
159  }
160  for (const path of before.entries.keys()) {
161    if (!after.entries.has(path)) out.push({ path, kind: 'cleaned' })
162  }
163  return out.slice(0, MAX_PATHS)
164}
165
166/** What a command added and removed, from the lines against a base before and after it. */
167export function lineDelta(
168  before: { added: number; removed: number } | undefined,
169  after: { added: number; removed: number },
170): { added: number; removed: number } {
171  const then = before ?? { added: 0, removed: 0 }
172  return { added: Math.max(0, after.added - then.added), removed: Math.max(0, after.removed - then.removed) }
173}
174
175export function countLines(text: string): number {
176  if (text === '') return 0
177  const lines = text.split('\n')
178  return lines.at(-1) === '' ? lines.length - 1 : lines.length
179}
180
181/** A repo path as the ledger shows it: relative to the session root when the root is a subfolder of the repo. */
182export function shownPath(path: string, top: string, prefix: string): string {
183  if (prefix === '') return path
184  return path.startsWith(prefix) ? path.slice(prefix.length) : `${top.replace(/\/+$/, '')}/${path}`
185}
186
187/** Reads `git rev-parse --show-toplevel --show-prefix`. */
188export function parseRepo(stdout: string): { top: string; prefix: string } | null {
189  const [top = '', prefix = ''] = stdout.replace(/\r/g, '').split('\n')
190  return top.trim() === '' ? null : { top: top.trim(), prefix: prefix.trim() }
191}
192
hooks/changes.ts 231 lines
1// Pure helpers: no `$`, so the tests and other mods can import them.
2
3import type { TouchedFile } from '../types'
4
5export type Change = {
6  /** As the tool named it: absolute in this build. */
7  path: string
8  added: number
9  removed: number
10  created: boolean
11  /** A Bash command made the change, rather than a file tool. */
12  bash?: boolean
13  deleted?: boolean
14}
15
16type Hunk = { lines?: unknown }
17
18export const TRACKED = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
19
20function linesOf(text: string): string[] {
21  if (text === '') return []
22  const lines = text.split('\n')
23  if (lines.at(-1) === '') lines.pop()
24  return lines
25}
26
27/** Counts `+` and `-` lines over a structured patch's hunks. */
28export function countPatch(hunks: readonly Hunk[]): { added: number; removed: number } {
29  let added = 0
30  let removed = 0
31  for (const hunk of hunks) {
32    if (!Array.isArray(hunk.lines)) continue
33    for (const line of hunk.lines) {
34      if (typeof line !== 'string') continue
35      if (line.startsWith('+') && !line.startsWith('+++')) added++
36      else if (line.startsWith('-') && !line.startsWith('---')) removed++
37    }
38  }
39  return { added, removed }
40}
41
42/** Lines added and removed going from `before` to `after`, common lines at both ends left out. */
43export function countStrings(before: string, after: string): { added: number; removed: number } {
44  const a = linesOf(before)
45  const b = linesOf(after)
46  let start = 0
47  while (start < a.length && start < b.length && a[start] === b[start]) start++
48  let end = 0
49  while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++
50  return { added: b.length - start - end, removed: a.length - start - end }
51}
52
53function record(value: unknown): Record<string, unknown> {
54  return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
55}
56
57function hunks(result: Record<string, unknown>): Hunk[] | undefined {
58  const patch = result.structuredPatch
59  return Array.isArray(patch) && patch.length > 0 ? (patch as Hunk[]) : undefined
60}
61
62/**
63 * What one successful file tool call changed, read from its input and the
64 * tool's result; undefined for a call that changed nothing on disk.
65 */
66export function changeOf(tool: string, input: Readonly<Record<string, unknown>>, rawResult: unknown): Change | undefined {
67  const result = record(rawResult)
68  if (result.staged === true) return undefined
69  const str = (value: unknown) => (typeof value === 'string' ? value : '')
70
71  switch (tool) {
72    case 'Edit': {
73      const path = str(input.file_path)
74      if (path === '') return undefined
75      const patch = hunks(result)
76      const counts = patch === undefined ? countStrings(str(input.old_string), str(input.new_string)) : countPatch(patch)
77      return { path, ...counts, created: false }
78    }
79    case 'MultiEdit': {
80      const path = str(input.file_path)
81      if (path === '') return undefined
82      const patch = hunks(result)
83      if (patch !== undefined) return { path, ...countPatch(patch), created: false }
84      const edits: Record<string, unknown>[] = Array.isArray(input.edits) ? input.edits.map(record) : []
85      const counts = edits.reduce<{ added: number; removed: number }>(
86        (sum, one) => {
87          const c = countStrings(str(one.old_string), str(one.new_string))
88          return { added: sum.added + c.added, removed: sum.removed + c.removed }
89        },
90        { added: 0, removed: 0 },
91      )
92      return { path, ...counts, created: false }
93    }
94    case 'Write': {
95      const path = str(input.file_path)
96      if (path === '') return undefined
97      const content = str(input.content)
98      if (result.type === 'create') return { path, added: linesOf(content).length, removed: 0, created: true }
99      const patch = hunks(result)
100      if (patch !== undefined) return { path, ...countPatch(patch), created: false }
101      if (typeof result.originalFile === 'string') return { path, ...countStrings(result.originalFile, content), created: false }
102      return { path, added: linesOf(content).length, removed: 0, created: false }
103    }
104    case 'NotebookEdit': {
105      const path = str(input.notebook_path)
106      if (path === '') return undefined
107      const lines = linesOf(str(input.new_source)).length
108      return input.edit_mode === 'delete'
109        ? { path, added: 0, removed: lines, created: false }
110        : { path, added: lines, removed: 0, created: false }
111    }
112  }
113  return undefined
114}
115
116/** `path` relative to `root` when it lies under it. */
117export function relativeTo(path: string, root: string | undefined): string {
118  if (root === undefined || root === '') return path
119  const base = root.replace(/[\\/]+$/, '')
120  if (path.startsWith(`${base}/`) || path.startsWith(`${base}\\`)) return path.slice(base.length + 1)
121  return path
122}
123
124/** The list with one more change in it, the file moved to the front. */
125export function merge(files: readonly TouchedFile[], change: Change & { path: string }, now: number): TouchedFile[] {
126  const old = files.find(f => f.path === change.path)
127  const base: TouchedFile = old === undefined
128    ? { path: change.path, edits: 1, added: change.added, removed: change.removed, created: change.created, firstAt: now, lastAt: now }
129    : { ...old, edits: old.edits + 1, added: old.added + change.added, removed: old.removed + change.removed, created: old.created || change.created, lastAt: now }
130  const bash = (old?.bash ?? 0) + (change.bash === true ? 1 : 0)
131  const next: TouchedFile = { ...base, deleted: change.deleted === true }
132  if (bash > 0) next.bash = bash
133  if (next.deleted !== true) delete next.deleted
134  return [next, ...files.filter(f => f.path !== change.path)]
135}
136
137/** `created`, `modified` or `deleted`, with `(bash)` when Bash made every change and `(+bash)` when it made some. */
138export function statusOf(file: TouchedFile): string {
139  const what = file.deleted === true ? 'deleted' : file.created ? 'created' : 'modified'
140  const bash = file.bash ?? 0
141  if (bash === 0) return what
142  return bash >= file.edits ? `${what} (bash)` : `${what} (+bash)`
143}
144
145/** Most recently touched first. */
146export function byRecent(files: readonly TouchedFile[]): TouchedFile[] {
147  return [...files].sort((a, b) => b.lastAt - a.lastAt)
148}
149
150/** Each file's basename, or more of its path where two basenames clash. */
151export function displayNames(files: readonly TouchedFile[]): Map<string, string> {
152  const names = new Map<string, string>()
153  const segments = files.map(f => f.path.split(/[\\/]/).filter(s => s !== ''))
154  for (let i = 0; i < files.length; i++) {
155    const mine = segments[i] ?? []
156    let depth = 1
157    while (depth < mine.length) {
158      const suffix = mine.slice(-depth).join('/')
159      const clash = segments.some((other, j) => j !== i && other.slice(-depth).join('/') === suffix)
160      if (!clash) break
161      depth++
162    }
163    names.set(files[i]!.path, mine.slice(-depth).join('/') || files[i]!.path)
164  }
165  return names
166}
167
168export function delta(file: Pick<TouchedFile, 'added' | 'removed'>): string {
169  const parts: string[] = []
170  if (file.added > 0 || file.removed === 0) parts.push(`+${file.added}`)
171  if (file.removed > 0) parts.push(`−${file.removed}`)
172  return parts.join(' ')
173}
174
175export function totals(files: readonly TouchedFile[]): { added: number; removed: number; edits: number } {
176  return files.reduce((t, f) => ({ added: t.added + f.added, removed: t.removed + f.removed, edits: t.edits + f.edits }), { added: 0, removed: 0, edits: 0 })
177}
178
179/** `✎ 4 files · register.ts +12 −3 · README.md +40 · +2 more`, at most `width` long. */
180export function bandLine(files: readonly TouchedFile[], width: number): string {
181  const recent = byRecent(files)
182  const names = displayNames(recent)
183  const head = `✎ ${recent.length} file${recent.length === 1 ? '' : 's'}`
184  let line = head
185  for (let i = 0; i < recent.length; i++) {
186    const file = recent[i]!
187    const piece = ` · ${names.get(file.path)} ${delta(file)}`
188    const left = recent.length - i - 1
189    const more = left > 0 ? ` · +${left} more` : ''
190    if ((line + piece + more).length <= width) {
191      line += piece
192      continue
193    }
194    const rest = recent.length - i
195    const tail = ` · +${rest} more`
196    if ((line + tail).length <= width) line += tail
197    break
198  }
199  return line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`
200}
201
202function clock(ms: number): string {
203  const d = new Date(ms)
204  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
205}
206
207/** The markdown `/touched` prints. */
208export function tableText(files: readonly TouchedFile[]): string {
209  if (files.length === 0) return 'No files changed yet this session.'
210  const recent = byRecent(files)
211  const sum = totals(recent)
212  const rows = recent.map(f => `| \`${f.path}\` | ${statusOf(f)} | ${f.edits} | +${f.added} | −${f.removed} | ${clock(f.lastAt)} |`)
213  return [
214    `**${recent.length} file${recent.length === 1 ? '' : 's'} changed** · +${sum.added} −${sum.removed} · ${sum.edits} edit${sum.edits === 1 ? '' : 's'}`,
215    '',
216    '| File | Status | Edits | Added | Removed | Last |',
217    '| --- | --- | ---: | ---: | ---: | --- |',
218    ...rows,
219  ].join('\n')
220}
221
222/** One path per line, newest first: what `/touched copy` puts on the clipboard. */
223export function listText(files: readonly TouchedFile[]): string {
224  return byRecent(files).map(f => f.path).join('\n')
225}
226
227/** An @-reference for the prompt box, quoted when the path has spaces. */
228export function mention(path: string): string {
229  return /\s/.test(path) ? `@"${path}" ` : `@${path} `
230}
231
types/index.d.ts 26 lines
1export type TouchedFile = {
2  /** Relative to the session's project root when inside it, else absolute. */
3  path: string
4  /** Successful Edit, Write and NotebookEdit calls on it, plus Bash commands that changed it. */
5  edits: number
6  /** How many of those edits were Bash commands (absent: none). */
7  bash?: number
8  /** True when the file is gone after its last change. */
9  deleted?: boolean
10  /** Lines added and removed over all of them. */
11  added: number
12  removed: number
13  /** True when Claude created the file this session. */
14  created: boolean
15  firstAt: number
16  lastAt: number
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'files-touched': {
22      files: TouchedFile[]
23    }
24  }
25}
26