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…

Every file Claude changed this session, at a glance. One line above the prompt, a +/− table on demand, and one keypress to
@mentiona 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.
✎ 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.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./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install files-touched@awesome-claude-mods
Requires Claude Code 2.1.287 or later.
| Command | What it does |
|---|---|
/touched | Print the table |
/touched pane | Open the pane of files, each pressable to @mention it |
/touched copy | Copy the paths to the clipboard |
/touched clear | Start the list over |
| Option | Default | What it does |
|---|---|---|
band | true | Show the one-line list above the prompt |
| Event / API | Why |
|---|---|
tool.call | After 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.start | Forgets the last look at the tree, since you may have edited files between turns |
$.session.root | Makes paths relative to the project |
$.state (files) | Holds the list across hot reloads and redraws the band. Resets with /clear. |
ui.render on AbovePrompt | The band. It keeps whatever other mods draw there below its own line. |
ui.render on Pane | The pane of files |
$.prompt.fill, $.ui.copy | @mention a file, copy the paths |
claude plugin test mods/awareness/files-touched # 41 tests
run_in_background) is checked when it returns, so later writes are missed./clear, /resume and /branch, like everything in $.state.hooks/register.tsx 288 lines1import { 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}
288hooks/bash.ts 192 lines1// 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}
192hooks/changes.ts 231 lines1// 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}
231types/index.d.ts 26 lines1export 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