SLOPSHOPPER

session-files

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…

newpaneguardcommandpromptprocess
v0.10.0MITupdated 2026-10-26lpareja99/claude-mods/session-files
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-files
│ ┃ todo-pane ✕ › fix the failing auth test and add an audit log call │ ┃ ⟨Claude Code's own drawing⟩ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /files │ ┃ ⎿ session-files: Files shown in the side panel. │ ┃ │ ┃ ──────────────────────────────────────────── │ ┃ ──────────── │ ┃ Files 3 │ files-mode-grid files-mode-list │ │ ┃ ⌕: search by name or path │ ┃ ╭────────────╮ ╭────────────╮ ╭────────────╮ │ ┃ │ │ │ │ │import { ve…│ │ ┃ │ │ │ │ │export asyn…│ │ ┃ │ │ │ │ │const claim…│ │ ┃ │cache.ts │ │audit.ts │ │auth.ts │ │ ┃ ╰────────────╯ ╰────────────╯ ╰────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · todo-pane
⟨Claude Code's own drawing⟩ ──────────────────────────────────────────────────────── Files 3 │ files-mode-grid files-mode-list │ files-filter-all ⌕: search by name or path ╭────────────╮ ╭────────────╮ ╭────────────╮ │ │ │ │ │import { ve…│ │ │ │ │ │export asyn…│ │ │ │ │ │const claim…│ │cache.ts │ │audit.ts │ │auth.ts │ ╰────────────╯ ╰────────────╯ ╰────────────╯
Pane · session-files
Files 3 │ files-mode-grid files-mode-list │ files-filter-all ⌕: search by name or path ╭────────────╮ ╭────────────╮ ╭────────────╮ │ │ │ │ │import { ve…│ │ │ │ │ │export asyn…│ │ │ │ │ │const claim…│ │cache.ts │ │audit.ts │ │auth.ts │ ╰────────────╯ ╰────────────╯ ╰────────────╯
Pane · task-lists
⟨Claude Code's own drawing⟩ ──────────────────────────────────────────────────────── Files 3 │ files-mode-grid files-mode-list │ files-filter-all ⌕: search by name or path ╭────────────╮ ╭────────────╮ ╭────────────╮ │ │ │ │ │import { ve…│ │ │ │ │ │export asyn…│ │ │ │ │ │const claim…│ │cache.ts │ │audit.ts │ │auth.ts │ ╰────────────╯ ╰────────────╯ ╰────────────╯
README

session-files

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

SourceHow it gets in
pastedan image you paste into a prompt
@a file you @mention in a prompt (only if it exists)
read / editeda 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.

Search

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.

Grid and list

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.

Layout

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.

Install

/plugin install session-files --marketplace lpareja99/claude-mods
/reload-plugins

Notes

  • Images draw as real pictures in Ghostty and kitty (not inside tmux). A non-PNG image is converted once with sips (macOS).
  • Whether @path of a pasted image attaches it as an image is up to Claude Code.
  • Viewing reads files up to 4 MiB.
Source 4 files
hooks/register.tsx 575 lines
1import { 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}
575
hooks/files.ts 122 lines
1import 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}
122
hooks/layout.ts 132 lines
1export 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}
132
types/index.d.ts 41 lines
1export 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