SLOPSHOPPER

files-seen

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

newpaneguardcommandstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · files-seen
│ ┃ Files seen ✕ › fix the failing auth test and add an audit log call │ ┃ 3 files: 3 edited, 0 read only │ ┃ ● touched this turn ✎ edits ◉ reads ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Edited (3) ⏺ Update(src/auth.ts) │ ┃ ● src/cache.ts ⎿ Added 2 lines, removed 1 line │ ┃ ● src/audit.ts ⏺ Bash(bun test) │ ┃ ● src/auth.ts ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Reset list ] [ Close ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /seen │ ⎿ files-seen: 3 files: 3 edited, 0 read only. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ files-seen: ◉ 0 read · ✎ 3 edited

Draws

Pane · Files seen
3 files: 3 edited, 0 read only ● touched this turn ✎ edits ◉ reads Edited (3) ● src/cache.ts ✎1 ● src/audit.ts ✎1 ● src/auth.ts ✎1 ◉1 [ Reset list ] [ Close ]
Source 2 files
hooks/register.tsx 188 lines
1// 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}
188
types/index.d.ts 21 lines
1export 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