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

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.
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./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..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.○ 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.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.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.
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.
git status any more", so a gitignored or discarded file turns green too. Outside a git repository nothing turns green./filetree in the real prompt box. Not run on Windows.(not scanned).hooks/register.tsx 316 lines1import { 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}
316hooks/model.ts 246 lines1import 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}
246types/index.d.ts 31 lines1export 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