SLOPSHOPPER

filetree

The project's file tree in a pane beside the chat, lit up as Claude reads, edits and commits files. Read-only.

newpaneguardcommandprocesstimer
★ 1v0.3.1MITupdated 2026-10-12samuelbfernandes/moddeck/mods/filetree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree
│ ┃ Filetree ✕ › fix the failing auth test and add an audit log call │ ┃ app/ [ Refresh ] │ ┃ package.json ⏺ Read(src/auth.ts) │ ┃ README.md ⎿ Read 6 lines │ ┃ src ⏺ Update(src/auth.ts) │ ┃ ● edited · ○ read · ✓ committed ⎿ 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 │ │ › /filetree │ ⎿ filetree: Filetree · open │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Filetree
app/ [ Refresh ] package.json README.md src ● edited · ○ read · ✓ committed
README

filetree

Install (Claude Code 2.1.287 or later): /plugin install filetree --marketplace samuelbfernandes/moddeck The project's file tree in a pane beside the chat, lit up as Claude works. Read-only: it never writes a file and never runs a git command that writes.

Use

  • It opens by itself at session start (setting autoOpen, default on) where the screen can dock a pane: the fullscreen terminal (/tui fullscreen, at least 144 columns) or the desktop app. Elsewhere it does nothing and says nothing. It opens once per session: after you close it, it stays closed.
  • Toggle it any time with /filetree. It runs locally, never sent to the model, no tokens. The reply is one line (Filetree · open, Filetree · closed); a second dim line appears only when it needs one.
  • It lists the working directory, 4 levels deep, at most 400 entries, folders collapsed (press a folder to open it). Hidden: .git, node_modules, target, renv, .Rproj.user, __pycache__, .venv, dist, build. Past the cap it says +N more. Refresh scans again; it also rescans (at most every 3 s) after Edit, Write, NotebookEdit or Bash calls while the pane is open.
  • Files Claude used light up: ○ cyan read (Read, and files named by Grep/Glob, up to 50 per call), ● yellow edited. A fresh one shimmers for 6 s, then keeps a steady colour. Folders above a lit file open once.
  • After a Bash call containing git commit, and every 5 s while the pane is open and an edit still waits, it runs git --no-optional-locks status --porcelain -z (and rev-parse --show-prefix). An edited file git no longer lists turns green ✓. Files only read never turn green.

Width

Best at 110 columns or more under /tui fullscreen. On the main screen, or under 40 columns of pane, it shows one line: Side panel · use /tui fullscreen (or Too narrow · widen the pane). The automatic open closes itself again there (and when the engine has no room for an unasked pane); a pane you open yourself with /filetree or tree stays.

Settings and turning off

autoOpen (true), maxDepth (4), maxEntries (400), extraIgnore (comma-separated names to hide too). /plugin disable filetree turns it off; autoOpen = false keeps the manual toggle only. Nothing is stored.

Caveats

  • It shows real file names: close it before screen-sharing. "Committed" means "not in git status any more", so a gitignored or discarded file turns green too. Outside a git repository nothing turns green.
  • Not verified in a live session: auto-open (tests use a mocked engine: whether the real engine docks the pane at session start, and the inline close on the main screen), the 500 ms shimmer and 5 s poll timers, how the dock scrolls 400 rows, the colours in the desktop app, and /filetree in the real prompt box. Not run on Windows.
  • Symbolic links are listed, never followed. Directories at the depth limit show (not scanned).
Source 3 files
hooks/register.tsx 316 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { FiletreeTouch } from '../types'
5import {
6  ancestorsOf,
7  dirHeat,
8  hasPendingEdits,
9  ignoreSet,
10  isCommitCommand,
11  isShimmering,
12  markCommitted,
13  mayChangeFiles,
14  parseStatus,
15  pathsOfCall,
16  relativeKey,
17  scanTree,
18  touch,
19  visibleRows,
20} from './model'
21
22const PANE = 'filetree'
23const TITLE = 'Filetree'
24const SHIMMER_TICK_MS = 500
25const POLL_MS = 5000
26const RESCAN_GAP_MS = 3000
27const MIN_COLUMNS = 40
28const HINT = 'Side panel · use /tui fullscreen'
29const FAILED = 'Filetree · could not read this folder'
30/** Surfaces that dock a pane beside the transcript; the others (a phone, an editor panel) are left alone. */
31const PANE_SURFACES = new Set(['terminal', 'desktop'])
32
33const tree = atom({ plugin: 'filetree', key: 'tree' } as const, null)
34const touched = atom({ plugin: 'filetree', key: 'touched' } as const, {})
35const expanded = atom({ plugin: 'filetree', key: 'expanded' } as const, {})
36const tick = atom({ plugin: 'filetree', key: 'tick' } as const, 0)
37// The one automatic open: 'new' until it is tried, 'checking' until the pane's first drawing says whether it docked,
38// then 'done' for the rest of the session. It never goes back, so a pane the person closes is not opened again.
39const autoOpen = atom({ plugin: 'filetree', key: 'autoOpen' } as const, 'new')
40
41type Dollar = EngineInterface
42type Heat = 'edit' | 'read' | 'done'
43
44function number(value: unknown, fallback: number): number {
45  return typeof value === 'number' && Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback
46}
47
48function settingsOf(options: PluginOptions) {
49  const extra = options.extraIgnore
50  const ignore = ignoreSet(typeof extra === 'string' ? extra : '')
51
52  return {
53    maxDepth: number(options.maxDepth, 4),
54    maxEntries: number(options.maxEntries, 400),
55    ignore,
56    isAutoOpen: options.autoOpen !== false,
57  }
58}
59
60/** The colors of a touched file; a shimmering one alternates two styles with the tick's parity. */
61function styleOf(entry: FiletreeTouch | undefined, now: number, phase: number) {
62  if (entry === undefined) return {}
63  if (entry.isCommitted) return { color: 'green' }
64  const color = entry.kind === 'edit' ? 'yellow' : 'cyan'
65  if (!isShimmering(entry, now)) return { color }
66
67  return phase === 0 ? { color, bold: true } : { color, dimColor: true }
68}
69
70function markerOf(entry: FiletreeTouch | undefined): string {
71  if (entry === undefined) return '  '
72  if (entry.isCommitted) return '✓ '
73
74  return entry.kind === 'edit' ? '● ' : '○ '
75}
76
77const HEAT_COLOR: Record<Heat, string> = { edit: 'yellow', read: 'cyan', done: 'green' }
78
79// Timer handles and the scan clock are not drawn state, and a reload drops the timers with the module, so
80// module variables are the right place. A function that takes `$` is declared here at the top, as the engine reads it.
81let settings = settingsOf({})
82let tickTimer: { cancel: () => void } | undefined
83let pollTimer: { cancel: () => void } | undefined
84let lastScanAt = -Infinity
85
86async function isPaneOpen($: Dollar): Promise<boolean> {
87  return (await $.ui.panes()).some(pane => pane.id === PANE)
88}
89
90async function rescan($: Dollar): Promise<void> {
91  lastScanAt = await $.clock.now()
92  const cwd = await $.session.cwd()
93  const scanned = await scanTree(path => $.fs.list(path), cwd, settings)
94  await update($, tree, () => scanned)
95}
96
97/** Runs the work in a timer's own dispatch, so a tool's result is never held up by it. */
98function defer($: Dollar, work: () => Promise<void>): void {
99  $.clock.after(1, () => {
100    work().catch(() => undefined)
101  })
102}
103
104/** What `/filetree` does: close the pane, or scan and open it.
105 * The text is a headline and at most one dim detail line. */
106async function toggleTree($: Dollar, isFullscreen: boolean): Promise<string> {
107  await update($, autoOpen, () => 'done') // the person has taken over: never open it for them after this
108  if (await isPaneOpen($)) {
109    await $.ui.close({ id: PANE })
110
111    return 'Filetree · closed'
112  }
113  await rescan($)
114  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: 44 })
115  if (hasPendingEdits(await read($, touched))) ensurePoll($)
116  if (!opened.isPlaced) return 'Filetree · waiting for room\nTry /tui fullscreen, or a wider window.'
117
118  return isFullscreen === false ? 'Filetree · open\nThe side panel needs /tui fullscreen.' : 'Filetree · open'
119}
120
121/**
122 * The automatic open, once per session, from a timer so session start never waits for it. Unasked, the engine
123 * places a pane only where the surface has room (144 terminal columns); where it does not, the pane is closed
124 * again so it cannot pop up later when the window is widened. Whether the layout docks it (fullscreen) is only
125 * known when the pane is first drawn: see `settleAutoOpen`. Nothing is scanned until it is known to be docked.
126 */
127async function autoOpenTree($: Dollar): Promise<void> {
128  if ((await read($, autoOpen)) !== 'new') return
129  await update($, autoOpen, () => 'done')
130  if (await isPaneOpen($)) return
131  const opened = await $.ui.open({ id: PANE, title: TITLE, columns: 44 })
132  if (opened.isPlaced) await update($, autoOpen, () => 'checking')
133  else await $.ui.close({ id: PANE })
134}
135
136/**
137 * First drawing of the automatically opened pane: keep it only if it is docked, else close it quietly. A drawing may
138 * not write state, so it only asks (from a timer) and this runs in the timer's own dispatch; a second ask finds 'done'.
139 */
140async function settleAutoOpen($: Dollar, placement: 'dock' | 'inline'): Promise<void> {
141  if ((await read($, autoOpen)) !== 'checking') return
142  await update($, autoOpen, () => 'done')
143  if (placement === 'dock') await rescan($)
144  else await $.ui.close({ id: PANE })
145}
146
147// ---- shimmer: a 500 ms tick that exists only while a touched file is still shimmering -------------------------
148
149function ensureTick($: Dollar): void {
150  if (tickTimer !== undefined) return
151  tickTimer = $.clock.every(SHIMMER_TICK_MS, () => {
152    onTick($).catch(() => undefined)
153  })
154}
155
156async function onTick($: Dollar): Promise<void> {
157  const now = await $.clock.now()
158  // Always bump the counter: the tick that finds nothing left to shimmer is the redraw that settles the styles.
159  await update($, tick, count => count + 1)
160  const map = await read($, touched)
161  if (Object.values(map).some(entry => isShimmering(entry, now))) return
162  tickTimer?.cancel()
163  tickTimer = undefined
164}
165
166// ---- commits: `git status` after a commit, and a slow poll while the pane is open ----------------------------
167
168function ensurePoll($: Dollar): void {
169  if (pollTimer !== undefined) return
170  pollTimer = $.clock.every(POLL_MS, () => {
171    onPoll($).catch(() => undefined)
172  })
173}
174
175async function onPoll($: Dollar): Promise<void> {
176  const isUseful = (await isPaneOpen($)) && hasPendingEdits(await read($, touched))
177  if (!isUseful) {
178    pollTimer?.cancel()
179    pollTimer = undefined
180
181    return
182  }
183  await refreshGit($)
184}
185
186/** Marks edited files that `git status` no longer lists. Read-only git; `--no-optional-locks` keeps even its index refresh from writing. */
187async function refreshGit($: Dollar): Promise<void> {
188  const git = ['git', '--no-optional-locks']
189  const prefix = await $.process.run([...git, 'rev-parse', '--show-prefix'], { timeoutMs: 10000 })
190  if (prefix.exitCode !== 0) return
191  const status = await $.process.run([...git, 'status', '--porcelain', '-z'], { timeoutMs: 10000 })
192  if (status.exitCode !== 0) return
193  const isModified = parseStatus(status.stdout, prefix.stdout.trim())
194  await update($, touched, map => markCommitted(map, isModified))
195}
196
197async function noteCall($: Dollar, tool: string, input: Record<string, unknown>, result: unknown): Promise<void> {
198  const cwd = await $.session.cwd()
199  const { kind, paths } = pathsOfCall(tool, input, result)
200  const keys = paths.flatMap(path => relativeKey(cwd, path, settings.ignore) ?? [])
201  if (keys.length > 0) await markTouched($, keys, kind)
202
203  if (tool === 'Bash' && isCommitCommand(String(input.command ?? ''))) defer($, () => refreshGit($))
204  if (mayChangeFiles(tool) && (await isPaneOpen($))) {
205    const now = await $.clock.now()
206    if (now - lastScanAt >= RESCAN_GAP_MS) defer($, () => rescan($))
207  }
208}
209
210async function markTouched($: Dollar, keys: string[], kind: FiletreeTouch['kind']): Promise<void> {
211  const now = await $.clock.now()
212  await update($, touched, map => keys.reduce((acc, key) => touch(acc, key, kind, now), map))
213  // Open the folders above a touched file so the light is visible, once; the person may collapse them again.
214  const parents = keys.flatMap(ancestorsOf)
215  await update($, expanded, map => ({ ...map, ...Object.fromEntries(parents.map(dir => [dir, true])) }))
216  ensureTick($)
217  if (kind === 'edit' && (await isPaneOpen($))) ensurePoll($)
218}
219
220export const register: Register = (on, options) => {
221  settings = settingsOf(options)
222
223  // ---- the commands ---------------------------------------------------------------------------------------------
224
225  on('session.start', async ($, e, next) => {
226    await $.command.register({ name: 'filetree', description: 'Show or hide the file tree beside the chat' })
227    // Only where a person is at a surface that docks panes; anywhere else this does nothing, silently.
228    if (settings.isAutoOpen && e.isInteractive && e.surface !== null && PANE_SURFACES.has(e.surface)) {
229      defer($, () => autoOpenTree($))
230    }
231
232    return next(e)
233  })
234
235  on('command.run', { command: 'filetree' }, async ($, e) => ({ text: await toggleTree($, e.presentation.isFullscreen) })).catch(
236    () => ({ text: FAILED }),
237  )
238
239  // ---- following Claude's tools ---------------------------------------------------------------------------------
240
241  // Fail open: this hook only watches. If it throws, the tool runs and its result reaches the model untouched.
242  on('tool.call', async ($, e, next) => {
243    const ran = await next(e)
244    if (ran.deny === undefined && ran.isError !== true) {
245      // A display problem must never surface as a skipped-hook notice in the middle of the work.
246      await noteCall($, String(e.tool), e as unknown as Record<string, unknown>, ran.result).catch(() => undefined)
247    }
248
249    return ran
250  }).catch(($, e, next) => next(e))
251
252  // ---- the pane -------------------------------------------------------------------------------------------------
253
254  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
255    const { Box, Button, Text } = $.ui.resolve(e)
256    if ((await read($, autoOpen)) === 'checking') defer($, () => settleAutoOpen($, e.props.placement))
257    const isInline = e.props.placement === 'inline' || e.viewport?.isFullscreen === false
258    if (e.surface === 'terminal' && isInline) return <Text dimColor>{HINT}</Text>
259    if (e.props.bodyColumns < MIN_COLUMNS) return <Text dimColor>Too narrow · widen the pane</Text>
260
261    const scanned = await read($, tree)
262    const marks = await read($, touched)
263    const open = await read($, expanded)
264    const phase = (await read($, tick)) % 2
265    const now = await $.clock.now()
266    const heat = dirHeat(marks)
267    const toggle = (dir: string) => () => update($, expanded, map => ({ ...map, [dir]: map[dir] !== true }))
268
269    if (scanned === null) {
270      return (
271        <Box flexDirection="column">
272          <Text dimColor>No tree yet</Text>
273          <Button key="refresh" label="Refresh" onPress={() => rescan($)} />
274        </Box>
275      )
276    }
277
278    const rows = visibleRows(scanned, open).map(row => {
279      const pad = '  '.repeat(row.depth)
280      if (row.type === 'more') return <Text dimColor>{pad}+{row.count} more</Text>
281      if (row.type === 'unscanned') return <Text dimColor>{pad}not scanned</Text>
282      if (row.type === 'file') {
283        const entry = marks[row.path]
284
285        return (
286          <Text {...styleOf(entry, now, phase)} wrap="truncate-end">
287            {pad}{markerOf(entry)}{row.name}
288          </Text>
289        )
290      }
291      const isOpen = open[row.path] === true
292      const dirHeatOfRow = isOpen ? undefined : heat[row.path]
293
294      return (
295        <Button key={`dir:${row.path}`} plain onPress={toggle(row.path)}>
296          {pad}{isOpen ? '▾ ' : '▸ '}{row.name}/
297          {dirHeatOfRow !== undefined && <Text color={HEAT_COLOR[dirHeatOfRow]}> {'•'}</Text>}
298        </Button>
299      )
300    })
301
302    return (
303      <Box flexDirection="column">
304        <Box>
305          <Text bold>{scanned.root.split('/').pop() || '/'}/ </Text>
306          <Button key="refresh" label="Refresh" onPress={() => rescan($)} />
307        </Box>
308        {rows}
309        {scanned.more > 0 && <Text dimColor>+{scanned.more} more</Text>}
310        {scanned.isCapped && scanned.more === 0 && <Text dimColor>+ more</Text>}
311        <Text dimColor>{'●'} edited · {'○'} read · {'✓'} committed</Text>
312      </Box>
313    )
314  })
315}
316
hooks/model.ts 246 lines
1import type { FsEntry } from 'claude-code'
2
3import type { FiletreeDir, FiletreeTouch, FiletreeTree } from '../types'
4
5export const DEFAULT_IGNORE = ['.git', 'node_modules', 'target', 'renv', '.Rproj.user', '__pycache__', '.venv', 'dist', 'build']
6
7/** How long a touched file shimmers before it settles to its steady style. */
8export const SHIMMER_MS = 6000
9/** Most touched paths kept; the oldest go first. */
10const MAX_TOUCHED = 500
11/** Most paths one Grep or Glob result may light up. */
12const MAX_SEARCH_PATHS = 50
13
14export type ScanOptions = { maxDepth: number; maxEntries: number; ignore: ReadonlySet<string> }
15export type Row =
16  | { type: 'dir' | 'file'; path: string; name: string; depth: number }
17  | { type: 'more' | 'unscanned'; path: string; name: string; depth: number; count: number }
18
19// ---- ignore list -------------------------------------------------------------------------------------------------
20
21export function ignoreSet(extra: string): Set<string> {
22  const names = extra.split(',').map(name => name.trim()).filter(name => name !== '')
23
24  return new Set([...DEFAULT_IGNORE, ...names])
25}
26
27/** True when any segment of a cwd-relative path is an ignored name (so it can never be in the tree). */
28export function isIgnoredPath(rel: string, ignore: ReadonlySet<string>): boolean {
29  return rel.split('/').some(part => ignore.has(part))
30}
31
32// ---- scanning ----------------------------------------------------------------------------------------------------
33
34function byKindThenName(a: FsEntry, b: FsEntry): number {
35  const rank = (entry: FsEntry) => (entry.kind === 'dir' ? 0 : 1)
36
37  return rank(a) - rank(b) || a.name.toLowerCase().localeCompare(b.name.toLowerCase())
38}
39
40/**
41 * Breadth-first scan, so the shallow entries win when the cap bites. A directory at `maxDepth` is shown but not
42 * listed; entries past `maxEntries` are only counted. A symbolic link is a plain entry and is never followed.
43 */
44export async function scanTree(
45  list: (absPath: string) => Promise<FsEntry[]>,
46  root: string,
47  options: ScanOptions,
48): Promise<FiletreeTree> {
49  const dirs: Record<string, FiletreeDir> = {}
50  const queue: string[] = ['']
51  let count = 0
52  let more = 0
53  let isCapped = false
54
55  for (let rel = queue.shift(); rel !== undefined; rel = queue.shift()) {
56    if (count >= options.maxEntries) {
57      isCapped = true
58      dirs[rel] = { entries: [], more: 0, isScanned: false }
59      continue
60    }
61    const found = await list(rel === '' ? root : `${root}/${rel}`).catch((): FsEntry[] => [])
62    const kept = found.filter(entry => !options.ignore.has(entry.name)).sort(byKindThenName)
63    const taken = kept.slice(0, options.maxEntries - count)
64    const entries = taken.map(entry => ({
65      name: entry.name,
66      path: rel === '' ? entry.name : `${rel}/${entry.name}`,
67      isDir: entry.kind === 'dir',
68    }))
69    dirs[rel] = { entries, more: kept.length - taken.length, isScanned: true }
70    count += taken.length
71    more += kept.length - taken.length
72    for (const entry of entries) {
73      if (!entry.isDir) continue
74      const level = entry.path.split('/').length
75      if (level < options.maxDepth) queue.push(entry.path)
76      else dirs[entry.path] = { entries: [], more: 0, isScanned: false }
77    }
78  }
79
80  return { root, dirs, more, isCapped }
81}
82
83/** The rows the pane draws, depth first: a directory's children only while it is expanded. */
84export function visibleRows(tree: FiletreeTree, expanded: Readonly<Record<string, boolean>>): Row[] {
85  const rows: Row[] = []
86  const walk = (dir: string, depth: number) => {
87    const info = tree.dirs[dir]
88    if (info === undefined) return
89    for (const entry of info.entries) {
90      rows.push({ type: entry.isDir ? 'dir' : 'file', path: entry.path, name: entry.name, depth })
91      if (!entry.isDir || expanded[entry.path] !== true) continue
92      const child = tree.dirs[entry.path]
93      if (child !== undefined && !child.isScanned) {
94        rows.push({ type: 'unscanned', path: entry.path, name: 'not scanned', depth: depth + 1, count: 0 })
95      }
96      walk(entry.path, depth + 1)
97    }
98    if (info.more > 0) rows.push({ type: 'more', path: dir, name: 'more', depth, count: info.more })
99  }
100  walk('', 0)
101
102  return rows
103}
104
105// ---- touched files -----------------------------------------------------------------------------------------------
106
107/**
108 * Turns an absolute (or cwd-relative) path into the tree's key, or undefined when it lies outside the directory or
109 * inside an ignored folder.
110 */
111export function relativeKey(cwd: string, path: string, ignore: ReadonlySet<string>): string | undefined {
112  const base = cwd.replace(/\/+$/, '')
113  const rel = path.startsWith('/') ? (path.startsWith(`${base}/`) ? path.slice(base.length + 1) : undefined) : path.replace(/^(\.\/)+/, '')
114  if (rel === undefined || rel === '' || rel.startsWith('../') || isIgnoredPath(rel, ignore)) return undefined
115
116  return rel
117}
118
119/** A read never un-commits an edit; an edit always does. A path ever edited stays an edit. */
120export function touch(
121  map: Readonly<Record<string, FiletreeTouch>>,
122  rel: string,
123  kind: FiletreeTouch['kind'],
124  now: number,
125): Record<string, FiletreeTouch> {
126  const prev = map[rel]
127  const isEdit = kind === 'edit'
128  const next: FiletreeTouch = {
129    kind: isEdit || prev?.kind === 'edit' ? 'edit' : 'read',
130    at: now,
131    isCommitted: !isEdit && prev?.isCommitted === true,
132  }
133  const merged = { ...map, [rel]: next }
134  const keys = Object.keys(merged)
135  if (keys.length <= MAX_TOUCHED) return merged
136  const oldest = keys.sort((a, b) => (merged[a]?.at ?? 0) - (merged[b]?.at ?? 0)).slice(0, keys.length - MAX_TOUCHED)
137  for (const key of oldest) delete merged[key]
138
139  return merged
140}
141
142export function isShimmering(entry: FiletreeTouch, now: number): boolean {
143  return !entry.isCommitted && now - entry.at < SHIMMER_MS
144}
145
146/** Edited files not yet seen committed: the only ones a git poll can change. */
147export function hasPendingEdits(map: Readonly<Record<string, FiletreeTouch>>): boolean {
148  return Object.values(map).some(entry => entry.kind === 'edit' && !entry.isCommitted)
149}
150
151export function markCommitted(
152  map: Readonly<Record<string, FiletreeTouch>>,
153  isModified: (rel: string) => boolean,
154): Record<string, FiletreeTouch> {
155  const next = { ...map }
156  for (const [rel, entry] of Object.entries(map)) {
157    if (entry.kind === 'edit' && !entry.isCommitted && !isModified(rel)) next[rel] = { ...entry, isCommitted: true }
158  }
159
160  return next
161}
162
163/** What a collapsed directory shows for the files below it: an uncommitted edit, else a read, else only commits. */
164export function dirHeat(map: Readonly<Record<string, FiletreeTouch>>): Record<string, 'edit' | 'read' | 'done'> {
165  const rank = { done: 0, read: 1, edit: 2 } as const
166  const heat: Record<string, 'edit' | 'read' | 'done'> = {}
167  for (const [rel, entry] of Object.entries(map)) {
168    const level = entry.isCommitted ? 'done' : entry.kind
169    const parts = rel.split('/').slice(0, -1)
170    for (let i = 1; i <= parts.length; i++) {
171      const dir = parts.slice(0, i).join('/')
172      const prev = heat[dir]
173      if (prev === undefined || rank[level] > rank[prev]) heat[dir] = level
174    }
175  }
176
177  return heat
178}
179
180/** Every ancestor directory of a path, shallowest first: what a touch expands so the file is visible. */
181export function ancestorsOf(rel: string): string[] {
182  const parts = rel.split('/').slice(0, -1)
183
184  return parts.map((_, i) => parts.slice(0, i + 1).join('/'))
185}
186
187// ---- tool calls --------------------------------------------------------------------------------------------------
188
189const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
190const SEARCH_TOOLS = new Set(['Grep', 'Glob'])
191
192/** Edits and Bash may create files, so the tree is scanned again after them. */
193export function mayChangeFiles(tool: string): boolean {
194  return EDIT_TOOLS.has(tool) || tool === 'Bash'
195}
196
197/** The paths one finished tool call used, as the tool spelled them. Other tools give none. */
198export function pathsOfCall(tool: string, input: Readonly<Record<string, unknown>>, result: unknown): { kind: FiletreeTouch['kind']; paths: string[] } {
199  const str = (value: unknown) => (typeof value === 'string' && value !== '' ? [value] : [])
200  if (tool === 'Read') return { kind: 'read', paths: str(input.file_path) }
201  if (tool === 'NotebookEdit') return { kind: 'edit', paths: str(input.notebook_path) }
202  if (EDIT_TOOLS.has(tool)) return { kind: 'edit', paths: str(input.file_path) }
203  if (SEARCH_TOOLS.has(tool)) {
204    const names = (result as { filenames?: unknown } | undefined)?.filenames
205
206    return { kind: 'read', paths: Array.isArray(names) ? names.flatMap(str).slice(0, MAX_SEARCH_PATHS) : [] }
207  }
208
209  return { kind: 'read', paths: [] }
210}
211
212/** `git commit` anywhere in a Bash command, also with options before the verb (`git -C dir commit`). */
213export function isCommitCommand(command: string): boolean {
214  return /\bgit\b[^;&|\n]*\bcommit\b/.test(command)
215}
216
217// ---- git status --------------------------------------------------------------------------------------------------
218
219/**
220 * Reads `git status --porcelain -z` output. Paths are repository-root relative, so `prefix` (what
221 * `git rev-parse --show-prefix` says, '' at the root) is cut off; an entry ending in '/' is an untracked
222 * directory and stands for everything below it.
223 */
224export function parseStatus(stdout: string, prefix: string): (rel: string) => boolean {
225  const files = new Set<string>()
226  const dirs: string[] = []
227  const add = (path: string) => {
228    const isDir = path.endsWith('/')
229    // An untracked directory at or above the working directory covers all of it.
230    if (isDir && prefix.startsWith(path)) dirs.push('')
231    else if (!path.startsWith(prefix)) return
232    else if (isDir) dirs.push(path.slice(prefix.length))
233    else files.add(path.slice(prefix.length))
234  }
235  const entries = stdout.split('\0')
236  for (let i = 0; i < entries.length; i++) {
237    const entry = entries[i] ?? ''
238    if (entry.length < 4) continue
239    add(entry.slice(3))
240    // A rename or copy is followed by its origin as an entry of its own.
241    if (/[RC]/.test(entry.slice(0, 2))) add(entries[++i] ?? '')
242  }
243
244  return rel => files.has(rel) || dirs.some(dir => rel.startsWith(dir))
245}
246
types/index.d.ts 31 lines
1export type FiletreeEntry = { name: string; path: string; isDir: boolean }
2
3/** One scanned directory: its (capped) entries, how many were left out, whether it was listed at all. */
4export type FiletreeDir = { entries: FiletreeEntry[]; more: number; isScanned: boolean }
5
6/** The scanned tree: directories by path relative to the session's directory ('' is the root). */
7export type FiletreeTree = {
8  root: string
9  dirs: Record<string, FiletreeDir>
10  /** Entries dropped by the cap, summed over every directory. */
11  more: number
12  /** True when the cap stopped the scan before every directory was listed. */
13  isCapped: boolean
14}
15
16/** A path Claude used: read (Read, Grep, Glob) or edit (Edit, Write, MultiEdit, NotebookEdit). */
17export type FiletreeTouch = { kind: 'read' | 'edit'; at: number; isCommitted: boolean }
18
19declare module 'claude-code' {
20  interface PluginState {
21    filetree: {
22      tree: FiletreeTree | null
23      touched: Record<string, FiletreeTouch>
24      expanded: Record<string, boolean>
25      tick: number
26      /** The one automatic open: not tried yet, waiting for the pane's first drawing, or settled for the session. */
27      autoOpen: 'new' | 'checking' | 'done'
28    }
29  }
30}
31