A live list of every file Claude read or edited this session, in a pane and the status line.

hooks/register.tsx 188 lines1// Files Seen: every file Claude read or edited this session.
2// Status line: "◉ 12 read · ✎ 3 edited". /seen opens a pane grouped into
3// Edited and Read only, with files from the current turn marked.
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { SeenFile } from '../types'
9
10const PANE = 'files-seen'
11const LIMIT = 500
12
13const files = atom({ plugin: 'files-seen', key: 'files' } as const, [])
14const turnId = atom({ plugin: 'files-seen', key: 'turnId' } as const, null)
15const cwd = atom({ plugin: 'files-seen', key: 'cwd' } as const, '')
16
17const READ_TOOLS = new Set(['Read'])
18const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
19
20export const register: Register = on => {
21 on('session.start', async ($, e, next) => {
22 const result = await next(e)
23 await update($, cwd, () => e.cwd)
24 try {
25 await $.command.register({ name: 'seen', description: 'Show every file Claude read or edited this session' })
26 } catch (error) {
27 $.ui.log(`files-seen: could not register /seen (${String(error)})`, { to: 'debug' })
28 }
29 await showStatus($)
30 return result
31 })
32
33 on('session.end', async ($, e, next) => {
34 // A /clear starts a fresh conversation: start the list over with it.
35 if (e.reason === 'clear') {
36 await update($, files, () => [])
37 $.ui.status(undefined)
38 }
39 return next(e)
40 })
41
42 on('turn.start', async ($, e, next) => {
43 await update($, turnId, () => e.turnId)
44 return next(e)
45 })
46
47 on('tool.call', async ($, e, next) => {
48 const tool = String(e.tool)
49 const isRead = READ_TOOLS.has(tool)
50 const isEdit = EDIT_TOOLS.has(tool)
51 const ran = await next(e)
52 if (!isRead && !isEdit) return ran
53 if (ran.deny !== undefined || ran.isError === true) return ran
54
55 const raw = pathOf(e as unknown as Record<string, unknown>)
56 if (raw === undefined) return ran
57
58 const root = await read($, cwd)
59 const path = display(raw, root)
60 const turn = (await read($, turnId)) ?? ''
61
62 await update($, files, list => {
63 const seq = list.reduce((max, f) => Math.max(max, f.seq), 0) + 1
64 const found = list.find(f => f.path === path)
65 const entry: SeenFile = found
66 ? {
67 ...found,
68 reads: found.reads + (isRead ? 1 : 0),
69 edits: found.edits + (isEdit ? 1 : 0),
70 lastTurn: turn,
71 seq,
72 }
73 : { path, reads: isRead ? 1 : 0, edits: isEdit ? 1 : 0, lastTurn: turn, seq }
74 return [...list.filter(f => f.path !== path), entry].slice(-LIMIT)
75 })
76 await showStatus($)
77 return ran
78 })
79
80 on('command.run', { command: 'seen' }, async $ => {
81 const list = await read($, files)
82 await $.ui.open({ id: PANE, title: 'Files seen', focus: true, closeOnEscape: true, rows: 30 })
83 return { text: list.length === 0 ? 'No files read or edited yet.' : `${count(list)}.` }
84 })
85
86 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
87 const { Box, Text, Button } = $.ui.resolve(e)
88 const list = [...(await read($, files))].sort((a, b) => b.seq - a.seq)
89 const turn = await read($, turnId)
90 const width = e.props.bodyColumns
91
92 const edited = list.filter(f => f.edits > 0)
93 const readOnly = list.filter(f => f.edits === 0)
94
95 const row = (f: SeenFile) => {
96 const isNow = turn !== null && f.lastTurn === turn
97 const counts =
98 f.edits > 0 ? `✎${f.edits}${f.reads > 0 ? ` ◉${f.reads}` : ''}` : `◉${f.reads}`
99 return (
100 <Box key={`f-${f.path}`} flexDirection="row" justifyContent="space-between">
101 <Text bold={isNow} dimColor={!isNow} wrap="truncate-start">
102 {isNow ? '● ' : ' '}
103 {f.path}
104 </Text>
105 <Text dimColor>{' ' + counts}</Text>
106 </Box>
107 )
108 }
109
110 if (list.length === 0) {
111 return (
112 <Box flexDirection="column">
113 <Text dimColor>Nothing read or edited yet this session.</Text>
114 </Box>
115 )
116 }
117
118 return (
119 <Box flexDirection="column" width={width}>
120 <Text bold>{count(list)}</Text>
121 <Text dimColor>● touched this turn ✎ edits ◉ reads</Text>
122 {edited.length > 0 && (
123 <Box flexDirection="column" marginTop={1}>
124 <Text color="yellow" bold>
125 Edited ({edited.length})
126 </Text>
127 {edited.map(row)}
128 </Box>
129 )}
130 {readOnly.length > 0 && (
131 <Box flexDirection="column" marginTop={1}>
132 <Text color="cyan" bold>
133 Read only ({readOnly.length})
134 </Text>
135 {readOnly.map(row)}
136 </Box>
137 )}
138 <Box flexDirection="row" marginTop={1} gap={1}>
139 <Button
140 key="reset"
141 label="Reset list"
142 hotkey="r"
143 onPress={async () => {
144 await update($, files, () => [])
145 await showStatus($)
146 }}
147 />
148 <Button
149 key="close"
150 label="Close"
151 hotkey="c"
152 role="dismiss"
153 onPress={() => $.ui.close({ id: PANE })}
154 />
155 </Box>
156 </Box>
157 )
158 })
159}
160
161async function showStatus($: EngineInterface) {
162 const list = await read($, files)
163 $.ui.status(list.length === 0 ? undefined : count(list, true))
164}
165
166function count(list: readonly SeenFile[], short = false): string {
167 const edited = list.filter(f => f.edits > 0).length
168 const readOnly = list.length - edited
169 return short
170 ? `◉ ${readOnly} read · ✎ ${edited} edited`
171 : `${list.length} files: ${edited} edited, ${readOnly} read only`
172}
173
174function pathOf(input: Record<string, unknown>): string | undefined {
175 const p = input.file_path ?? input.notebook_path
176 return typeof p === 'string' && p.length > 0 ? p : undefined
177}
178
179/** Shows a path relative to the project when it lies inside it, with forward slashes. */
180function display(path: string, root: string): string {
181 const p = path.replace(/\\/g, '/')
182 const r = root.replace(/\\/g, '/').replace(/\/+$/, '')
183 if (r.length > 0 && p.toLowerCase().startsWith(r.toLowerCase() + '/')) {
184 return p.slice(r.length + 1)
185 }
186 return p
187}
188types/index.d.ts 21 lines1export type SeenFile = {
2 /** Path as Claude named it, shown relative to the project when it lies inside. */
3 path: string
4 reads: number
5 edits: number
6 /** The main-loop turn that last touched it. */
7 lastTurn: string
8 /** Order of last touch, so the newest sort first. */
9 seq: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'files-seen': {
15 files: SeenFile[]
16 turnId: string | null
17 cwd: string
18 }
19 }
20}
21