Shows the 5 files most recently created or edited in this session in a side pane, each name a link to the file

A Claude Code mod that keeps the five files the session most recently created, edited or deleted in a pane. Each file name is a link to the file, so a terminal that opens links (such as Orca) opens it on click.
report.md
docs/ · edited · edit · 15:58
a.txt
./ · created · bash · subagent · 15:57
At the prompt of a Claude Code session:
/plugin install recent-files --marketplace ryoupr/claude-code-recent-files
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
Requires a Claude Code release with mods (function-hook plugins). Mods are an early-access feature, and their API may change between releases. Built and tested on Claude Code 2.1.290 and 2.1.291.
CLAUDE_CODE_NO_FLICKER=1 claude, or set "tui": "fullscreen" in your settings./recent-files opens the pane again after you close it.file:///<absolute path>. Line 2, dim: the folder relative to the session's working directory, what happened, which tool, whether a subagent did it, and the time. A deleted file is not a link.| Tool | How |
|---|---|
| Write, Edit, NotebookEdit | The file path, when the call succeeds. A failed or denied call is not listed. |
| Bash | The files that changed, found by running git status and stat before and after the command in the git repositories the session has touched (and any cd /absolute/path target). Writes outside a git repository are not seen. |
A file appears once, at the top, however often it is touched.
git --no-optional-locks -c core.fsmonitor=false status --porcelain=v1 -z -uall and a stat per changed file, before and after every Bash call, in up to 10 repositories. In a repository with many untracked files this slows Bash calls down.bash.claude plugin validate .
claude plugin test .
claude --plugin-dir . # a session with this folder's mod, reloaded on save
hooks/register.tsx 239 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RecentFile, RecentKind, RecentVia } from '../types'
5import {
6 cdTargets,
7 diffSnapshots,
8 parsePorcelainZ,
9 baseName,
10 fileUrl,
11 LIMIT,
12 pushRecent,
13 relativeTo,
14 shortPath,
15 type Snapshot,
16} from './lib'
17
18const PANE = 'recent-files'
19const TITLE = 'Recent files'
20const MAX_ROOTS = 10
21/** Width the docked pane asks for; a width the person drags to wins. */
22const COLUMNS = 48
23
24const files = atom({ plugin: 'recent-files', key: 'files' } as const, [])
25const roots = atom({ plugin: 'recent-files', key: 'roots' } as const, [])
26
27type $ = EngineInterface
28
29/** Mirror of the session's state in `$.store`, so a reload that loses it can restore. */
30const MIRROR = 'last-session'
31
32type Mirror = { sessionId: string; files: RecentFile[]; roots: string[] }
33
34const persist = async ($: $): Promise<void> => {
35 const mirror: Mirror = {
36 sessionId: await $.session.id(),
37 files: [...(await read($, files))],
38 roots: [...(await read($, roots))],
39 }
40 await $.store.set(MIRROR, mirror)
41}
42
43const isMirror = (value: unknown): value is Mirror =>
44 typeof value === 'object' &&
45 value !== null &&
46 typeof (value as Mirror).sessionId === 'string' &&
47 Array.isArray((value as Mirror).files) &&
48 Array.isArray((value as Mirror).roots)
49
50/** Puts back this session's list when the host's state came back empty. */
51const restore = async ($: $): Promise<void> => {
52 if ((await read($, files)).length > 0) return
53 const saved = await $.store.get(MIRROR)
54 if (!isMirror(saved) || saved.sessionId !== (await $.session.id())) return
55 await update($, files, () => saved.files.slice(0, LIMIT))
56 await update($, roots, list => (list.length > 0 ? list : saved.roots))
57}
58
59const record = async (
60 $: $,
61 path: string,
62 kind: RecentKind,
63 via: RecentVia,
64 agentId: string | undefined,
65): Promise<void> => {
66 const entry: RecentFile = { path, kind, via, at: await $.clock.now(), agentId }
67 await update($, files, list => pushRecent(list, entry))
68 await persist($).catch(() => undefined)
69}
70
71/** The git top level of `dir`, or undefined outside a repo. */
72const topLevel = async ($: $, dir: string): Promise<string | undefined> => {
73 const { exitCode, stdout } = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'])
74
75 return exitCode === 0 ? stdout.trim() : undefined
76}
77
78const learnRoot = async ($: $, dir: string): Promise<void> => {
79 const root = await topLevel($, dir)
80 if (root === undefined) return
81 await update($, roots, list => [root, ...list.filter(one => one !== root)].slice(0, MAX_ROOTS))
82 await persist($).catch(() => undefined)
83}
84
85const dirname = (path: string): string => path.replace(/\/[^/]*$/, '') || '/'
86
87/** Dirty and untracked files of every known repo, with their stat. */
88const snapshot = async ($: $, repoRoots: readonly string[]): Promise<Snapshot> => {
89 const snap: Snapshot = new Map()
90 for (const root of repoRoots) {
91 // --no-optional-locks: status must not take index.lock, or a git command the
92 // session runs at the same moment (a parallel Bash call) fails on the lock.
93 // core.fsmonitor=false: a repo's local config must not make status run a program.
94 const { exitCode, stdout } = await $.process.run([
95 'git', '--no-optional-locks', '-c', 'core.fsmonitor=false',
96 '-C', root, 'status', '--porcelain=v1', '-z', '-uall',
97 ])
98 if (exitCode !== 0) continue
99 for (const { path, code } of parsePorcelainZ(root, stdout)) {
100 const stat = await $.fs.stat(path).catch(() => undefined)
101 snap.set(path, { code, mtimeMs: stat?.mtimeMs ?? -1, size: stat?.size ?? -1 })
102 }
103 }
104
105 return snap
106}
107
108const isOk = (ran: { deny?: string; isError?: boolean }): boolean =>
109 ran.deny === undefined && ran.isError !== true
110
111export const register: Register = on => {
112 on('session.start', async ($, e, next) => {
113 const started = await next(e)
114 await restore($).catch(() => undefined)
115 try {
116 await $.command.register({
117 name: 'recent-files',
118 description: 'Show the files most recently created or edited in this session',
119 })
120 await learnRoot($, e.cwd)
121 } catch {
122 // The pane still works without the command or the cwd's repo.
123 }
124 void $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
125
126 return started
127 })
128
129 on('command.run', { command: 'recent-files' }, async $ => {
130 await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
131
132 return { text: 'Recent files pane opened.' }
133 })
134
135 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
136 const existed = await $.fs.exists(e.file_path).catch(() => true)
137 const ran = await next(e)
138 if (isOk(ran)) {
139 try {
140 await record($, e.file_path, existed ? 'edited' : 'created', 'write', e.agentId)
141 await learnRoot($, dirname(e.file_path))
142 } catch {
143 // Recording never gets in the way of the write itself.
144 }
145 }
146
147 return ran
148 }).catch(($, e, next) => next(e))
149
150 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
151 const ran = await next(e)
152 if (isOk(ran)) {
153 try {
154 await record($, e.file_path, 'edited', 'edit', e.agentId)
155 await learnRoot($, dirname(e.file_path))
156 } catch {
157 // Recording never gets in the way of the edit itself.
158 }
159 }
160
161 return ran
162 }).catch(($, e, next) => next(e))
163
164 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
165 const ran = await next(e)
166 if (isOk(ran)) {
167 try {
168 await record($, e.notebook_path, 'edited', 'notebook', e.agentId)
169 await learnRoot($, dirname(e.notebook_path))
170 } catch {
171 // Recording never gets in the way of the edit itself.
172 }
173 }
174
175 return ran
176 }).catch(($, e, next) => next(e))
177
178 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
179 let watched: string[] = []
180 let before: Snapshot | undefined
181 try {
182 for (const dir of cdTargets(e.command)) await learnRoot($, dir)
183 watched = [...(await read($, roots))]
184 before = await snapshot($, watched)
185 } catch {
186 before = undefined
187 }
188
189 const ran = await next(e)
190
191 if (before !== undefined && watched.length > 0) {
192 try {
193 const after = await snapshot($, watched)
194 for (const { path, kind } of diffSnapshots(before, after)) {
195 await record($, path, kind, 'bash', e.agentId)
196 }
197 } catch {
198 // A failed snapshot only means this command's writes go unlisted.
199 }
200 }
201
202 return ran
203 }).catch(($, e, next) => next(e))
204
205 // A pane: docked at the right of a fullscreen transcript, inline above the
206 // prompt otherwise. Each file shows its name only; the name is an OSC 8 link
207 // to the file's absolute path, so a terminal that opens links (Orca) opens it,
208 // while the pane stays narrow. The folder, relative to the session's working
209 // directory, and what happened follow dim on the next line.
210 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
211 const { Box, Link, Text } = $.ui.resolve(e)
212 const list = await read($, files)
213 const cwd = await $.session.cwd()
214
215 return (
216 <Box flexDirection="column">
217 {list.length === 0 && <Text dimColor>No files created or edited yet.</Text>}
218 {list.map(file => (
219 <Box key={file.path} flexDirection="column" paddingX={1}>
220 {file.kind === 'deleted' ? (
221 <Text dimColor>{baseName(file.path)}</Text>
222 ) : (
223 <Link href={fileUrl(file.path)} label={baseName(file.path)} />
224 )}
225 <Text dimColor>
226 {' '}
227 {shortPath(relativeTo(cwd, file.path.slice(0, file.path.length - baseName(file.path).length))) || './'}
228 {' · '}
229 {file.kind} · {file.via}
230 {file.agentId === undefined ? '' : ' · subagent'} ·{' '}
231 {new Date(file.at).toLocaleTimeString('ja-JP', { hour12: false, hour: '2-digit', minute: '2-digit' })}
232 </Text>
233 </Box>
234 ))}
235 </Box>
236 )
237 })
238}
239hooks/lib.ts 102 lines1import type { RecentFile, RecentKind } from '../types'
2
3/** How many files the pane lists. */
4export const LIMIT = 5
5
6/** Puts `entry` first, drops any older entry for the same path, keeps LIMIT. */
7export const pushRecent = (list: readonly RecentFile[], entry: RecentFile): RecentFile[] =>
8 [entry, ...list.filter(one => one.path !== entry.path)].slice(0, LIMIT)
9
10/** One path of `git status --porcelain=v1 -z -uall`, absolute, with its XY code. */
11export type StatusEntry = { path: string; code: string }
12
13/**
14 * Parses `git status --porcelain=v1 -z -uall` output into absolute paths.
15 * A rename or copy (`R`/`C`) carries the new path, then the old one as a
16 * separate NUL-terminated field, which is skipped.
17 */
18export const parsePorcelainZ = (root: string, out: string): StatusEntry[] => {
19 const fields = out.split('\0')
20 const entries: StatusEntry[] = []
21 for (let i = 0; i < fields.length; i += 1) {
22 const field = fields[i]
23 if (field === undefined || field.length < 4) continue
24 const code = field.slice(0, 2)
25 entries.push({ path: `${root}/${field.slice(3)}`, code })
26 if (code[0] === 'R' || code[0] === 'C') i += 1
27 }
28
29 return entries
30}
31
32/** A file's state in a snapshot: its status code and what stat said. */
33export type Signature = { code: string; mtimeMs: number; size: number }
34
35export type Snapshot = Map<string, Signature>
36
37/**
38 * Compares two snapshots of the dirty and untracked files of the same repos
39 * and says which files the command between them changed, and how.
40 */
41export const diffSnapshots = (
42 before: Snapshot,
43 after: Snapshot,
44): { path: string; kind: RecentKind }[] => {
45 const changed: { path: string; kind: RecentKind }[] = []
46 for (const [path, now] of after) {
47 const was = before.get(path)
48 const isSame =
49 was !== undefined &&
50 was.code === now.code &&
51 was.mtimeMs === now.mtimeMs &&
52 was.size === now.size
53 if (isSame) continue
54 if (now.code.includes('D')) changed.push({ path, kind: 'deleted' })
55 else if (was === undefined && (now.code === '??' || now.code.includes('A')))
56 changed.push({ path, kind: 'created' })
57 else changed.push({ path, kind: 'edited' })
58 }
59 for (const [path, was] of before) {
60 if (after.has(path)) continue
61 // Gone from the dirty set: an untracked file removed, or a change reverted.
62 changed.push({ path, kind: was.code === '??' ? 'deleted' : 'edited' })
63 }
64
65 return changed
66}
67
68/** Absolute directories a command `cd`s into, so their repos can be watched. */
69export const cdTargets = (command: string): string[] => {
70 const found: string[] = []
71 const pattern = /(?:^|[;&|(]\s*)cd\s+(["']?)(\/[^"'\s;&|)]+)\1/g
72 for (const match of command.matchAll(pattern)) {
73 if (match[2] !== undefined) found.push(match[2])
74 }
75
76 return found
77}
78
79/**
80 * `path` relative to `cwd` when it lies under it (`src/a.ts`), else as is.
81 * A terminal that links paths (Orca) resolves a relative one from the
82 * session's working directory, and a short path stays on one line.
83 */
84export const relativeTo = (cwd: string, path: string): string => {
85 const base = cwd.endsWith('/') ? cwd : `${cwd}/`
86
87 return cwd !== '/' && path.startsWith(base) ? path.slice(base.length) : path
88}
89
90/** The last segment of a path: `/a/b/c.ts` → `c.ts`. */
91export const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1) || path
92
93/**
94 * A `file://` URL for an absolute path, each segment percent-encoded so
95 * spaces, `#`, `?` and non-ASCII names survive (`/a b/c#1` → `file:///a%20b/c%231`).
96 */
97export const fileUrl = (path: string): string =>
98 `file://${path.split('/').map(encodeURIComponent).join('/')}`
99
100/** `/Users/name/x` → `~/x`, for display. */
101export const shortPath = (path: string): string => path.replace(/^\/Users\/[^/]+(?=\/|$)/, '~')
102types/index.d.ts 18 lines1export type RecentKind = 'created' | 'edited' | 'deleted'
2
3export type RecentVia = 'write' | 'edit' | 'notebook' | 'bash'
4
5export type RecentFile = {
6 path: string
7 kind: RecentKind
8 via: RecentVia
9 at: number
10 agentId?: string
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'recent-files': { files: RecentFile[]; roots: string[] }
16 }
17}
18