Live list and thumbnail gallery of the files in your session: pasted images, @mentions, and files Claude read or edited. Reuse one in the next prompt, or open…

A Claude Code mod that keeps a live list of the files in your session, so you can reuse them without attaching them again and look at them without leaving the terminal.
What it tracks
| Source | How it gets in |
|---|---|
| pasted | an image you paste into a prompt |
| @ | a file you @mention in a prompt (only if it exists) |
| read / edited | a file Claude reads, edits or writes |
What you can do with a row (click it, or Tab to it and Enter, which opens a card with the file's name, size and age, and its path): press u or click Use to put @path in your prompt at the cursor, v or View shows the file in the panel (images as pictures, text with highlighting and prev/next paging), f or Forget drops it from the list. The hotkeys work while the panel has the keyboard. The all | mine | Claude's filter splits what you attached from what Claude touched.
Rows other than pasted images are remembered per project folder between sessions. They load by themselves when a new session starts with an empty list. A pasted image's file lives in the session's temp folder, so it is only listed for that session.
The field under the header (⌕ search by name or path) narrows the grid and the list as you type: every word must be in the file's name or path, in any order, ignoring case. Focus it with a click or Tab, type, and press Esc to give the keys back; ✕ clears it. It combines with the all | mine | Claude's filter, and the count in the header is the number of matches.
The bottom half starts as a grid of thumbnail tiles: pictures and a PDF's first page as pictures, text files as their first lines, a page at a time with ‹ prev 2/7 next › pinned to the last line of the box (the list pages the same way). The header reads Files 4 │ ▦ ≡ │ all mine Claude's: the count, then the view (▦ grid, ≡ list), then the filter, and whichever is in use is drawn inverted. /gallery shows the grid. A picture itself can't take a click in a terminal pane (only text buttons can), so the name bar under each tile, padded to the tile's full width, is the button: pressing it opens the file at thumbnail size, with Expand, Use, Open and Back as above.
By default the files sit under the top tabs of the side panel: Todos and Task lists each keep their own drawing in the top half (next(e) on their pane) and the files take the bottom half. Switching between the tabs changes only the top; the bottom stays as it is. todo-pane and task-lists are not changed. /files focuses the panel, and /side-panel opens every tab with the files below, or closes them all together if any is open.
Set in the mod's options:
layout: split (default) or tab (a pane of its own).hostPanes: comma-separated ids of the panes (tabs) the files go under, todo-pane,task-lists by default.topShare: the share of the panel's height the host pane keeps, 50 by default.For the split to work this mod's hook must sit outside the host pane's in the hook chain; if the todo pane then shows alone, switch layout to tab.
/plugin install session-files --marketplace lpareja99/claude-mods
/reload-plugins
sips (macOS).@path of a pasted image attaches it as an image is up to Claude Code.hooks/register.tsx 575 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput } from 'claude-code'
3
4import type { FileFilter, FileRow } from '../types'
5import { absolute, addFile, ago, fitBox, formatSize, keepable, kindOf, matching, mentionOf, mentionPaths, sourcesLabel, touched } from './files'
6import { fitCells, imageNumbers, pngSize } from './layout'
7
8const PANE = 'session-files'
9// Tab titles of the panes this mod sits under, for opening them again.
10const TITLES: Record<string, string> = { 'todo-pane': 'Todos', 'task-lists': 'Task lists', 'session-files': 'Files' }
11// A gallery tile's picture box, in cells (the tile adds a border, a gap and a name row).
12const TILE_COLUMNS = 12
13const TILE_ROWS = 3
14// Rows a tile takes: its border, the picture box and the name under it.
15const TILE_HEIGHT = TILE_ROWS + 3
16const MIN_ROWS = 3
17// A viewed text file is read again at most this often while the pane redraws.
18const TEXT_TTL_MS = 1500
19// The longest side of a converted preview: big enough to stay sharp when the viewer takes the whole panel.
20const PREVIEW_PX = 1600
21
22const files = atom({ plugin: 'session-files', key: 'files' } as const, [] as FileRow[])
23const selected = atom({ plugin: 'session-files', key: 'selected' } as const, null as string | null)
24const viewing = atom({ plugin: 'session-files', key: 'viewing' } as const, null as string | null)
25const viewOffset = atom({ plugin: 'session-files', key: 'viewOffset' } as const, 0)
26const mode = atom({ plugin: 'session-files', key: 'mode' } as const, 'grid' as 'grid' | 'list')
27const query = atom({ plugin: 'session-files', key: 'query' } as const, '')
28const listPage = atom({ plugin: 'session-files', key: 'listPage' } as const, 0)
29const gridPage = atom({ plugin: 'session-files', key: 'gridPage' } as const, 0)
30const expanded = atom({ plugin: 'session-files', key: 'expanded' } as const, false)
31const filter = atom({ plugin: 'session-files', key: 'filter' } as const, 'all' as FileFilter)
32
33type PaneInput = RenderInput<'Pane'>
34
35const FILTERS: { value: FileFilter; label: string }[] = [
36 { value: 'all', label: 'all' },
37 { value: 'mine', label: 'mine' },
38 { value: 'claude', label: "Claude's" },
39]
40
41const clamp = (n: number, low: number, high: number) => Math.min(high, Math.max(low, n))
42
43/** Tracking is best-effort: it must never fail a prompt or one of Claude's tool calls. */
44const safely = async (fn: () => Promise<unknown>) => {
45 try {
46 await fn()
47 } catch {
48 // The list may lag; the call itself is unaffected.
49 }
50}
51
52const storeKey = async ($: EngineInterface) => `files:${await $.session.cwd()}`
53
54const save = async ($: EngineInterface) => $.store.set(await storeKey($), keepable(await read($, files)))
55
56async function track($: EngineInterface, path: string, source: FileRow['sources'][number]) {
57 const cwd = await $.session.cwd()
58 await update($, files, list => addFile(list, absolute(cwd, path), source, Date.now()))
59 await save($)
60}
61
62// Claude Code caches each paste as <tmp>/claude-<uid>/<project>/<session>/images/<n>.<ext>.
63let sessionDir: { sessionId: string; dir: string } | undefined
64
65async function imagesDir($: EngineInterface): Promise<string | undefined> {
66 const sessionId = await $.session.id()
67 if (sessionDir?.sessionId === sessionId) return sessionDir.dir
68 const uid = (await $.process.run(['id', '-u'])).stdout.trim()
69 const root = `${((await $.env.get('CLAUDE_CODE_TMPDIR')) ?? '/tmp').replace(/\/+$/, '')}/claude-${uid}`
70 for (const entry of await $.fs.list(root).catch(() => [])) {
71 const dir = `${root}/${entry.name}/${sessionId}/images`
72 if (entry.kind === 'dir' && (await $.fs.exists(dir))) {
73 sessionDir = { sessionId, dir }
74 return dir
75 }
76 }
77 return undefined
78}
79
80/** The cached file of a pasted image; a PNG is preferred over another format of the same paste. */
81async function pastedFile($: EngineInterface, n: number): Promise<string | undefined> {
82 const dir = await imagesDir($)
83 if (dir === undefined) return undefined
84 const png = `${dir}/${n}.png`
85 if (await $.fs.exists(png)) return png
86 const entry = (await $.fs.list(dir).catch(() => [])).find(one => one.kind === 'file' && one.name.startsWith(`${n}.`))
87
88 return entry === undefined ? undefined : `${dir}/${entry.name}`
89}
90
91/** What the terminal can draw: a PNG as is; any other image, or a PDF's first page, converted once (sips, macOS) beside the pastes. */
92async function previewPng($: EngineInterface, path: string): Promise<string | undefined> {
93 if (/\.png$/i.test(path)) return path
94 const dir = await imagesDir($)
95 if (dir === undefined) return undefined
96 let hash = 0x811c9dc5
97 for (let i = 0; i < path.length; i++) hash = Math.imul(hash ^ path.charCodeAt(i), 0x01000193)
98 const out = `${dir.replace(/\/images$/, '')}/session-files-${hash >>> 0}-${PREVIEW_PX}.png`
99 if (await $.fs.exists(out)) return out
100 const { exitCode } = await $.process
101 .run(['sips', '-s', 'format', 'png', '-Z', String(PREVIEW_PX), path, '--out', out])
102 .catch(() => ({ exitCode: 1 }))
103
104 return exitCode === 0 ? out : undefined
105}
106
107const texts = new Map<string, { at: number; text: string | undefined }>()
108
109async function textOf($: EngineInterface, path: string): Promise<string | undefined> {
110 const hit = texts.get(path)
111 if (hit && Date.now() - hit.at < TEXT_TTL_MS) return hit.text
112 const text = await $.fs.read(path).catch(() => undefined)
113 texts.set(path, { at: Date.now(), text })
114
115 return text
116}
117
118async function useFile($: EngineInterface, row: FileRow) {
119 const mention = mentionOf(await $.session.cwd(), row.path)
120 await $.prompt.fill({ text: ` @${mention.includes(' ') ? `"${mention}"` : mention} `, mode: 'insert' })
121 await update($, files, list => list.map(one => (one.path === row.path ? { ...one, uses: one.uses + 1 } : one)))
122 await save($)
123}
124
125/** The whole file in the system's own viewer (Preview on macOS), for what a terminal panel can't show big. */
126async function openOutside($: EngineInterface, row: FileRow) {
127 const isMac = (await $.process.run(['uname'])).stdout.trim() === 'Darwin'
128 await $.process.run([isMac ? 'open' : 'xdg-open', row.path])
129}
130
131async function forgetFile($: EngineInterface, row: FileRow) {
132 await update($, files, list => list.filter(one => one.path !== row.path))
133 await update($, selected, () => null)
134 await save($)
135}
136
137/** The files list, or the file being viewed, drawn into `bodyRows` rows. */
138async function filesView($: EngineInterface, e: PaneInput, bodyRows: number) {
139 const open = await read($, viewing)
140 const list = await read($, files)
141 const row = open === null ? undefined : list.find(one => one.path === open)
142
143 if (row) return viewer($, e, row, bodyRows)
144
145 return (await read($, mode)) === 'grid' ? grid($, e, list, bodyRows) : browser($, e, list, bodyRows)
146}
147
148/** Title, view switch (grid or list) and filter, as chips: the one in use is drawn inverted, the others dim. */
149function header($: EngineInterface, e: PaneInput, count: number, by: FileFilter, view: 'grid' | 'list') {
150 const { Box, Text, Button } = $.ui.resolve(e)
151 const chip = (label: string, isOn: boolean) => (
152 <Text inverse={isOn} bold={isOn} dimColor={!isOn}> {label} </Text>
153 )
154
155 return (
156 <Box flexDirection="row" columnGap={1}>
157 <Text bold>Files</Text>
158 <Text dimColor>{count}</Text>
159 <Text dimColor>│</Text>
160 {(['grid', 'list'] as const).map(v => (
161 <Button key={`files-mode-${v}`} plain onPress={() => update($, mode, () => v)}>{chip(v === 'grid' ? '▦' : '≡', view === v)}</Button>
162 ))}
163 <Text dimColor>│</Text>
164 {FILTERS.map(f => (
165 <Button
166 key={`files-filter-${f.value}`}
167 plain
168 onPress={async () => {
169 await update($, gridPage, () => 0)
170 await update($, listPage, () => 0)
171 await update($, filter, () => f.value)
172 }}
173 >
174 {chip(f.label, by === f.value)}
175 </Button>
176 ))}
177 </Box>
178 )
179}
180
181/** The search field and a clear button; typing narrows both views and goes back to the first page. */
182async function searchRow($: EngineInterface, e: PaneInput) {
183 // The element table of one surface (mobile) has no Input.
184 if (e.surface === 'mobile') return undefined
185 const { Box, Text, Button, Input } = $.ui.resolve(e)
186 const q = await read($, query)
187 const set = async (value: string) => {
188 await update($, gridPage, () => 0)
189 await update($, listPage, () => 0)
190 await update($, query, () => value)
191 }
192
193 return (
194 <Box flexDirection="row" columnGap={1}>
195 <Input key="files-search" label="⌕" placeholder="search by name or path" value={q} onInput={set} onSubmit={set} />
196 {q !== '' && <Button key="files-search-clear" plain onPress={() => set('')}><Text dimColor>✕</Text></Button>}
197 </Box>
198 )
199}
200
201/** `‹ prev 2/7 next ›`, drawn on the last line of the bottom half. */
202function pager(
203 $: EngineInterface,
204 e: PaneInput,
205 name: string,
206 page: number,
207 pages: number,
208 go: (to: number) => Promise<unknown>,
209) {
210 if (pages <= 1) return undefined
211 const { Box, Text, Button } = $.ui.resolve(e)
212
213 return (
214 <Box flexDirection="row" columnGap={2}>
215 <Button key={`${name}-prev`} plain onPress={() => go(Math.max(0, page - 1))}>‹ prev</Button>
216 <Text dimColor>{page + 1}/{pages}</Text>
217 <Button key={`${name}-next`} plain onPress={() => go(Math.min(pages - 1, page + 1))}>next ›</Button>
218 </Box>
219 )
220}
221
222async function browser($: EngineInterface, e: PaneInput, list: FileRow[], bodyRows: number) {
223 const { Box, Text, Button } = $.ui.resolve(e)
224 const by = await read($, filter)
225 const pick = await read($, selected)
226 const q = await read($, query)
227 const shown = matching(list, by, q)
228 const chosen = shown.find(row => row.path === pick)
229 const stat = chosen && (await $.fs.stat(chosen.path).catch(() => undefined))
230 // The header and the search take two rows, the pager one, the card four (a blank line and three). When everything fits there is
231 // no pager; otherwise a page leaves room for the card whether one is selected or not, so it never resizes.
232 const fitsAll = shown.length <= Math.max(1, bodyRows - 2 - (chosen ? 4 : 0))
233 const perPage = fitsAll ? Math.max(1, shown.length) : Math.max(1, bodyRows - 7)
234 const pages = Math.max(1, Math.ceil(shown.length / perPage))
235 const page = clamp(await read($, listPage), 0, pages - 1)
236 const icon = (row: FileRow) => {
237 const kind = kindOf(row.path)
238
239 return kind === 'image' ? '▣' : kind === 'pdf' ? '▤' : kind === 'text' ? '≡' : '·'
240 }
241
242 return (
243 <Box flexDirection="column" height={bodyRows}>
244 {header($, e, shown.length, by, 'list')}
245 {await searchRow($, e)}
246 {shown.length === 0 && (
247 <Text dimColor>
248 {list.length === 0 ? 'Nothing yet: paste an image, @mention a file, or let Claude read one.' : 'No file matches.'}
249 </Text>
250 )}
251 {shown.slice(page * perPage, (page + 1) * perPage).map(row => (
252 <Button key={`files-row-${row.path}`} plain onPress={() => update($, selected, cur => (cur === row.path ? null : row.path))}>
253 <Text bold={row.path === pick}>{row.path === pick ? '❯' : ' '} {icon(row)} {row.name}</Text>
254 <Text dimColor> {sourcesLabel(row)}</Text>
255 </Button>
256 ))}
257 {chosen && (
258 <Box flexDirection="column" marginTop={1}>
259 <Box flexDirection="row" columnGap={1}>
260 <Text bold wrap="truncate">{chosen.name}</Text>
261 <Text dimColor>{[stat ? formatSize(stat.size) : 'gone', ago(chosen.lastAt, Date.now())].join(' · ')}</Text>
262 </Box>
263 <Text dimColor wrap="truncate-start">{chosen.path}</Text>
264 <Box flexDirection="row" columnGap={3}>
265 <Button key="files-use" plain hotkey="u" onPress={() => useFile($, chosen)}><Text bold>Use</Text></Button>
266 <Button
267 key="files-view"
268 plain
269 hotkey="v"
270 onPress={async () => {
271 await update($, viewOffset, () => 0)
272 await update($, expanded, () => false)
273 await update($, viewing, () => chosen.path)
274 }}
275 >
276 View
277 </Button>
278 <Button key="files-forget" plain hotkey="f" onPress={() => forgetFile($, chosen)}><Text dimColor>Forget</Text></Button>
279 {chosen.uses > 0 && <Text dimColor>used {chosen.uses}×</Text>}
280 </Box>
281 </Box>
282 )}
283 {/* The pager is pinned to the last line of the box. */}
284 <Box flexGrow={1} />
285 {pager($, e, 'list', page, pages, to => update($, listPage, () => to))}
286 </Box>
287 )
288}
289
290/** How the viewer is driven: the split panel's own state, or the gallery's (always big, no toggle). */
291type ViewerControl = { prefix: string; isBig: boolean; toggle?: () => Promise<unknown>; close: () => Promise<unknown> }
292
293async function viewer($: EngineInterface, e: PaneInput, row: FileRow, bodyRows: number, ctl?: ViewerControl) {
294 const { Box, Text, Button, Code } = $.ui.resolve(e)
295 const room = Math.max(1, bodyRows - 3)
296 const control: ViewerControl = ctl ?? {
297 prefix: 'files',
298 isBig: await read($, expanded),
299 toggle: () => update($, expanded, was => !was),
300 close: async () => {
301 await update($, viewing, () => null)
302 await update($, expanded, () => false)
303 },
304 }
305 const { prefix: p, isBig } = control
306 const back = (
307 <Box flexDirection="row" columnGap={1}>
308 <Button key={`${p}-back`} plain onPress={control.close}>← Back</Button>
309 <Text bold wrap="truncate">{row.name}</Text>
310 <Button key={`${p}-use`} onPress={() => useFile($, row)}>Use</Button>
311 {control.toggle && <Button key={`${p}-expand`} onPress={control.toggle}>{isBig ? 'Shrink' : 'Expand'}</Button>}
312 <Button key={`${p}-open`} onPress={() => openOutside($, row)}>Open</Button>
313 </Box>
314 )
315 const note = (text: string) => (
316 <Box flexDirection="column">
317 {back}
318 <Text dimColor>{text}</Text>
319 </Box>
320 )
321
322 // The kind is read from the path, not the stored row: a row saved by an older version may be stale.
323 const kind = kindOf(row.path)
324 if (kind === 'image' || kind === 'pdf') {
325 // Only the terminal's element table draws a picture from a file.
326 if (e.surface !== 'terminal') return note('Images are shown in the terminal.')
327 const { Image } = $.ui.resolve(e)
328 const png = await previewPng($, row.path)
329 if (png === undefined) return note(kind === 'pdf' ? 'No preview for this PDF here: use Open.' : 'No preview for this image format here.')
330 const base64 = await $.fs.read(png, { as: 'bytes' }).then(b => b.base64, () => undefined)
331 if (base64 === undefined) return note("Can't read this image (over 4 MiB or gone).")
332 // Shrunk: the thumbnail's size. Expanded: fill the panel, as wide as it is and as tall as is left.
333 const maxRows = Math.max(3, room - (kind === 'pdf' ? 1 : 0))
334 const size = pngSize(base64)
335 const fit = isBig ? fitBox(size, Math.max(4, e.props.bodyColumns - 1), maxRows) : fitCells(size, maxRows)
336 const columns = fit.columns
337
338 return (
339 <Box flexDirection="column">
340 {back}
341 <Image key={`${p}-image-${row.path}`} source={{ file: png, format: 'png' }} columns={columns} rows={fit.rows} alt={row.name} />
342 {kind === 'pdf' && <Text dimColor>First page only. Open shows the whole document.</Text>}
343 </Box>
344 )
345 }
346
347 if (kind === 'other') return note('A binary file: open it outside the terminal.')
348 const text = await textOf($, row.path)
349 if (text === undefined) return note("Can't read this file (over 4 MiB or gone).")
350 const lines = text.split('\n')
351 const offset = clamp(await read($, viewOffset), 0, Math.max(0, lines.length - room))
352 const step = (by: number) => update($, viewOffset, () => clamp(offset + by, 0, Math.max(0, lines.length - room)))
353
354 return (
355 <Box flexDirection="column">
356 {back}
357 <Box flexDirection="row" columnGap={2}>
358 <Button key={`${p}-prev`} plain onPress={() => step(-room)}>↑ prev</Button>
359 <Button key={`${p}-next`} plain onPress={() => step(room)}>↓ next</Button>
360 <Text dimColor>{offset + 1}–{Math.min(lines.length, offset + room)} of {lines.length}</Text>
361 </Box>
362 <Code
363 key={`${p}-code-${row.path}`}
364 source={lines.slice(offset, offset + room).join('\n')}
365 path={row.path}
366 startLine={offset + 1}
367 wrap="truncate-end"
368 />
369 </Box>
370 )
371}
372
373/** A converted-or-original PNG and its size, kept so a redraw doesn't convert or read it again. */
374const previews = new Map<string, { png: string; size: { width: number; height: number } | null } | null>()
375
376async function previewOf($: EngineInterface, path: string) {
377 if (previews.has(path)) return previews.get(path) ?? undefined
378 const png = await previewPng($, path)
379 const base64 = png === undefined ? undefined : await $.fs.read(png, { as: 'bytes' }).then(b => b.base64, () => undefined)
380 const hit = png === undefined || base64 === undefined ? null : { png, size: pngSize(base64) }
381 // A file still being written or converted is looked at again on the next draw.
382 if (hit !== null) previews.set(path, hit)
383
384 return hit ?? undefined
385}
386
387/** The grid: every file as a thumbnail tile, a page at a time; a tile's name opens the file. */
388async function grid($: EngineInterface, e: PaneInput, list: FileRow[], bodyRows: number) {
389 const { Box, Text, Button } = $.ui.resolve(e)
390 const by = await read($, filter)
391 const q = await read($, query)
392 const shown = matching(list, by, q)
393 const perRow = Math.max(1, Math.floor((e.props.bodyColumns + 1) / (TILE_COLUMNS + 3)))
394 // The header and the search take two rows and the pager one; the tiles get the rest, at least one row of them.
395 const rowsPerPage = Math.max(1, Math.floor((bodyRows - 3) / TILE_HEIGHT))
396 const perPage = perRow * rowsPerPage
397 const pages = Math.max(1, Math.ceil(shown.length / perPage))
398 const page = clamp(await read($, gridPage), 0, pages - 1)
399 const tiles = shown.slice(page * perPage, (page + 1) * perPage)
400 const rows: FileRow[][] = []
401 for (let i = 0; i < tiles.length; i += perRow) rows.push(tiles.slice(i, i + perRow))
402 const label = (name: string) => (name.length > TILE_COLUMNS ? `${name.slice(0, TILE_COLUMNS - 1)}…` : name)
403 const open = async (path: string) => {
404 await update($, viewOffset, () => 0)
405 await update($, expanded, () => false)
406 await update($, viewing, () => path)
407 }
408
409 const picture = async (one: FileRow) => {
410 const kind = kindOf(one.path)
411 if ((kind === 'image' || kind === 'pdf') && e.surface === 'terminal') {
412 const { Image } = $.ui.resolve(e)
413 const preview = await previewOf($, one.path)
414 if (preview) {
415 const fit = fitBox(preview.size, TILE_COLUMNS, TILE_ROWS)
416
417 return <Image key={`grid-image-${one.path}`} source={{ file: preview.png, format: 'png' }} columns={fit.columns} rows={fit.rows} alt={one.name} />
418 }
419 }
420 if (kind === 'text') {
421 const lines = ((await textOf($, one.path)) ?? '').split('\n').filter(line => line.trim() !== '').slice(0, TILE_ROWS)
422
423 return (
424 <Box flexDirection="column" width={TILE_COLUMNS}>
425 {lines.map((line, i) => (
426 <Text key={`grid-line-${one.path}-${i}`} dimColor wrap="truncate">{line.trim()}</Text>
427 ))}
428 </Box>
429 )
430 }
431
432 return <Text dimColor>{kind === 'pdf' ? 'PDF' : kind === 'image' ? 'image' : 'file'}</Text>
433 }
434
435 return (
436 <Box flexDirection="column" height={bodyRows}>
437 {header($, e, shown.length, by, 'grid')}
438 {await searchRow($, e)}
439 {shown.length === 0 && <Text dimColor>{list.length === 0 ? 'Nothing yet: paste an image, @mention a file, or let Claude read one.' : 'No file matches.'}</Text>}
440 {await Promise.all(
441 rows.map(async (cells, r) => (
442 <Box key={`grid-row-${r}`} flexDirection="row" columnGap={1}>
443 {await Promise.all(
444 cells.map(async one => (
445 <Box key={`grid-tile-${one.path}`} flexDirection="column" alignItems="center" borderStyle="round" borderDimColor width={TILE_COLUMNS + 2}>
446 <Box height={TILE_ROWS} width={TILE_COLUMNS} alignItems="center" justifyContent="center" overflow="hidden">
447 {await picture(one)}
448 </Box>
449 {/* The name is padded to the tile's width, so the whole bottom line takes the click. */}
450 <Button key={`grid-open-${one.path}`} plain onPress={() => open(one.path)}>{label(one.name).padEnd(TILE_COLUMNS, ' ')}</Button>
451 </Box>
452 )),
453 )}
454 </Box>
455 )),
456 )}
457 {/* The pager is pinned to the last line of the box. */}
458 <Box flexGrow={1} />
459 {pager($, e, 'grid', page, pages, to => update($, gridPage, () => to))}
460 </Box>
461 )
462}
463
464export const register: Register = (on, options) => {
465 const isSplit = String(options.layout ?? 'split') !== 'tab'
466 const hosts = String(options.hostPanes ?? 'todo-pane,task-lists')
467 .split(',')
468 .map(id => id.trim())
469 .filter(id => id !== '')
470 const share = clamp(Number(options.topShare ?? 50), 20, 80)
471
472 on('session.start', async ($, e, next) => {
473 await safely(() => $.command.register({ name: 'files', description: "Show the session's files in the side panel" }))
474 await safely(() => $.command.register({ name: 'side-panel', description: 'Open or close the whole side panel: Todos, Task lists and the files below them' }))
475 await safely(() => $.command.register({ name: 'gallery', description: "Show the session's files as a grid of thumbnails" }))
476 void safely(async () => {
477 const saved = await $.store.get(await storeKey($))
478 if (Array.isArray(saved)) await update($, files, list => (list.length > 0 ? list : (saved as FileRow[]).map(row => ({ ...row, kind: kindOf(row.path) }))))
479 })
480 if (!isSplit) void $.ui.open({ id: PANE, title: 'Files' })
481
482 return next(e)
483 })
484
485 on('command.run', { command: 'files' }, async $ => {
486 await $.ui.open(isSplit ? { id: hosts[0] ?? 'todo-pane', focus: true } : { id: PANE, title: 'Files', focus: true })
487
488 return { text: 'Files shown in the side panel.' }
489 })
490
491 // One switch for the whole side panel: every tab and the files under them open together, or close together.
492 on('command.run', { command: 'side-panel' }, async $ => {
493 const tabs = isSplit ? hosts : [PANE]
494 const open = (await $.ui.panes()).filter(pane => tabs.includes(pane.id))
495 if (open.length > 0) {
496 for (const pane of open) await $.ui.close({ id: pane.id })
497
498 return { text: 'Side panel closed.' }
499 }
500 for (const [i, id] of tabs.entries()) {
501 await $.ui.open({ id, title: TITLES[id] ?? id, ...(i === 0 ? { focus: true as const } : {}) })
502 }
503
504 return { text: `Side panel opened: ${tabs.map(id => TITLES[id] ?? id).join(', ')}, with the files below.` }
505 })
506
507 on('command.run', { command: 'gallery' }, async $ => {
508 await update($, mode, () => 'grid')
509 await $.ui.open(isSplit ? { id: hosts[0] ?? 'todo-pane', focus: true } : { id: PANE, title: 'Files', focus: true })
510
511 return { text: 'Files shown as a grid in the side panel.' }
512 })
513
514 // Files Claude reads or changes.
515 on('tool.call', async ($, e, next) => {
516 const hit = touched(e)
517 if (hit) await safely(() => track($, hit.path, hit.source))
518
519 return next(e)
520 })
521
522 // Files the person pastes or @mentions in a prompt.
523 on('prompt.submit', async ($, e, next) => {
524 if (e.origin.kind === 'composer') {
525 await safely(async () => {
526 const cwd = await $.session.cwd()
527 for (const n of imageNumbers(e.text)) {
528 const path = await pastedFile($, n)
529 if (path !== undefined) await track($, path, 'pasted')
530 }
531 for (const mention of mentionPaths(e.text)) {
532 const path = absolute(cwd, mention)
533 const stat = await $.fs.stat(path).catch(() => undefined)
534 if (stat?.kind === 'file') await track($, path, 'mention')
535 }
536 })
537 }
538
539 return next(e)
540 })
541
542 // A pane of its own, for the tab layout (and as the fallback when the host pane is not drawn).
543 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => filesView($, e, e.props.scroll.bodyRows))
544
545 // Split layout: the host pane keeps its drawing in the top part and the files take the rest.
546 // The files stay put while the top half changes tabs: every host pane gets the same bottom half.
547 if (isSplit) {
548 for (const host of hosts) {
549 on('ui.render', { component: 'Pane', requestId: host }, async ($, e, next) => {
550 const { Box, Text } = $.ui.resolve(e)
551 // One row goes to the divider between the parts.
552 const rows = Math.max(2 * MIN_ROWS + 1, e.props.scroll.bodyRows)
553 const topRows = clamp(Math.round(((rows - 1) * share) / 100), MIN_ROWS, rows - 1 - MIN_ROWS)
554 const bottomRows = rows - 1 - topRows
555 const rule = '─'.repeat(Math.max(4, e.props.bodyColumns))
556 // A viewer opened big takes the whole panel; the host pane's part waits behind it.
557 if ((await read($, viewing)) !== null && (await read($, expanded))) return filesView($, e, e.props.scroll.bodyRows)
558 const upper = await next({ ...e, props: { ...e.props, scroll: { ...e.props.scroll, bodyRows: topRows } } })
559
560 return (
561 <Box flexDirection="column">
562 <Box key="files-top" flexDirection="column" height={topRows} overflow="hidden">
563 {upper}
564 </Box>
565 <Text dimColor>{rule}</Text>
566 <Box key="files-bottom" flexDirection="column" height={bottomRows} overflow="hidden">
567 {await filesView($, e, bottomRows)}
568 </Box>
569 </Box>
570 )
571 })
572 }
573 }
574}
575hooks/files.ts 122 lines1import type { FileFilter, FileKind, FileRow, FileSource } from '../types'
2
3const MAX_ROWS = 200
4const IMAGE = /\.(png|jpe?g|gif|webp|bmp|heic|tiff?)$/i
5const PDF = /\.pdf$/i
6const BINARY = /\.(zip|gz|tar|dmg|exe|bin|mp[34]|mov|wav|docx?|xlsx?|pptx?)$/i
7
8export const kindOf = (path: string): FileKind => (IMAGE.test(path) ? 'image' : PDF.test(path) ? 'pdf' : BINARY.test(path) ? 'other' : 'text')
9
10export const nameOf = (path: string) => path.split(/[\\/]/).pop() || path
11
12/** Absolute form of a path the model or the person gave, relative to the working directory. */
13export function absolute(cwd: string, path: string): string {
14 if (path.startsWith('/')) return path
15 const parts = `${cwd.replace(/\/+$/, '')}/${path.replace(/^\.\//, '')}`.split('/')
16 const out: string[] = []
17 for (const part of parts) {
18 if (part === '..') out.pop()
19 else if (part !== '.' && part !== '') out.push(part)
20 }
21 return `/${out.join('/')}`
22}
23
24/** What goes after "@" to mention the file: relative inside the working directory, else absolute. */
25export function mentionOf(cwd: string, path: string): string {
26 const base = `${cwd.replace(/\/+$/, '')}/`
27 return path.startsWith(base) ? path.slice(base.length) : path
28}
29
30/** The paths an `@` mention names in a prompt, quotes and trailing punctuation removed. */
31export function mentionPaths(text: string): string[] {
32 const found: string[] = []
33 for (const match of text.matchAll(/(?:^|\s)@("[^"]+"|\S+)/g)) {
34 const path = match[1]!.replace(/^"|"$/g, '').replace(/[,.;:!?)\]]+$/, '')
35 if (path.includes('/') || /\.[A-Za-z0-9]{1,5}$/.test(path)) found.push(path)
36 }
37 return found
38}
39
40/** Adds a file, or notes another way it came in; the latest touched stays first. */
41export function addFile(list: FileRow[], path: string, source: FileSource, at: number): FileRow[] {
42 const old = list.find(row => row.path === path)
43 const row: FileRow = old
44 ? { ...old, sources: old.sources.includes(source) ? old.sources : [...old.sources, source], lastAt: at }
45 : { path, name: nameOf(path), kind: kindOf(path), sources: [source], firstAt: at, lastAt: at, uses: 0 }
46
47 return [row, ...list.filter(one => one.path !== path)].slice(0, MAX_ROWS)
48}
49
50const isMine = (row: FileRow) => row.sources.some(s => s === 'pasted' || s === 'mention')
51const isClaudes = (row: FileRow) => row.sources.some(s => s === 'read' || s === 'edit')
52
53export function visibleFiles(list: FileRow[], filter: FileFilter): FileRow[] {
54 return filter === 'mine' ? list.filter(isMine) : filter === 'claude' ? list.filter(isClaudes) : list
55}
56
57const WORDS: Record<FileSource, string> = { pasted: 'pasted', mention: '@', read: 'read', edit: 'edited' }
58export const sourcesLabel = (row: FileRow) => row.sources.map(s => WORDS[s]).join(' · ')
59
60/** Rows worth keeping between sessions: a pasted image's file lives in the session's temp folder. */
61export const keepable = (list: FileRow[]) => list.filter(row => row.sources.some(s => s !== 'pasted'))
62
63const TOOL_PATHS: Record<string, { key: string; source: FileSource }> = {
64 Read: { key: 'file_path', source: 'read' },
65 Edit: { key: 'file_path', source: 'edit' },
66 MultiEdit: { key: 'file_path', source: 'edit' },
67 Write: { key: 'file_path', source: 'edit' },
68 NotebookEdit: { key: 'notebook_path', source: 'edit' },
69}
70
71/** The file a tool call touches, when it is one of the file tools. */
72export function touched(e: { tool: string }): { path: string; source: FileSource } | undefined {
73 const spec = TOOL_PATHS[e.tool]
74 const path = spec && (e as unknown as Record<string, unknown>)[spec.key]
75
76 return spec && typeof path === 'string' && path !== '' ? { path, source: spec.source } : undefined
77}
78
79// A terminal cell is about twice as tall as it is wide.
80const CELL_ASPECT = 2
81
82/** The biggest box of cells that keeps a picture's shape inside `maxColumns` x `maxRows`. */
83export function fitBox(size: { width: number; height: number } | null, maxColumns: number, maxRows: number) {
84 const { width, height } = size ?? { width: 16, height: 10 }
85 let columns = Math.max(1, maxColumns)
86 let rows = Math.max(1, Math.round((columns * height) / (CELL_ASPECT * width)))
87 if (rows > maxRows) {
88 rows = Math.max(1, maxRows)
89 columns = Math.max(1, Math.min(maxColumns, Math.round((rows * CELL_ASPECT * width) / height)))
90 }
91
92 return { columns, rows }
93}
94
95/** The files a filter and a search box leave: every word of the query must be in the name or the path. */
96export function matching(list: FileRow[], by: FileFilter, query: string): FileRow[] {
97 const words = query.toLowerCase().split(/\s+/).filter(word => word !== '')
98
99 return visibleFiles(list, by).filter(row => {
100 const hay = `${row.name} ${row.path}`.toLowerCase()
101
102 return words.every(word => hay.includes(word))
103 })
104}
105
106export function formatSize(bytes: number): string {
107 if (bytes < 1024) return `${bytes} B`
108 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10240 ? 1 : 0)} KB`
109
110 return `${(bytes / 1024 / 1024).toFixed(1)} MB`
111}
112
113/** "just now", "5m ago", "3h ago", "2d ago". */
114export function ago(at: number, now: number): string {
115 const seconds = Math.max(0, Math.round((now - at) / 1000))
116 if (seconds < 45) return 'just now'
117 if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`
118 if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`
119
120 return `${Math.round(seconds / 86400)}d ago`
121}
122hooks/layout.ts 132 lines1export type Size = { width: number; height: number }
2export type Cells = { columns: number; rows: number }
3
4const TILE_ROWS = 6
5const MAX_COLUMNS = 32
6const MIN_COLUMNS = 4
7// A terminal cell is about twice as tall as it is wide.
8const CELL_ASPECT = 2
9// Used when the size is unknown (file over $.fs.read's 4 MiB cap, or no file).
10const FALLBACK: Size = { width: 16, height: 10 }
11// Each tile adds a border on every side and a label row under the picture.
12const TILE_CHROME_ROWS = 3
13const TILE_CHROME_COLUMNS = 2
14const GAP = 1
15
16/** The distinct image numbers a draft references, in the order they first appear. */
17export function imageNumbers(draft: string): number[] {
18 const seen = new Set<number>()
19 for (const match of draft.matchAll(/\[Image #(\d+)\]/g)) seen.add(Number(match[1]))
20 return [...seen]
21}
22
23/** Width and height from a PNG's IHDR chunk, or null when the bytes aren't a PNG. */
24export function pngSize(base64: string): Size | null {
25 // 24 bytes cover the signature and IHDR's width and height; 32 base64 chars decode to exactly 24.
26 const head = Uint8Array.from(atob(base64.slice(0, 32)), char => char.charCodeAt(0))
27 const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
28 if (head.length < 24 || signature.some((byte, i) => head[i] !== byte)) return null
29 const view = new DataView(head.buffer, head.byteOffset, head.byteLength)
30 const width = view.getUint32(16)
31 const height = view.getUint32(20)
32 return width > 0 && height > 0 ? { width, height } : null
33}
34
35/** A picture box `rows` tall that keeps the picture's aspect ratio. */
36export function fitCells(size: Size | null, tileRows = TILE_ROWS): Cells {
37 const { width, height } = size ?? FALLBACK
38 let rows = tileRows
39 let columns = Math.round((rows * CELL_ASPECT * width) / height)
40 if (columns > MAX_COLUMNS) {
41 columns = MAX_COLUMNS
42 rows = Math.max(1, Math.round((MAX_COLUMNS * height) / (CELL_ASPECT * width)))
43 }
44 return { columns: Math.max(MIN_COLUMNS, columns), rows: Math.min(rows, tileRows) }
45}
46
47/**
48 * Picture boxes for one row of tiles that fits the band whole, so it never scrolls:
49 * the tallest tiles whose chrome fits in `maxRows` and whose total width fits in `bodyColumns`.
50 * When even one-row tiles are too wide, only the first ones that fit beside a `+N` label for
51 * the rest; none when the band is too small for one tile.
52 */
53export function fitRow(sizes: readonly (Size | null)[], maxRows: number, bodyColumns: number): Cells[] {
54 if (maxRows < TILE_CHROME_ROWS + 1) return []
55 const tallest = Math.min(TILE_ROWS, maxRows - TILE_CHROME_ROWS)
56 for (let tileRows = tallest; tileRows > 1; tileRows--) {
57 const cells = sizes.map(size => fitCells(size, tileRows))
58 if (rowWidth(cells, 0) <= bodyColumns) return cells
59 }
60 const cells = sizes.map(size => fitCells(size, 1))
61 let count = cells.length
62 while (count > 0 && rowWidth(cells.slice(0, count), cells.length - count) > bodyColumns) count--
63 return cells.slice(0, count)
64}
65
66function rowWidth(cells: readonly Cells[], hidden: number): number {
67 const tiles = cells.reduce((sum, c) => sum + c.columns + TILE_CHROME_COLUMNS, 0) + GAP * Math.max(0, cells.length - 1)
68 return hidden > 0 ? tiles + GAP + `+${hidden}`.length : tiles
69}
70
71type Env = Readonly<Record<string, string | undefined>>
72
73/**
74 * Whether Claude Code will draw `Image` as a real picture. It decides that itself and doesn't tell
75 * mods, so this mirrors its rule as far as the environment shows it: always when
76 * CLAUDE_CODE_FORCE_TERMINAL_IMAGES is set; otherwise never inside tmux or screen or in a
77 * background session, and only when the terminal is kitty or Ghostty (it goes by the terminal's
78 * name, so other kitty-protocol terminals such as WezTerm still get the `alt` text).
79 * `CLAUDE_IMAGE_VIEW_RENDERER` overrides the guess: `image` for the picture, `blocks` for colored half blocks.
80 */
81export function drawsPictures(env: Env): boolean {
82 const forced = env.CLAUDE_IMAGE_VIEW_RENDERER
83 if (forced === 'image' || forced === 'blocks') return forced === 'image'
84 if (env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES) return true
85 if (env.TMUX || env.STY || env.CLAUDE_CODE_SESSION_KIND === 'bg') return false
86 return (
87 Boolean(env.KITTY_WINDOW_ID || env.GHOSTTY_RESOURCES_DIR) ||
88 /kitty|ghostty/i.test(env.TERM ?? '') ||
89 /^ghostty$/i.test(env.TERM_PROGRAM ?? '')
90 )
91}
92
93/**
94 * The cells of a `Raster` that draws `rgb` (a `width` x `height` thumbnail) in `columns` x `rows`
95 * cells, base64. Each cell is an upper half block, so it holds two pixels: the top as its
96 * foreground and the bottom as its background.
97 */
98export function blockCells(thumb: { width: number; height: number; rgb: Uint8Array }, columns: number, rows: number): string {
99 const pixelRows = rows * 2
100 const average = (px: number, py: number): number => {
101 // The box of source pixels under output pixel (px, py), at least one pixel wide and tall.
102 const x0 = Math.floor((px * thumb.width) / columns)
103 const x1 = Math.max(x0 + 1, Math.floor(((px + 1) * thumb.width) / columns))
104 const y0 = Math.floor((py * thumb.height) / pixelRows)
105 const y1 = Math.max(y0 + 1, Math.floor(((py + 1) * thumb.height) / pixelRows))
106 let r = 0
107 let g = 0
108 let b = 0
109 let n = 0
110 for (let y = y0; y < y1 && y < thumb.height; y++) {
111 for (let x = x0; x < x1 && x < thumb.width; x++, n++) {
112 const at = (y * thumb.width + x) * 3
113 r += thumb.rgb[at]!
114 g += thumb.rgb[at + 1]!
115 b += thumb.rgb[at + 2]!
116 }
117 }
118 n = n || 1
119 return (Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)
120 }
121 const words = new Uint32Array(columns * rows * 3)
122 for (let row = 0; row < rows; row++) {
123 for (let column = 0; column < columns; column++) {
124 const at = (row * columns + column) * 3
125 words[at] = 0x2580
126 words[at + 1] = average(column, row * 2)
127 words[at + 2] = average(column, row * 2 + 1)
128 }
129 }
130 return (new Uint8Array(words.buffer) as Uint8Array & { toBase64(): string }).toBase64()
131}
132types/index.d.ts 41 lines1export type FileSource = 'pasted' | 'mention' | 'read' | 'edit'
2export type FileKind = 'image' | 'pdf' | 'text' | 'other'
3export type FileFilter = 'all' | 'mine' | 'claude'
4
5export type FileRow = {
6 /** Absolute path. */
7 path: string
8 name: string
9 kind: FileKind
10 /** How the file came into the session; a file can have several. */
11 sources: FileSource[]
12 firstAt: number
13 lastAt: number
14 /** How many times it was put back into a prompt with Use. */
15 uses: number
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'session-files': {
21 files: FileRow[]
22 selected: string | null
23 /** The path being viewed in the panel, or null for the list. */
24 viewing: string | null
25 /** First line (text) shown while viewing. */
26 viewOffset: number
27 /** The bottom half shows thumbnail tiles or the plain list. */
28 mode: 'grid' | 'list'
29 /** The search box's text. */
30 query: string
31 /** The grid's current page, from 0. */
32 gridPage: number
33 /** The list's current page, from 0. */
34 listPage: number
35 /** The viewer takes the whole panel, the host pane's part included. */
36 expanded: boolean
37 filter: FileFilter
38 }
39 }
40}
41