SLOPSHOPPER

filetree

A live file-tree pane of the project that marks the files Claude read, changed or created this session.

newpaneguardcommandtimer
v0.1.0no licenseupdated 2026-10-10gauravruhela07/claude-mods/filetree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree
│ ┃ Files ✕ › fix the failing auth test and add an audit log call │ ┃ app· 1 changed · 2 new · 0 read │ ┃ [ Only touched ] [ Collapse all ] [ Refresh ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ package.json ⏺ Update(src/auth.ts) │ ┃ README.md ⎿ Added 2 lines, removed 1 line │ ┃ src ⏺ 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: File tree opened (all files). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files
app· 1 changed · 2 new · 0 read [ Only touched ] [ Collapse all ] [ Refresh ] package.json README.md src
README

claude-mods

Seven mods for Claude Code. Each one is a plugin of function hooks: it draws panes, status lines and toasts inside Claude Code, in the terminal and in the desktop app's Code tab.

ModWhat it doesHow to use it
savvy-progressA live band above the prompt while Claude works: what it is doing now ("Reading format.ts · 4s"), elapsed time, steps, files read and changed, errors, and a progress bar: the real task list when Claude keeps one, otherwise a time bar guessed from how long your past turns took (clearly labelled a guess). A one-line summary after each turn.Shows by itself. `/savvy-progress [on\off]`
skinsRe-skins the spinner (glyphs, verbs, color), the turn's closing line and the prompt hints. Skins: matrix, pirate, zen, retro, space. Your choice is remembered./skin (picker), /skin matrix, /skin off
filetreeA live file-tree pane of the project. Marks the files Claude read (R), changed (M) and created (A) this session, and opens the folders they live in./filetree, /filetree touched, /filetree all, /filetree close
cache-taxHow well the prompt cache works, and the "tax": the money paid above the ideal where every input token is a cache read. Status line cache 94% · tax $0.12, a per-turn table, and a toast with the likely cause when the cache busts./cache-tax, /cache-tax reset
blast-radiusFor each file Claude edits, the other files that import it; a flag on wide-reaching shell commands (git reset --hard, rm -rf, force push, ...). Shows only, never blocks./blast-radius, /blast-radius session, /blast-radius clear
reflectClaude critiques its own recent work: what may be wrong or missed, risky assumptions, what to check next. One button puts the follow-ups in your prompt box (it never sends them). Optional auto mode, off by default./reflect [focus], /reflect show, `/reflect auto on\off`
replay-theatreRecords the session and plays it back like a film: scene by scene, step by step, with play/pause, step, speed and a timeline./replay, /replay last, /replay copy; keys k play, j/l step, h/n scene, s speed, r restart

Install

In a Claude Code terminal session, install any mod by name:

/plugin install savvy-progress --marketplace gauravruhela07/claude-mods

Answer y to add the marketplace, then pick a scope (user scope loads it in every session). Repeat for each mod you want: skins, filetree, cache-tax, blast-radius, reflect, replay-theatre.

A mod installed from a terminal at the user scope also loads in the desktop app's Code tab.

Try one from a clone, without installing

git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree

Notes

  • Cost: only reflect calls the model, and only when you run /reflect or turn auto mode on. It asks over the session's own transcript, so most of it is served from the prompt cache.
  • cache-tax prices are first-party API list prices as of 2026-10-11, in cache-tax/hooks/prices.ts. Claude Code does not report 5-minute and 1-hour cache writes apart, so all writes use the 5-minute price.
  • blast-radius finds dependents with a text search (rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.
  • These mods use the Claude Code function-hooks API, which is early access and can change between releases. They were built and tested on Claude Code 2.1.29x.

Develop

Each mod folder holds .claude-plugin/plugin.json, hooks/ (the hooks module), types/index.d.ts (its state contract) and tests/.

claude plugin validate filetree
claude plugin test filetree
Source 3 files
hooks/register.tsx 322 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Listing, Mark, Mode } from '../types'
5import {
6  MAX_ROWS,
7  baseName,
8  buildView,
9  clip,
10  clipStart,
11  isIgnored,
12  join,
13  normalize,
14  parents,
15  relative,
16  resolve,
17  stronger,
18  toListing,
19} from './tree'
20import type { Loose, Row } from './tree'
21
22const PANE = 'filetree'
23const TITLE = 'Files'
24const USAGE = 'Use /filetree (open), /filetree touched, /filetree all, or /filetree close. Nothing changed.'
25const TRACKED = new Set(['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
26
27const root = atom({ plugin: 'filetree', key: 'root' } as const, '')
28const mode = atom({ plugin: 'filetree', key: 'mode' } as const, 'all')
29const expanded = atom({ plugin: 'filetree', key: 'expanded' } as const, [])
30const listings = atom({ plugin: 'filetree', key: 'listings' } as const, {})
31const touched = atom({ plugin: 'filetree', key: 'touched' } as const, {})
32
33type Engine = EngineInterface
34
35const text = (value: unknown): string => (typeof value === 'string' ? value : '')
36
37/** Lists one folder; a folder that cannot be listed comes back with its error. */
38const fetchListing = async ($: Engine, top: string, rel: string): Promise<Listing> => {
39  try {
40    return toListing(await $.fs.list(join(top, rel)))
41  } catch {
42    return { entries: [], more: 0, error: 'Cannot list this folder' }
43  }
44}
45
46/** Loads the listings of these folders that are not cached yet. */
47const ensureLoaded = async ($: Engine, dirs: readonly string[]): Promise<void> => {
48  const top = await read($, root)
49  if (top === '') return
50  const have = await read($, listings)
51  const fresh: Record<string, Listing> = {}
52
53  for (const rel of new Set(dirs)) {
54    if (have[rel] === undefined && fresh[rel] === undefined) fresh[rel] = await fetchListing($, top, rel)
55  }
56  if (Object.keys(fresh).length > 0) await update($, listings, all => ({ ...all, ...fresh }))
57}
58
59/** Opens a closed folder (loading it the first time) or closes an open one. */
60const toggle = async ($: Engine, rel: string): Promise<void> => {
61  let isOpening = false
62
63  await update($, expanded, list => {
64    isOpening = !list.includes(rel)
65    return isOpening ? [...list, rel] : list.filter(one => one !== rel)
66  })
67  if (isOpening) await ensureLoaded($, [rel])
68}
69
70/** Reloads the root and every open folder; other cached listings are dropped and load again when opened. */
71const refresh = async ($: Engine): Promise<void> => {
72  const top = await read($, root)
73  if (top === '') return
74  const open = await read($, expanded)
75  const fresh: Record<string, Listing> = {}
76
77  for (const rel of new Set(['', ...open])) fresh[rel] = await fetchListing($, top, rel)
78  await update($, listings, () => fresh)
79}
80
81/** The mark a successful call leaves, or null when it left none (a Write held for review). */
82const markOf = (tool: string, result: unknown, existed: boolean): Mark | null => {
83  if (tool === 'Read') return 'R'
84  if (tool !== 'Write') return 'M'
85
86  const record = (typeof result === 'object' && result !== null ? result : {}) as { type?: unknown; staged?: unknown }
87
88  if (record.staged === true) return null
89  if (record.type === 'create') return 'A'
90  if (record.type === 'update') return 'M'
91
92  return existed ? 'M' : 'A'
93}
94
95// Folders to load after a touch, drained by the timer so tool calls never wait on a listing.
96// Module state: a hot reload drops what was queued, and the folders load when next opened.
97let queue: string[] = []
98let isDraining = false
99
100const drain = async ($: Engine): Promise<void> => {
101  if (isDraining || queue.length === 0) return
102  isDraining = true
103  try {
104    while (queue.length > 0) {
105      const batch = queue
106      queue = []
107      await ensureLoaded($, batch)
108    }
109  } catch {
110    // A failed load is retried the next time the folder is opened or refreshed.
111  } finally {
112    isDraining = false
113  }
114}
115
116export const register: Register = on => {
117  on('session.start', async ($, e, next) => {
118    const top = normalize(e.cwd)
119    let isMoved = false
120
121    await update($, root, was => {
122      isMoved = was !== top
123      return top
124    })
125    if (isMoved) {
126      await update($, listings, () => ({}))
127      await update($, expanded, () => [])
128    }
129    await $.command.register({
130      name: 'filetree',
131      description: 'Show the project file tree, marking files Claude read (R), changed (M) or created (A)',
132      argumentHint: '[touched|all|close]',
133      immediate: true,
134    })
135    $.clock.every(250, () => void drain($))
136
137    return next(e)
138  })
139
140  on('session.end', async ($, e, next) => {
141    if (e.reason === 'clear') {
142      queue = []
143      await update($, touched, () => ({}))
144    }
145
146    return next(e)
147  })
148
149  on('command.run', { command: 'filetree' }, async ($, e) => {
150    const word = e.args.trim().toLowerCase()
151
152    if (word !== '' && word !== 'touched' && word !== 'all' && word !== 'close') return { text: USAGE }
153
154    if (word === 'close') {
155      try {
156        await $.ui.close({ id: PANE })
157      } catch {
158        return { text: 'The file tree stays open.' }
159      }
160      return { text: 'File tree closed.' }
161    }
162
163    if (word === 'touched' || word === 'all') {
164      const wanted: Mode = word
165      await update($, mode, () => wanted)
166    }
167    await ensureLoaded($, ['', ...(await read($, expanded))])
168
169    const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
170    const shown = (await read($, mode)) === 'touched' ? 'only touched files' : 'all files'
171
172    return {
173      text: opened.isPlaced ? `File tree opened (${shown}).` : `File tree opened (${shown}); ${opened.reason}`,
174    }
175  })
176
177  on('tool.call', async ($, e, next) => {
178    const tool = String(e.tool)
179    if (!TRACKED.has(tool)) return next(e)
180
181    const input = e as unknown as Record<string, unknown>
182    const raw = text(input.file_path) || text(input.notebook_path)
183    if (raw === '') return next(e)
184
185    const top = await read($, root)
186    const path = resolve(top, raw)
187    const existed = tool === 'Write' ? await $.fs.stat(path).then(() => true, () => false) : true
188    const ran = await next(e)
189
190    if (ran.deny !== undefined || ran.isError === true) return ran
191    const mark = markOf(tool, ran.result, existed)
192    if (mark === null) return ran
193
194    await update($, touched, all => ({ ...all, [path]: stronger(all[path], mark) }))
195
196    const rel = relative(top, path)
197    if (rel !== undefined && rel !== '' && !isIgnored(rel)) {
198      const dirs = parents(rel)
199      const open = await read($, expanded)
200      const missing = dirs.filter(dir => !open.includes(dir))
201
202      if (missing.length > 0) {
203        await update($, expanded, list => [...list, ...missing.filter(dir => !list.includes(dir))])
204      }
205      queue.push('', ...dirs)
206    }
207
208    return ran
209  }).catch(($, e, next) => next(e))
210
211  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
212    const { Box, Text, Button } = $.ui.resolve(e)
213    const top = await read($, root)
214    const shown = await read($, mode)
215    const view = buildView(
216      top,
217      shown,
218      await read($, expanded),
219      await read($, listings),
220      await read($, touched),
221      MAX_ROWS,
222    )
223    const width = Math.max(20, e.props.bodyColumns)
224
225    const tone = (mark: Mark | null) =>
226      mark === 'M' ? { color: 'warning' } : mark === 'A' ? { color: 'success' } : mark === 'R' ? { dimColor: true } : {}
227
228    const line = (row: Row) => {
229      const pad = row.depth * 2
230      const room = Math.max(4, width - pad - 2)
231
232      if (row.kind === 'dir') {
233        const tail = row.count > 0 ? ` ${row.count}` : ''
234
235        return (
236          <Box key={row.key} flexDirection="row" paddingLeft={pad}>
237            <Button
238              key={`dir:${row.rel}`}
239              plain
240              label={`${row.isOpen ? '▾' : '▸'} ${clip(`${row.name}/`, room - tail.length)}`}
241              onPress={() => toggle($, row.rel)}
242            />
243            {tail !== '' && <Text color="warning">{tail}</Text>}
244          </Box>
245        )
246      }
247
248      if (row.kind === 'file') {
249        return (
250          <Box key={row.key} flexDirection="row" paddingLeft={pad}>
251            <Text {...tone(row.mark)} bold={row.mark === 'M' || row.mark === 'A'}>
252              {row.mark ?? ' '}{' '}
253            </Text>
254            <Text {...tone(row.mark)} wrap="truncate-end">
255              {clip(row.name, room)}
256            </Text>
257          </Box>
258        )
259      }
260
261      return (
262        <Box key={row.key} flexDirection="row" paddingLeft={pad + 2}>
263          <Text dimColor wrap="truncate-end">
264            {clip(row.text, Math.max(4, width - pad - 2))}
265          </Text>
266        </Box>
267      )
268    }
269
270    const group = (key: string, title: string, list: Loose[]) =>
271      list.length === 0 ? null : (
272        <Box key={key} flexDirection="column" marginTop={1}>
273          <Text bold dimColor>
274            {clip(`${title} (${list.length})`, width)}
275          </Text>
276          {list.map(one => (
277            <Box key={`${key}:${one.path}`} flexDirection="row">
278              <Text {...tone(one.mark)} bold={one.mark !== 'R'}>
279                {one.mark}{' '}
280              </Text>
281              <Text {...tone(one.mark)} wrap="truncate-start">
282                {clipStart(one.path, width - 2)}
283              </Text>
284            </Box>
285          ))}
286        </Box>
287      )
288
289    const counts = ` · ${view.changed} changed · ${view.added} new · ${view.read} read`
290    const name = clip(baseName(top) || 'project', Math.max(6, width - counts.length))
291    const isEmpty = view.rows.length === 0
292
293    return (
294      <Box flexDirection="column">
295        <Box key="header" flexDirection="row">
296          <Text bold>{name}</Text>
297          <Text dimColor wrap="truncate-end">
298            {clip(counts, Math.max(0, width - name.length))}
299          </Text>
300        </Box>
301        <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={1} marginBottom={1}>
302          <Button
303            key="mode"
304            label={shown === 'all' ? 'Only touched' : 'Show all'}
305            onPress={() => update($, mode, was => (was === 'all' ? 'touched' : 'all'))}
306          />
307          <Button key="collapse" label="Collapse all" onPress={() => update($, expanded, () => [])} />
308          <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
309        </Box>
310        {top === '' && <Text dimColor>Waiting for the session to start…</Text>}
311        {top !== '' && isEmpty && shown === 'touched' && (
312          <Text dimColor>{clip('Nothing in the project touched yet.', width)}</Text>
313        )}
314        {view.rows.map(line)}
315        {view.isCut && <Text dimColor>{clip(`… stopped at ${MAX_ROWS} rows; collapse some folders`, width)}</Text>}
316        {group('outside', 'Outside project', view.outside)}
317        {group('ignored', 'In ignored folders', view.ignored)}
318      </Box>
319    )
320  })
321}
322
hooks/tree.ts 232 lines
1import type { Entry, Listing, Mark, Mode } from '../types'
2
3/** Entries kept per folder listing; the rest is a "… N more" row. */
4export const CAP = 200
5
6/** Rows drawn at most, so a huge expanded tree stays light. */
7export const MAX_ROWS = 1500
8
9/** Names never shown as tree rows. */
10export const IGNORED = new Set([
11  '.git',
12  'node_modules',
13  'dist',
14  'build',
15  '.next',
16  '__pycache__',
17  '.venv',
18  'target',
19  '.DS_Store',
20])
21
22const DRIVE = /^[A-Za-z]:\//
23
24/** Forward slashes, no empty, `.` or `..` segments; keeps a leading `/` or `C:/`. */
25export const normalize = (path: string): string => {
26  const slashed = path.replace(/\\/g, '/')
27  const prefix = DRIVE.test(slashed) ? slashed.slice(0, 3) : slashed.startsWith('/') ? '/' : ''
28  const parts: string[] = []
29
30  for (const seg of slashed.slice(prefix.length).split('/')) {
31    if (seg === '' || seg === '.') continue
32    if (seg === '..') {
33      if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
34      else if (prefix === '') parts.push('..')
35      continue
36    }
37    parts.push(seg)
38  }
39
40  return prefix + parts.join('/')
41}
42
43const isAbsolute = (path: string): boolean => /^([\\/]|[A-Za-z]:[\\/])/.test(path)
44
45/** An absolute, normalized path for what a tool named, relative ones taken under `root`. */
46export const resolve = (root: string, path: string): string =>
47  normalize(isAbsolute(path) || root === '' ? path : `${root}/${path}`)
48
49/** The path under `root` ('' for the root itself), or undefined when it lies outside. */
50export const relative = (root: string, abs: string): string | undefined => {
51  if (root === '') return undefined
52  if (abs === root) return ''
53  const base = root.endsWith('/') ? root : `${root}/`
54
55  return abs.startsWith(base) ? abs.slice(base.length) : undefined
56}
57
58/** The absolute path of a folder given by its path under `root`. */
59export const join = (root: string, rel: string): string =>
60  rel === '' ? root : root.endsWith('/') ? `${root}${rel}` : `${root}/${rel}`
61
62export const baseName = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
63
64/** True when any folder on the path is one the tree never shows. */
65export const isIgnored = (rel: string): boolean => rel.split('/').some(seg => IGNORED.has(seg))
66
67/** The folders above a path under the root, outermost first, the root ('') left out. */
68export const parents = (rel: string): string[] => {
69  const segs = rel.split('/')
70  const out: string[] = []
71
72  for (let i = 1; i < segs.length; i++) out.push(segs.slice(0, i).join('/'))
73
74  return out
75}
76
77const compare = (a: string, b: string): number => {
78  const x = a.toLowerCase()
79  const y = b.toLowerCase()
80
81  if (x !== y) return x < y ? -1 : 1
82
83  return a < b ? -1 : a > b ? 1 : 0
84}
85
86/** Folders first, then files, each alphabetical (case folded). */
87export const sortEntries = (entries: readonly Entry[]): Entry[] =>
88  [...entries].sort((a, b) => (a.isDir !== b.isDir ? (a.isDir ? -1 : 1) : compare(a.name, b.name)))
89
90/** What the tree keeps of a raw `$.fs.list` answer. */
91export const toListing = (raw: readonly { name: string; kind: string }[]): Listing => {
92  const kept = sortEntries(
93    raw.filter(one => !IGNORED.has(one.name)).map(one => ({ name: one.name, isDir: one.kind === 'dir' })),
94  )
95
96  return { entries: kept.slice(0, CAP), more: Math.max(0, kept.length - CAP), error: null }
97}
98
99const RANK: Record<Mark, number> = { R: 1, M: 2, A: 3 }
100
101/** The mark that stays: added over modified over read. */
102export const stronger = (was: Mark | undefined, now: Mark): Mark =>
103  was !== undefined && RANK[was] >= RANK[now] ? was : now
104
105/** One line, cut to `max` cells with an ellipsis at the end. */
106export const clip = (text: string, max: number): string => {
107  const line = text.replace(/\s+/g, ' ').trim()
108
109  if (max <= 1) return line.slice(0, Math.max(0, max))
110
111  return line.length > max ? `${line.slice(0, max - 1)}…` : line
112}
113
114/** One line, cut to `max` cells from the start, so a path keeps its file name. */
115export const clipStart = (text: string, max: number): string => {
116  if (max <= 1) return text.slice(-Math.max(0, max))
117
118  return text.length > max ? `…${text.slice(text.length - max + 1)}` : text
119}
120
121export type Row =
122  | { kind: 'dir'; key: string; rel: string; name: string; depth: number; isOpen: boolean; count: number }
123  | { kind: 'file'; key: string; rel: string; name: string; depth: number; mark: Mark | null }
124  | { kind: 'note'; key: string; depth: number; text: string }
125
126export type Loose = { path: string; mark: Mark }
127
128export type View = {
129  rows: Row[]
130  outside: Loose[]
131  ignored: Loose[]
132  changed: number
133  added: number
134  read: number
135  isCut: boolean
136}
137
138/** The rows the pane draws, from what the state holds. */
139export const buildView = (
140  root: string,
141  mode: Mode,
142  expanded: readonly string[],
143  listings: Readonly<Record<string, Listing>>,
144  touched: Readonly<Record<string, Mark>>,
145  maxRows: number,
146): View => {
147  const open = new Set(expanded)
148  const view: View = { rows: [], outside: [], ignored: [], changed: 0, added: 0, read: 0, isCut: false }
149  const marks = new Map<string, Mark>()
150  const tally = new Map<string, number>()
151  const extra = new Map<string, Map<string, boolean>>()
152
153  for (const [path, mark] of Object.entries(touched)) {
154    if (mark === 'M') view.changed += 1
155    else if (mark === 'A') view.added += 1
156    else view.read += 1
157
158    const rel = relative(root, path)
159
160    if (rel === undefined || rel === '') {
161      view.outside.push({ path, mark })
162      continue
163    }
164    if (isIgnored(rel)) {
165      view.ignored.push({ path: rel, mark })
166      continue
167    }
168
169    marks.set(rel, mark)
170    const segs = rel.split('/')
171
172    for (let i = 0; i < segs.length; i++) {
173      const dir = segs.slice(0, i).join('/')
174      const kids = extra.get(dir) ?? new Map<string, boolean>()
175
176      tally.set(dir, (tally.get(dir) ?? 0) + 1)
177      kids.set(segs[i] ?? '', i < segs.length - 1)
178      extra.set(dir, kids)
179    }
180  }
181
182  const push = (row: Row): boolean => {
183    if (view.rows.length >= maxRows) {
184      view.isCut = true
185      return false
186    }
187    view.rows.push(row)
188    return true
189  }
190
191  const walk = (dir: string, depth: number): void => {
192    const listing = listings[dir]
193    const entries = [...(listing?.entries ?? [])]
194    const names = new Set(entries.map(one => one.name))
195
196    for (const [name, isDir] of extra.get(dir) ?? []) {
197      if (!names.has(name)) entries.push({ name, isDir })
198    }
199
200    if (mode === 'all') {
201      if (listing === undefined && entries.length === 0) push({ kind: 'note', key: `note:${dir}`, depth, text: 'Loading…' })
202      else if (listing?.error) push({ kind: 'note', key: `note:${dir}`, depth, text: listing.error })
203      else if (entries.length === 0) push({ kind: 'note', key: `note:${dir}`, depth, text: '(empty)' })
204    }
205
206    for (const entry of sortEntries(entries)) {
207      if (view.isCut) return
208      const rel = dir === '' ? entry.name : `${dir}/${entry.name}`
209
210      if (entry.isDir) {
211        const count = tally.get(rel) ?? 0
212        if (mode === 'touched' && count === 0) continue
213        const isOpen = open.has(rel)
214        if (!push({ kind: 'dir', key: `row:${rel}`, rel, name: entry.name, depth, isOpen, count })) return
215        if (isOpen) walk(rel, depth + 1)
216      } else {
217        const mark = marks.get(rel) ?? null
218        if (mode === 'touched' && mark === null) continue
219        if (!push({ kind: 'file', key: `row:${rel}`, rel, name: entry.name, depth, mark })) return
220      }
221    }
222
223    if (mode === 'all' && listing !== undefined && listing.more > 0) {
224      push({ kind: 'note', key: `more:${dir}`, depth, text: `… ${listing.more} more` })
225    }
226  }
227
228  walk('', 0)
229
230  return view
231}
232
types/index.d.ts 28 lines
1/** What this session did to a file: `R` read, `M` modified, `A` added (written new). */
2export type Mark = 'R' | 'M' | 'A'
3
4/** Which rows the tree shows: every entry, or only touched files and the folders holding them. */
5export type Mode = 'all' | 'touched'
6
7/** One entry of a folder listing; ignored names are already left out. */
8export type Entry = { name: string; isDir: boolean }
9
10/** A folder's listing as last loaded: up to 200 entries, sorted, how many more there were, and why it failed. */
11export type Listing = { entries: Entry[]; more: number; error: string | null }
12
13declare module 'claude-code' {
14  interface PluginState {
15    filetree: {
16      /** The session's working directory, normalized: the tree's root. */
17      root: string
18      mode: Mode
19      /** Folders shown open, as paths under the root (the root itself is always open). */
20      expanded: string[]
21      /** Loaded listings by path under the root ('' is the root). */
22      listings: Record<string, Listing>
23      /** Every file touched this session, by absolute path. */
24      touched: Record<string, Mark>
25    }
26  }
27}
28