SLOPSHOPPER

files-panel

A live Files pane: every file Claude reads, edits or writes this session, as a tree, with what is happening to each and whether git has it committed

newpaneguardcommandpromptprocess
v0.1.0MITupdated 2026-10-24eranw2000/claude-code-mods/files-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · files-panel
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ Files · 0 read · 3 changed · 0 committed │ ┃ Last: cache.ts · changed · no git · main · ⏺ Read(src/auth.ts) │ ┃ just now ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ /work/app/src · no git · searched · 3 cha… ⎿ Added 2 lines, removed 1 line │ ┃ ● audit.ts · changed · no git · main · … ⏺ Bash(bun test) │ ┃ ● auth.ts · changed · no git · main · j… ⎿ 3 pass, 1 fail │ ┃ ● cache.ts · changed · no git · main · … │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /files-panel │ ⎿ files-panel: Files pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files
Files · 0 read · 3 changed · 0 committed Last: cache.ts · changed · no git · main · just now /work/app/src · no git · searched · 3 changed ● audit.ts · changed · no git · main · just now ● auth.ts · changed · no git · main · just now ● cache.ts · changed · no git · main · just now
README

Claude Code mods

Three mods for Claude Code that draw three live side panes (Agents, Thread and Files), showing what Claude and its helpers are doing while they work. A mod is a Claude Code plugin whose hooks draw on Claude's own screen, so these panes update by themselves as the session runs.

  • agents-panel: a live Agents pane.
  • One card per helper agent: its task, what it is doing now, its recent actions, its plan steps, tokens, an approximate cost and its time. Each helper type wears its own pixel crab (a developer with headphones and a laptop, a reviewer in a graduation cap, a planner in a captain's hat, and so on).
  • A progress bar for the current batch of helpers at the top of the pane: tasks done, percent and time left. It can also be drawn above the prompt.
  • What the main loop is doing, and every shell command of the session, with background commands kept until they report back.
  • A card for each Codex run started through codex_delegate.py (from claude-release-workflow): what it reviews, its model and effort, live tokens from its session log, time against its limit or against how long similar past runs took, and its end in words (finished, timed out, refused, and so on).
  • /agents-panel opens the pane.
  • thread-panel: a live Thread pane, your conversation at a glance.
  • Each prompt you typed in this conversation, oldest first, with the first paragraph of Claude's answer under it, and how long ago each was.
  • A long prompt is folded; press to open it whole.
  • Click a prompt to scroll the conversation to it.
  • It opens with your first prompt, and /thread-panel opens it too.
  • files-panel: a live Files pane.
  • Every file Claude reads, edits or writes this session, as a tree, with what is happening to each one and whether git has it committed.
  • Files outside the project folder are folded under their own heading.
  • /files-panel opens the pane.

The panes start over when you /clear or start a new conversation.

Three panes beside the conversation: Agents opens when a helper starts or with your first prompt, Thread lists your prompts and jumps to one on a click, and Files shows every file the session reads or edits

The diagram's source is docs/mods-flow.drawio.

Install

Add this repository as a plugin marketplace, then install the mods you want:

claude plugin marketplace add https://github.com/eranw2000/claude-code-mods
claude plugin install agents-panel@claude-code-mods
claude plugin install files-panel@claude-code-mods
claude plugin install thread-panel@claude-code-mods

Restart Claude Code to load them. The panes open by themselves when there is something to show (Agents and Thread with your first prompt), or on /agents-panel, /thread-panel and /files-panel.

If you installed agents-panel before version 0.7.0, the Thread pane was part of it. It is now thread-panel: install it as above to keep the pane.

To update later:

claude plugin marketplace update claude-code-mods
claude plugin update agents-panel@claude-code-mods
claude plugin update files-panel@claude-code-mods
claude plugin update thread-panel@claude-code-mods

Settings

Each mod has switches in /config:

  • agents-panel
  • Show the Agents pane (on by default). Off: the pane never opens by itself.
  • Show the progress bar above the prompt too (off by default). The bar is always at the top of the Agents pane; on, it is also drawn above the prompt while helpers run.
  • files-panel
  • Show the files panel (on by default). Off: nothing is tracked and the pane never opens.
  • thread-panel
  • Show the Thread pane (on by default). Off: the pane never opens by itself and /thread-panel says it is off.

What the mods run

The Codex card reads a run's own files (.codex-runs/<run>/request.json, receipt.json and child-process.json beside the run's --out file) and uses four read-only programs on macOS: find, printenv, lsof and tail. The Files pane asks git whether a file is committed. The Thread pane runs no programs. None of the mods writes files or changes anything in your repositories.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 660 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer, ToolCallResult } from 'claude-code'
3
4import type { DirInfo, FileRow, GitState } from '../types'
5
6const PANE = 'files-panel'
7const TITLE = 'Files'
8const files = atom({ plugin: 'files-panel', key: 'files' } as const, {} as Record<string, FileRow>)
9const dirs = atom({ plugin: 'files-panel', key: 'dirs' } as const, {} as Record<string, DirInfo>)
10const helpers = atom({ plugin: 'files-panel', key: 'helpers' } as const, {} as Record<string, string>)
11const now = atom({ plugin: 'files-panel', key: 'now' } as const, 0)
12const cwd = atom({ plugin: 'files-panel', key: 'cwd' } as const, '')
13const hasOpened = atom({ plugin: 'files-panel', key: 'hasOpened' } as const, false)
14const isOpen = atom({ plugin: 'files-panel', key: 'isOpen' } as const, false)
15// The conversation the rows belong to; '' until the first event reads it.
16const sessionId = atom({ plugin: 'files-panel', key: 'sessionId' } as const, '')
17// The project folder (where the session started, or where /cd took it); a file
18// outside it goes in the folded "Other places" group.
19const root = atom({ plugin: 'files-panel', key: 'root' } as const, '')
20const isOtherOpen = atom({ plugin: 'files-panel', key: 'isOtherOpen' } as const, false)
21
22// The slow git check while the pane is open.
23const EVERY_MS = 15_000
24// Paths per git call, so an argument list never grows past what a shell takes.
25const CHUNK = 100
26const ORANGE = '#d9734e'
27const BLUE = '#4a90e2'
28const GREEN = 'success'
29// Outside any repository, rows are grouped under this key.
30const OUTSIDE = '\u0000outside'
31
32type Target = { raw: string; verb: string; isFolder: boolean; isWrite: boolean }
33type Shown = 'working' | 'searched' | 'committed' | 'changed' | 'read'
34
35let ticker: Timer | undefined
36
37function str(value: unknown): string {
38  return typeof value === 'string' ? value : ''
39}
40
41// What a call does to which path; undefined for a call that touches no file.
42// Bash is not here: its command is never read.
43function targetOf(tool: string, a: Record<string, unknown>): Target | undefined {
44  switch (tool) {
45    case 'Read': return { raw: str(a.file_path), verb: 'reading', isFolder: false, isWrite: false }
46    case 'Edit': case 'MultiEdit':
47      return { raw: str(a.file_path), verb: 'editing', isFolder: false, isWrite: true }
48    case 'NotebookEdit': return { raw: str(a.notebook_path), verb: 'editing', isFolder: false, isWrite: true }
49    case 'Write': return { raw: str(a.file_path), verb: 'writing', isFolder: false, isWrite: true }
50    case 'Glob': case 'Grep': return { raw: str(a.path), verb: 'searching', isFolder: true, isWrite: false }
51    default: return undefined
52  }
53}
54
55function normalize(path: string, base: string): string {
56  const abs = path.startsWith('/') ? path : `${base || '/'}/${path}`
57  const out: string[] = []
58  for (const seg of abs.split('/')) {
59    if (!seg || seg === '.') continue
60    if (seg === '..') out.pop()
61    else out.push(seg)
62  }
63  return `/${out.join('/')}`
64}
65
66function dirOf(path: string): string {
67  const i = path.lastIndexOf('/')
68  return i <= 0 ? '/' : path.slice(0, i)
69}
70
71function baseName(path: string): string {
72  return path.split('/').filter(Boolean).pop() ?? path
73}
74
75function fit(text: string, cols: number): string {
76  return text.length <= cols ? text : `${text.slice(0, Math.max(1, cols - 1))}…`
77}
78
79// A path cut from its head, so its end (the file) stays readable.
80function fitPath(text: string, cols: number): string {
81  return text.length <= cols ? text : `…${text.slice(text.length - Math.max(1, cols - 1))}`
82}
83
84// A line cut to `cols` from its head part, so the tail (a state, an age) stays whole.
85// On a narrow pane the head keeps at least a few cells and the tail is cut too.
86function withTail(head: string, tail: string, cols: number): string {
87  const room = Math.max(Math.min(head.length, 8), cols - tail.length)
88  const line = head.length <= room ? head + tail : fit(head, room) + tail
89  return fit(line, cols)
90}
91
92function ago(ms: number): string {
93  const s = Math.max(0, Math.round(ms / 1000))
94  if (s < 5) return 'just now'
95  if (s < 60) return `${s}s ago`
96  const m = Math.floor(s / 60)
97  if (m < 60) return `${m}m ago`
98  return `${Math.floor(m / 60)}h${m % 60}m ago`
99}
100
101function shownOf(row: FileRow): Shown {
102  if (row.active > 0) return 'working'
103  if (row.isFolder) return 'searched'
104  if (row.hasWritten) return row.git === 'clean' ? 'committed' : 'changed'
105  return 'read'
106}
107
108function stateWord(row: FileRow): string {
109  const shown = shownOf(row)
110  if (shown === 'working') return row.verb
111  if (shown === 'changed') {
112    if (row.git === 'no-git') return 'changed · no git'
113    if (row.git === 'untracked') return 'changed · untracked'
114    if (row.git === 'untracked-ignored') return 'changed · not tracked'
115    return 'changed'
116  }
117  return shown
118}
119
120function blank(path: string, t: Target, by: string, at: number): FileRow {
121  return {
122    path, isFolder: t.isFolder, isResolved: false, root: '', rel: path, hasWritten: false,
123    git: 'unknown', verb: t.verb, active: 0, by, at,
124  }
125}
126
127// Who made a call: 'main', a helper's description, or undefined for a helper
128// this conversation does not know (one of an earlier conversation, still busy
129// after a /clear), whose files are not listed.
130async function whoIs($: EngineInterface, agentId: string | undefined): Promise<string | undefined> {
131  if (!agentId) return 'main'
132  const known = (await read($, helpers))[agentId]
133  if (known) return known
134  try {
135    const info = (await $.agent.list()).find(a => a.id === agentId)
136    if (info?.description) {
137      await update($, helpers, h => ({ ...h, [agentId]: info.description }))
138      return info.description
139    }
140  } catch {}
141  return undefined
142}
143
144// The repository holding a folder, asked of git once per folder. Read-only.
145async function resolveDir($: EngineInterface, dir: string): Promise<DirInfo | undefined> {
146  const known = (await read($, dirs))[dir]
147  if (known) return known
148  let info: DirInfo
149  try {
150    const r = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel', '--show-prefix'], { timeoutMs: 10_000 })
151    if (r.exitCode === 0) {
152      const [root = '', prefix = ''] = r.stdout.split('\n')
153      info = { root, prefix }
154    } else if (/not a git repository/i.test(r.stderr)) {
155      info = { root: '', prefix: '' }
156    } else {
157      return undefined
158    }
159  } catch {
160    return undefined
161  }
162  await update($, dirs, d => ({ ...d, [dir]: info }))
163  return info
164}
165
166// Fills in which repository a touched path is in, the first time it is touched.
167async function place($: EngineInterface, path: string) {
168  const row = (await read($, files))[path]
169  if (!row || row.isResolved) return
170  let rel = path
171  let info = row.isFolder ? await resolveDir($, path) : undefined
172  if (info && info.root) rel = info.prefix.replace(/\/$/, '')
173  if (!info) {
174    info = await resolveDir($, dirOf(path))
175    if (info && info.root) rel = info.prefix + baseName(path)
176  }
177  if (!info) return
178  const root = info.root
179  await update($, files, all => {
180    const cur = all[path]
181    if (!cur) return all
182    return {
183      ...all,
184      [path]: { ...cur, isResolved: true, root, rel: root ? rel : path, git: root ? cur.git : 'no-git' },
185    }
186  })
187}
188
189// Splits `git status --porcelain=v1 -z` into path -> XY. A rename or copy
190// carries its old path as the next field, which is skipped.
191function parseStatus(out: string): Record<string, string> {
192  const map: Record<string, string> = {}
193  const fields = out.split('\u0000')
194  for (let i = 0; i < fields.length; i++) {
195    const f = fields[i] ?? ''
196    if (f.length < 4) continue
197    const xy = f.slice(0, 2)
198    map[f.slice(3)] = xy
199    if (xy[0] === 'R' || xy[0] === 'C') i++
200  }
201  return map
202}
203
204// Asks git, read-only, what state each written file of one repository is in.
205// `--no-optional-locks` keeps status from refreshing the index.
206async function checkRepo($: EngineInterface, root: string) {
207  const rows = Object.values(await read($, files)).filter(r => r.hasWritten && r.isResolved && r.root === root)
208  if (rows.length === 0) return
209  const states: Record<string, GitState> = {}
210  for (let i = 0; i < rows.length; i += CHUNK) {
211    const part = rows.slice(i, i + CHUNK)
212    const rels = part.map(r => r.rel)
213    let status: Record<string, string>
214    let tracked: Set<string>
215    try {
216      const s = await $.process.run(
217        ['git', '--literal-pathspecs', '--no-optional-locks', '-C', root, 'status', '--porcelain=v1', '-z', '--untracked-files=all', '--', ...rels],
218        { timeoutMs: 20_000 },
219      )
220      const l = await $.process.run(
221        ['git', '--literal-pathspecs', '--no-optional-locks', '-C', root, 'ls-files', '-z', '--', ...rels],
222        { timeoutMs: 20_000 },
223      )
224      if (s.exitCode !== 0 || l.exitCode !== 0) continue
225      status = parseStatus(s.stdout)
226      tracked = new Set(l.stdout.split('\u0000').filter(Boolean))
227    } catch {
228      continue
229    }
230    for (const r of part) {
231      const xy = status[r.rel]
232      states[r.path] = xy === '??' ? 'untracked'
233        : xy !== undefined ? 'dirty'
234        : tracked.has(r.rel) ? 'clean'
235        : 'untracked-ignored'
236    }
237  }
238  if (Object.keys(states).length === 0) return
239  await update($, files, all => {
240    const next = { ...all }
241    for (const [path, git] of Object.entries(states)) {
242      const cur = next[path]
243      if (cur) next[path] = { ...cur, git }
244    }
245    return next
246  })
247}
248
249async function checkAll($: EngineInterface) {
250  const roots = new Set(Object.values(await read($, files)).filter(r => r.hasWritten && r.root).map(r => r.root))
251  for (const root of roots) await checkRepo($, root)
252}
253
254async function tick($: EngineInterface) {
255  const t = await $.clock.now()
256  await update($, now, () => t)
257  if (!(await read($, isOpen))) {
258    ticker?.cancel()
259    ticker = undefined
260    return
261  }
262  await checkAll($)
263}
264
265function wake($: EngineInterface) {
266  if (!ticker) ticker = $.clock.every(EVERY_MS, () => void tick($))
267}
268
269// Drops what one conversation touched, and the folder-to-repository answers,
270// since a repository may have been made or moved since.
271async function forget($: EngineInterface) {
272  await update($, files, () => ({}))
273  await update($, helpers, () => ({}))
274  await update($, dirs, () => ({}))
275  await update($, isOtherOpen, () => false)
276}
277
278// A /clear or a resume keeps the process, and so this state, but starts another
279// conversation under a new id, and no session.start fires for it. session.end
280// drops at once; this is the fallback for a switch it did not see. Only the
281// main loop's events ask (a helper's hook may read its own id and folder), and
282// the decision is made inside one update, so of two racing events one drops.
283async function fresh($: EngineInterface, agentId?: string) {
284  if (agentId) return
285  let id = ''
286  try {
287    id = await $.session.id()
288  } catch {
289    return
290  }
291  let moved = false
292  let changed = false
293  await update($, sessionId, was => {
294    moved = was !== '' && was !== id
295    changed = was !== id
296    return id
297  })
298  if (moved) await forget($)
299  if (!changed) return
300  try {
301    const dir = await $.session.cwd()
302    await update($, cwd, () => dir)
303    const top = await $.session.root()
304    await update($, root, () => top)
305  } catch {}
306}
307
308// macOS reaches /tmp, /var and /etc through /private, so a path may arrive
309// either way; compare without it. Letter case is compared as written.
310function plainPath(path: string): string {
311  return path.replace(/^\/private(?=\/(?:tmp|var|etc)(?:\/|$))/, '').replace(/\/+$/, '')
312}
313
314// Inside the project folder, or the folder itself; with no folder known, all is.
315function isInside(path: string, top: string): boolean {
316  const base = plainPath(top)
317  if (!base) return true
318  const p = plainPath(path)
319  return p === base || p.startsWith(`${base}/`)
320}
321
322async function openPane($: EngineInterface) {
323  await update($, isOpen, () => true)
324  wake($)
325  return $.ui.open({ id: PANE, title: TITLE })
326}
327
328type Node = { name: string; children: Map<string, Node>; file?: FileRow; searched?: FileRow }
329
330function node(name: string): Node {
331  return { name, children: new Map() }
332}
333
334function insert(top: Node, segs: string[], row: FileRow) {
335  let cur = top
336  for (const seg of segs) {
337    let child = cur.children.get(seg)
338    if (!child) {
339      child = node(seg)
340      cur.children.set(seg, child)
341    }
342    cur = child
343  }
344  if (cur === top) {
345    top.searched = row
346    return
347  }
348  if (row.isFolder) cur.searched = row
349  else cur.file = row
350}
351
352function isFolderNode(n: Node): boolean {
353  return n.children.size > 0 || (!n.file && !!n.searched)
354}
355
356type Counts = Record<Shown, number>
357
358function countsOf(n: Node, into: Counts = { working: 0, changed: 0, committed: 0, read: 0, searched: 0 }): Counts {
359  if (n.file) into[shownOf(n.file)] += 1
360  for (const c of n.children.values()) countsOf(c, into)
361  return into
362}
363
364function countLine(c: Counts): string {
365  const parts: string[] = []
366  if (c.working) parts.push(`${c.working} now`)
367  if (c.changed) parts.push(`${c.changed} changed`)
368  if (c.committed) parts.push(`${c.committed} committed`)
369  if (c.read) parts.push(`${c.read} read`)
370  return parts.join(' · ')
371}
372
373// The groups of the tree: one per repository root, and one for the files
374// outside any repository under their nearest common folder.
375function groupsOf(rows: FileRow[]): Array<{ label: string; isGit: boolean; top: Node }> {
376  const byRoot = new Map<string, FileRow[]>()
377  for (const r of rows) {
378    const key = r.root || OUTSIDE
379    byRoot.set(key, [...(byRoot.get(key) ?? []), r])
380  }
381  const out: Array<{ label: string; isGit: boolean; top: Node }> = []
382  for (const [key, list] of byRoot) {
383    const top = node('')
384    if (key === OUTSIDE) {
385      const segsOf = (r: FileRow) => r.path.split('/').filter(Boolean)
386      let common: string[] | undefined
387      for (const r of list) {
388        const folder = r.isFolder ? segsOf(r) : segsOf(r).slice(0, -1)
389        if (!common) common = folder
390        else {
391          let i = 0
392          while (i < common.length && i < folder.length && common[i] === folder[i]) i++
393          common = common.slice(0, i)
394        }
395      }
396      const base = common ?? []
397      for (const r of list) insert(top, segsOf(r).slice(base.length), r)
398      out.push({ label: `/${base.join('/')}`, isGit: false, top })
399    } else {
400      for (const r of list) insert(top, r.rel.split('/').filter(Boolean), r)
401      out.push({ label: key, isGit: true, top })
402    }
403  }
404  return out.sort((a, b) => (a.isGit === b.isGit ? a.label.localeCompare(b.label) : a.isGit ? -1 : 1))
405}
406
407type Line = { key: string; depth: number; text: string; tail: string; shown?: Shown; isFolder: boolean }
408
409// Folders first, then files, each alphabetical; a chain of folders that each
410// hold one folder and nothing else is drawn as one line ("a/b/c").
411function linesOf(n: Node, depth: number, prefix: string, t: number, out: Line[]) {
412  const kids = [...n.children.values()].sort((a, b) => a.name.localeCompare(b.name))
413  for (const k of kids.filter(isFolderNode)) {
414    let label = k.name
415    let cur = k
416    while (!cur.file && !cur.searched && cur.children.size === 1) {
417      const only = [...cur.children.values()][0]!
418      if (!isFolderNode(only)) break
419      label += `/${only.name}`
420      cur = only
421    }
422    const counts = countLine(countsOf(cur))
423    const tags = [cur.searched ? (cur.searched.active > 0 ? 'searching' : 'searched') : '', counts].filter(Boolean).join(' · ')
424    const key = `${prefix}/${label}`
425    out.push({ key, depth, text: `▾ ${label}/`, tail: tags ? ` · ${tags}` : '', isFolder: true, shown: cur.searched && cur.children.size === 0 ? shownOf(cur.searched) : undefined })
426    linesOf(cur, depth + 1, key, t, out)
427  }
428  for (const k of kids.filter(c => !isFolderNode(c))) {
429    const row = k.file
430    if (!row) continue
431    const shown = shownOf(row)
432    const icon = shown === 'working' ? '▶' : shown === 'committed' ? '✓' : shown === 'changed' ? '●' : '·'
433    const when = shown === 'working' ? 'now' : ago(t - row.at)
434    out.push({
435      key: `${prefix}/${k.name}`, depth, text: `${icon} ${k.name}`, shown, isFolder: false,
436      tail: ` · ${stateWord(row)} · ${fit(row.by, 24)} · ${when}`,
437    })
438  }
439}
440
441export const register: Register = (on, options) => {
442  // The switch in plugin.json's userConfig; anything but an explicit false is on.
443  const showPane = options.showPane !== false
444
445  on('session.start', async ($, e, next) => {
446    await $.command.register({
447      name: 'files-panel',
448      description: 'Show the Files pane: every file Claude read, edited or wrote this session, and which are committed',
449    })
450    await update($, cwd, () => e.cwd)
451    const result = await next(e)
452    await fresh($)
453    return result
454  })
455
456  on('command.run', { command: 'files-panel' }, async $ => {
457    if (!showPane) {
458      return { text: 'The files panel is switched off. Turn "Show the files panel" back on in /config.' }
459    }
460    await update($, hasOpened, () => true)
461    await openPane($)
462    await checkAll($)
463    return { text: 'Files pane opened.' }
464  })
465
466  on('ui.close', { id: PANE }, async ($, e, next) => {
467    await update($, isOpen, () => false)
468    ticker?.cancel()
469    ticker = undefined
470    return next(e)
471  }).catch(($, e, next) => next(e))
472
473  // A /clear or a resume ends this conversation while the process goes on: drop
474  // its files now, so the pane never shows them in the next one.
475  on('session.end', async ($, e, next) => {
476    if (e.reason === 'clear' || e.reason === 'resume') {
477      try {
478        await forget($)
479        // '' is "not known yet": the next event adopts the new id without dropping.
480        await update($, sessionId, () => '')
481      } catch {}
482    }
483    return next(e)
484  })
485
486  // The owner's prompt is the main loop's first event after a /clear, even in a
487  // turn that touches no file.
488  on('prompt.submit', async ($, e, next) => {
489    await fresh($)
490    return next(e)
491  }).catch(($, e, next) => next(e))
492
493  on('agent.spawn', async ($, e, next) => {
494    await fresh($, e.parentAgentId)
495    const result = await next(e)
496    if (result.agentId) {
497      const id = result.agentId
498      await update($, helpers, h => ({ ...h, [id]: e.description || 'helper' }))
499    }
500    return result
501  }).catch(($, e, next) => next(e))
502
503  // Every tool call, main loop and helpers alike. A file call marks its file
504  // working until the call ends; a write, or any Bash call, asks git after.
505  on('tool.call', async ($, e, next) => {
506    if (!showPane) return next(e)
507    await fresh($, e.agentId)
508    const tool = String(e.tool)
509    if (tool === 'Bash') {
510      try {
511        return await next(e)
512      } finally {
513        await checkAll($)
514      }
515    }
516    const target = targetOf(tool, e as unknown as Record<string, unknown>)
517    if (!target) return next(e)
518    const base = await read($, cwd)
519    const path = normalize(target.raw || base || '/', base)
520    const by = await whoIs($, e.agentId)
521    if (by === undefined) return next(e)
522    const at = await $.clock.now()
523    await update($, files, all => {
524      const cur = all[path] ?? blank(path, target, by, at)
525      return { ...all, [path]: { ...cur, verb: target.verb, active: cur.active + 1, by, at } }
526    })
527    if (!(await read($, hasOpened))) {
528      await update($, hasOpened, () => true)
529      void openPane($)
530    }
531    let result: ToolCallResult | undefined
532    try {
533      result = await next(e)
534      return result
535    } finally {
536      const end = await $.clock.now()
537      const isOk = result !== undefined && result.deny === undefined && result.isError !== true
538      await update($, files, all => {
539        const cur = all[path]
540        if (!cur) return all
541        const hasWritten = cur.hasWritten || (target.isWrite && isOk)
542        return { ...all, [path]: { ...cur, active: Math.max(0, cur.active - 1), at: end, hasWritten } }
543      })
544      await place($, path)
545      if (target.isWrite && isOk) {
546        const row = (await read($, files))[path]
547        if (row?.root) await checkRepo($, row.root)
548      }
549    }
550  }).catch(($, e, next) => next(e))
551
552  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
553    const { Box, Text, Button } = $.ui.resolve(e)
554    if (!showPane) {
555      return <Text dimColor>The files panel is switched off in /config.</Text>
556    }
557    const all = Object.values(await read($, files))
558    // Read at every drawing, so after a /cd the line moves at the next redraw;
559    // the stored one if that fails.
560    let top = await read($, root)
561    try {
562      top = (await $.session.root()) || top
563    } catch {}
564    const rows = all.filter(r => isInside(r.path, top))
565    const others = all.filter(r => !isInside(r.path, top))
566    const isOthersShown = await read($, isOtherOpen)
567    const t = Math.max(await read($, now), await $.clock.now())
568    // The pane's padding takes 2 cells.
569    const cols = Math.max(20, (e.props?.bodyColumns ?? 40) - 2)
570    if (all.length === 0) {
571      return (
572        <Box flexDirection="column" paddingX={1}>
573          <Text dimColor>{fit('No files touched in this session yet.', cols)}</Text>
574        </Box>
575      )
576    }
577    const fileRows = rows.filter(r => !r.isFolder)
578    const count = (s: Shown) => fileRows.filter(r => shownOf(r) === s).length
579    const head = [
580      `${count('read')} read`, `${count('changed')} changed`, `${count('committed')} committed`,
581    ].join(' · ')
582    // What Claude is doing now, among the rows shown: a folded file stays folded
583    // here too, and the Other places label counts it instead.
584    const shown = isOthersShown ? all : rows
585    const working = shown.filter(r => r.active > 0).sort((a, b) => b.at - a.at)
586    const latest = [...shown].sort((a, b) => b.at - a.at)[0]
587    const nowLine = working[0]
588      ? withTail(`▶ ${working[0].verb} ${baseName(working[0].path)}`, ` · ${fit(working[0].by, 24)}${working.length > 1 ? ` · +${working.length - 1} more` : ''}`, cols)
589      : latest
590        ? withTail(`Last: ${baseName(latest.path)}`, ` · ${stateWord(latest)} · ${fit(latest.by, 24)} · ${ago(t - latest.at)}`, cols)
591        : ''
592
593    type Group = ReturnType<typeof groupsOf>[number]
594    const lines: Array<{ key: string; group?: Group; line?: Line }> = []
595    const addGroups = (list: FileRow[], prefix: string) => {
596      for (const g of groupsOf(list)) {
597        lines.push({ key: `${prefix}g:${g.label}`, group: g })
598        const out: Line[] = []
599        linesOf(g.top, 1, prefix + g.label, t, out)
600        for (const line of out) lines.push({ key: line.key, line })
601      }
602    }
603    addGroups(rows, '')
604    // How much is folded, and what of it is changed or busy, so a changed file
605    // outside the project is never hidden behind a bare count.
606    const otherFiles = others.filter(r => !r.isFolder)
607    const otherFolders = others.length - otherFiles.length
608    const many = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
609    const size = [otherFiles.length ? many(otherFiles.length, 'file') : '', otherFolders ? many(otherFolders, 'folder') : '']
610      .filter(Boolean).join(', ')
611    const busy = { working: 0, changed: 0, committed: 0, read: 0, searched: 0 }
612    for (const r of otherFiles) busy[shownOf(r)] += 1
613    const state = [busy.working ? `${busy.working} now` : '', busy.changed ? `${busy.changed} changed` : '']
614      .filter(Boolean).join(' · ')
615    const otherLabel = `${isOthersShown ? '▾' : '▸'} Other places · ${size}${state ? ` · ${state}` : ''} · outside ${fitPath(top, Math.max(8, cols - 60))}`
616    const otherLines = lines.length
617    if (isOthersShown) addGroups(others, 'other:')
618
619    const draw = (key: string, group: Group | undefined, line: Line | undefined) => {
620      if (group !== undefined) {
621        const counts = countLine(countsOf(group.top))
622        const searched = group.top.searched ? ' · searched' : ''
623        const tail = `${group.isGit ? '' : ' · no git'}${searched}${counts ? ` · ${counts}` : ''}`
624        const room = Math.max(Math.min(group.label.length, 8), cols - tail.length)
625        const label = fitPath(group.label, room)
626        return (
627          <Box key={key} marginTop={1}>
628            <Text bold wrap="truncate-end">{label}<Text dimColor>{fit(tail, Math.max(0, cols - label.length))}</Text></Text>
629          </Box>
630        )
631      }
632      const l = line!
633      const indent = '  '.repeat(l.depth)
634      const text = withTail(indent + l.text, l.tail, cols)
635      const color = l.shown === 'working' ? BLUE : l.shown === 'changed' ? ORANGE : l.shown === 'committed' ? GREEN : undefined
636      const isDim = l.isFolder ? l.shown !== 'working' : l.shown === 'read'
637      return (
638        <Box key={key}>
639          <Text wrap="truncate-end" color={color} bold={l.shown === 'working'} dimColor={isDim}>{text}</Text>
640        </Box>
641      )
642    }
643
644    return (
645      <Box flexDirection="column" paddingX={1}>
646        <Text bold>{fit(`Files · ${head}`, cols)}</Text>
647        <Text color={working[0] ? BLUE : undefined} bold={!!working[0]} dimColor={!working[0]}>{nowLine}</Text>
648        {rows.length === 0 && <Text dimColor>{fit('No files in this project yet.', cols)}</Text>}
649        {lines.slice(0, otherLines).map(({ key, group, line }) => draw(key, group, line))}
650        {others.length > 0 && (
651          <Box marginTop={1}>
652            <Button key="other-toggle" plain label={fit(otherLabel, cols)} onPress={() => update($, isOtherOpen, v => !v)} />
653          </Box>
654        )}
655        {lines.slice(otherLines).map(({ key, group, line }) => draw(key, group, line))}
656      </Box>
657    )
658  })
659}
660
types/index.d.ts 59 lines
1// What git says about a file Claude edited or wrote, as the last check read it.
2//   unknown    not checked yet (or the check failed)
3//   clean      tracked, and git status lists nothing for it: committed
4//   dirty      git status lists a change (staged or not, deleted included)
5//   untracked  git status lists it as ??
6//   untracked-ignored  not listed and not tracked: ignored, or gone
7//   no-git     the file is in no git repository
8export type GitState = 'unknown' | 'clean' | 'dirty' | 'untracked' | 'untracked-ignored' | 'no-git'
9
10// One path Claude touched this session: a file, or a folder Glob or Grep searched.
11export type FileRow = {
12  // The absolute path as the tool call named it (normalised).
13  path: string
14  // True for a folder a Glob or Grep searched.
15  isFolder: boolean
16  // True once the git repository holding it was looked up.
17  isResolved: boolean
18  // The repository root, as git prints it; '' outside any repository.
19  root: string
20  // The path inside the repository ('' for the root itself); outside one, the path.
21  rel: string
22  // True once an Edit, MultiEdit, NotebookEdit or Write on it succeeded.
23  hasWritten: boolean
24  git: GitState
25  // The verb of the latest call on it: reading, editing, writing, searching.
26  verb: string
27  // Calls on it still in flight.
28  active: number
29  // Who touched it last: 'main', or the helper's description.
30  by: string
31  // When it was last touched (start of a call, then its end).
32  at: number
33}
34
35// A folder's repository lookup: its root and its own path inside it ('a/b/').
36export type DirInfo = {
37  root: string
38  prefix: string
39}
40
41declare module 'claude-code' {
42  interface PluginState {
43    'files-panel': {
44      files: Record<string, FileRow>
45      dirs: Record<string, DirInfo>
46      helpers: Record<string, string>
47      now: number
48      cwd: string
49      hasOpened: boolean
50      isOpen: boolean
51      // The conversation the rows belong to; a new id clears them.
52      sessionId: string
53      // The project folder; files outside it are folded under Other places.
54      root: string
55      isOtherOpen: boolean
56    }
57  }
58}
59