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

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.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./thread-panel opens it too.files-panel: a live Files pane./files-panel opens the pane.The panes start over when you /clear or start a new conversation.

The diagram's source is docs/mods-flow.drawio.
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
Each mod has switches in /config:
agents-panelfiles-panelthread-panel/thread-panel says it is off.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.
MIT. See LICENSE.
hooks/register.tsx 660 lines1import { 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}
660types/index.d.ts 59 lines1// 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