An IDE-style file tree for Claude Code that shows what Claude is doing and where in files

<h1 align="center">Claude Code Filetree</h1>
An IDE-style file tree for Claude Code that shows what Claude is doing and where in files
<img src="https://img.shields.io/badge/Claude_Code-%E2%89%A5_2.1.287-D97757?logo=claude&logoColor=fff" alt="Claude Code 2.1.287 or newer"> <img src="https://img.shields.io/badge/version-0.2.24-blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<img src="media/filetree-shimmer.gif" alt="filetree shimmering the file Claude is editing" width="900">
[!NOTE] filetree is a Claude Code mod and needs Claude Code 2.1.287+. It shows in the right sidebar, which needs the fullscreen layout (
/tui fullscreen) and a terminal at least 110 columns wide. Tested on Linux, macOS and Windows; mods don't load in WSL sessions of the Desktop app.
The repo is its own plugin marketplace. Run this in the terminal:
claude plugin marketplace add data-goblin/claude-code-filetree
claude plugin install filetree@claude-code-filetree
Or inside a Claude Code session:
/plugin marketplace add data-goblin/claude-code-filetree
/plugin install filetree@claude-code-filetree
Installed it as filetree@filetree before the repository was renamed? Nothing to do: that install keeps loading and keeps receiving updates.
/filetree <path> pins another folder+N -N) on modified files and ?:N M:N D:N file counts on folders<img src="media/filetree-read.gif" alt="Files shimmer purple while Claude reads and searches them" width="800">
git and gh (commit, push, pull, checkout, merge, PR and more) shown as a status at the bottom of the pane<img src="media/filetree-git.gif" alt="A committed file shimmers green and the footer shows the commit" width="800">
prompt.submit hook, and @path mentions in a prompt reveal that file in the tree<img src="media/filetree-ask.gif" alt="Selecting config.yaml in the tree and asking Claude what it changed there" width="800">
Σ header button swaps the date column for sizes; folders show their disk usage (du, or a summed listing on Windows), worked out in the background for the rows on screen and refreshed after Claude writes/filetreeYou can resize the pane with the mouse, or by setting custom pane:grow or pane:shrink keybindings in keybindings.json
<img src="media/filetree-resize.gif" alt="Dragging the filetree pane edge to resize it" width="900">
All settings are in /config under filetree.
reads and writes (default), writes, reads or none. Git status, line counts and the git status at the bottom always show.on (default) scrolls the tree to what Claude reads, writes or commits; off keeps the view where you put it, and highlights still show.date (default) or size; what the right column shows when a session starts. The Σ button in the header toggles it.auto (default) uses Nerd Font icons when a Nerd Font is installed and your terminal started after it was installed, plain Unicode in the desktop app, and Nerd Font over SSH. nerd or plain forces one.Clicking rows needs herdr 0.9.1 or later. herdr 0.9.0 and older accept pixel mouse reporting but still send cell positions, which would put every click in the session in the wrong place, so on those versions the rows ignore the mouse and the rest of the pane and session keep working. Run herdr update to get row clicks.
Turn on the pre-commit hook once per clone; it runs claude plugin validate and the plugin tests before each commit that touches the plugin:
git config core.hooksPath .githooks
The same checks run on macOS, Windows and Linux in CI on every push that touches the plugin.
This project is inspired by my Omarchy app FileBlade
hooks/register.tsx 1453 lines1import { type BuiltinToolResults, type EngineInterface, type PromptOrigin, type Register, type Timer, update } from 'claude-code'
2import { openCommand } from './open'
3
4import type { Activity, FileNode, FileTree, Theme } from '../types'
5import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve, TONES } from './git'
6import type { RowSpec, RowsProps, Seg } from './rows'
7import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR } from './icons'
8import {
9 ancestorsOf,
10 type Change,
11 DEFAULT_THEME,
12 dirname,
13 emptyTree,
14 formatSize,
15 inside,
16 isAbsolute,
17 join,
18 mapper,
19 parseGit,
20 parseNumstat,
21 parseTheme,
22 posix,
23 relative,
24 dropBelow,
25 useDrives,
26 rollCounts,
27 replaceChildren,
28 rollUp,
29 stamp,
30 toNodes,
31 underAny,
32 visibleRows,
33} from './tree'
34
35const TREE = { plugin: 'filetree', key: 'tree' } as const
36const THEME = { plugin: 'filetree', key: 'theme' } as const
37const ACTIVITY = { plugin: 'filetree', key: 'activity' } as const
38const PANE = 'filetree'
39const CLOSED = 'paneClosed'
40const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
41const BRANCH_ROW = '#branch'
42const FLASH_MS = 2700
43const RUNNING_MAX_MS = 600_000
44const NO_REPO_DEPTH = 4
45const DOUBLE_MS = 450
46const FIND_LIMIT = 200
47const READ_REVEAL_LIMIT = 12
48const SEARCH_REVEAL_LIMIT = 60
49const ACTIVITY_TTL_MS = 45_000
50const ADD_COLOR = '#98c379'
51const DEL_COLOR = '#e06c75'
52const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
53const THEME_POLL_MS = 2000
54const FONT_SCRIPT =
55 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
56 'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
57 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
58 'while [ -n "$p" ] && [ "$p" -gt 1 ]; do c=$(ps -o comm= -p "$p" 2>/dev/null); c=$(basename "$c" 2>/dev/null | tr -d " "); case "$c" in ' +
59 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
60 'e=$(ps -o etime= -p "$p" 2>/dev/null | awk -F\'[-:]\' \'{n=NF; s=$n+60*$(n-1); if (n>2) s+=3600*$(n-2); if (n>3) s+=86400*$(n-3); print s}\'); [ -n "$e" ] && [ $(( $(date +%s) - e )) -lt "$m" ] && echo stale || echo ok; exit 0;; esac; ' +
61 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
62const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
63const HOME_PRUNE = ['Library', 'AppData', '.Trash']
64const SIZE_TIMEOUT_MS = 15_000
65const SIZE_JOBS = 2
66const SIZE_WALK_LIMIT = 50_000
67
68let blink: Timer | null = null
69let themePoll: Timer | null = null
70let themeMtime: number | null = null
71let generation = 0
72let lastPress = { key: '', at: 0 }
73let noNerd = false
74let glyphSetting = 'auto'
75let follow = true
76let followClaude = true
77let scanning: Promise<void> | null = null
78let scanJobs: Job[] = []
79let gitRun: Promise<void> | null = null
80let gitNext: Promise<void> | null = null
81let queuedReads: string[] = []
82let showReads = true
83let showWrites = true
84let searchIndex: { root: string; paths: Promise<string[]> } | null = null
85let activityId = 0
86let pointer = true
87let view = { from: 0, max: 0 }
88let lastSync = 0
89let noDock = false
90let home = ''
91let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
92let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
93let markId = 0
94let listSeq = 0
95let lastRoot = ''
96let offerSelection = false
97let sizeDefault = false
98let sizeEpoch = 0
99let sizeActive = 0
100let sizeQueue: string[] = []
101const sizing = new Set<string>()
102const listLatest = new Map<string, number>()
103const unreadable = new Set<string>()
104const background = new Map<string, Background>()
105
106function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
107 platform ??= (async () => {
108 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
109 try {
110 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
111 } catch {
112 return 'linux'
113 }
114 })()
115 return platform
116}
117
118async function cwdOf($: EngineInterface): Promise<string> {
119 return posix(await $.session.cwd())
120}
121
122function clean(segs: Seg[]): Seg[] {
123 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
124 return segs
125}
126
127function faint(hex: string): string {
128 const v = parseInt(hex.slice(1), 16)
129 const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
130 return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
131}
132
133async function get($: EngineInterface): Promise<FileTree> {
134 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
135}
136
137async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
138 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
139}
140
141function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
142 return put($, t => ({ ...t, ...fn(t) }))
143}
144
145async function activities($: EngineInterface): Promise<Activity[]> {
146 return (await $.state.get(ACTIVITY)).value ?? []
147}
148
149async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
150 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
151}
152
153async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
154 try {
155 const entries = await $.fs.list(dir)
156 const resolved = await Promise.all(
157 entries.map(async e => {
158 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
159 try {
160 const target = await $.fs.stat(join(dir, e.name))
161 return target.kind === 'dir'
162 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
163 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
164 } catch {
165 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
166 }
167 }),
168 )
169 unreadable.delete(dir)
170 return toNodes(dir, resolved)
171 } catch (err) {
172 if (!(await exists($, dir))) return []
173 if (!unreadable.has(dir)) {
174 unreadable.add(dir)
175 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
176 }
177 return null
178 }
179}
180
181async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
182 const root = (await get($)).root
183 const seq = ++listSeq
184 for (const dir of dirs) listLatest.set(dir, seq)
185 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
186 const listed = new Map<string, FileNode[]>()
187 for (const [dir, kids] of results) {
188 if (listLatest.get(dir) !== seq) continue
189 listLatest.delete(dir)
190 if (kids) listed.set(dir, kids)
191 }
192 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
193 return listed
194}
195
196async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
197 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
198}
199
200async function detectRepo($: EngineInterface): Promise<void> {
201 const t = await get($)
202 if (!t.root) return
203 let top = ''
204 let prefix = ''
205 try {
206 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
207 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
208 prefix = p.trim()
209 top = tl.trim()
210 } catch {
211 top = ''
212 }
213 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
214}
215
216async function readGit($: EngineInterface): Promise<void> {
217 const t = await get($)
218 if (!t.root || !t.top) return
219 const root = t.root
220 try {
221 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
222 if (status.exitCode !== 0) return
223 const parsed = parseGit(status.stdout, root, t.prefix)
224 const diff: Record<string, [number, number]> = {}
225 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
226 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
227 const files = { ...parsed.files }
228 if (parsed.untrackedDirs.length) {
229 const untracked = new Set(parsed.untrackedDirs)
230 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', '.'])
231 if (others.exitCode === 0) for (const path of others.stdout.split('\0').filter(Boolean).map(rel => join(root, rel))) if (underAny(path, untracked, root)) files[path] = 'new'
232 }
233 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
234 dirty = { root, files }
235 await patch($, cur =>
236 cur.root === root
237 ? {
238 git: parsed.git,
239 ignored: parsed.ignored,
240 untrackedDirs: parsed.untrackedDirs,
241 branch: parsed.branch,
242 counts: rollCounts(files, root),
243 diff: rollUp(diff, root),
244 }
245 : {},
246 )
247 } catch {
248 return
249 }
250}
251
252function refreshGit($: EngineInterface): Promise<void> {
253 if (!gitRun) {
254 gitRun = readGit($).finally(() => {
255 gitRun = null
256 })
257 return gitRun
258 }
259 const rerun = () => {
260 gitNext = null
261 return refreshGit($)
262 }
263 gitNext ??= gitRun.then(rerun, rerun)
264 return gitNext
265}
266
267async function closedByPerson($: EngineInterface): Promise<boolean> {
268 return (await $.store.get(CLOSED)) === true
269}
270
271async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
272 const prev = await get($)
273 generation += 1
274 blink?.cancel()
275 blink = null
276 lastRoot = root
277 searchIndex = null
278 sizeEpoch += 1
279 sizeQueue = []
280 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
281 const title = `Files: ${root.split('/').pop() || root}`
282 if (focus) await $.ui.open({ id: PANE, title, focus: true })
283 else if (!noDock && !(await closedByPerson($))) await $.ui.open({ id: PANE, title })
284 await loadDirs($, [root])
285 await detectRepo($)
286 await refreshGit($)
287}
288
289async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
290 const tried = new Set<string>()
291 for (let pass = 0; pass < 32; pass++) {
292 const t = await get($)
293 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
294 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
295 const need = new Set<string>()
296 for (const p of paths) {
297 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
298 if (!loaded.has(dir)) {
299 if (!tried.has(dir)) need.add(dir)
300 break
301 }
302 }
303 }
304 if (need.size === 0) return
305 for (const dir of need) tried.add(dir)
306 await loadDirs($, [...need])
307 }
308}
309
310function pruneArgs(root: string, ignored: string[] = []): string[] {
311 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
312 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
313}
314
315async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
316 const stats = await Promise.all(
317 paths.map(async p => {
318 try {
319 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
320 } catch {
321 return ''
322 }
323 }),
324 )
325 return stats.filter(Boolean)
326}
327
328function broad(root: string): boolean {
329 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
330}
331
332function rootOfDisk(root: string): boolean {
333 return root === '/' || /^[A-Za-z]:\/$/.test(root)
334}
335
336async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
337 if (rootOfDisk(root)) return []
338 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
339 try {
340 const run = await $.process.run(['find', '-H', root, '-xdev', ...(depth ? ['-maxdepth', String(depth)] : []), ...pruneArgs(root, ignored.filter(p => inside(root, p)).slice(0, 40)), ...test, '-print0'], { timeoutMs: 8_000 })
341 return run.stdout.split('\0').filter(p => p && p !== root)
342 } catch {
343 return []
344 }
345}
346
347async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
348 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
349 if (dirty.root !== root) return { hits: [], gone: [] }
350 const entries = Object.entries(dirty.files)
351 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
352 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
353 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
354}
355
356type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
357
358async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
359 const ms = await $.clock.now()
360 const os = await osName($)
361 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
362 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
363 try {
364 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
365 return { ms, mark, os }
366 } catch {
367 return { ms, mark: '', os }
368 }
369}
370
371async function walkSize($: EngineInterface, dir: string): Promise<number> {
372 let total = 0
373 let seen = 0
374 let level = [dir]
375 while (level.length) {
376 const next: string[] = []
377 for (const d of level) {
378 let entries
379 try {
380 entries = await $.fs.list(d)
381 } catch {
382 continue
383 }
384 for (const e of entries) {
385 if (++seen > SIZE_WALK_LIMIT) return -1
386 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
387 else if (e.kind === 'file') total += e.size
388 }
389 }
390 level = next
391 }
392 return total
393}
394
395async function dirSize($: EngineInterface, dir: string): Promise<number> {
396 if (rootOfDisk(dir)) return -1
397 if ((await osName($)) === 'win32') return walkSize($, dir)
398 try {
399 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
400 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
401 return Number.isFinite(kb) ? kb * 1024 : -1
402 } catch {
403 return -1
404 }
405}
406
407function pumpSizes($: EngineInterface): void {
408 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
409 const dir = sizeQueue.shift() ?? ''
410 const epoch = sizeEpoch
411 sizing.add(dir)
412 sizeActive += 1
413 void dirSize($, dir)
414 .then(async bytes => {
415 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
416 const cur = await get($)
417 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
418 })
419 .catch(() => undefined)
420 .finally(() => {
421 sizing.delete(dir)
422 sizeActive -= 1
423 pumpSizes($)
424 })
425 }
426}
427
428function wantSizes($: EngineInterface, dirs: string[]): void {
429 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
430 pumpSizes($)
431}
432
433async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
434 sizeEpoch += 1
435 sizeQueue = []
436 await patch($, cur => {
437 if (!paths) return { dirSizes: {} }
438 const keep: Record<string, number> = {}
439 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
440 return { dirSizes: keep }
441 })
442}
443
444function openDirs(t: FileTree): string[] {
445 const open = new Set(t.expanded)
446 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
447}
448
449function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
450 if (followClaude) return { scroll: null }
451 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
452 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
453 return at < 0 ? {} : { scroll: at }
454}
455
456async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
457 const unique = Object.keys(tones)
458 if (unique.length === 0) return
459 const mine = ++generation
460 const root = (await get($)).root
461 blink?.cancel()
462 blink = null
463 await patch($, cur => {
464 if (cur.root !== root) return {}
465 const open = new Set(cur.expanded)
466 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
467 const dim = new Set<string>()
468 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
469 if (cur.flashOn) {
470 for (const id of cur.flashDim) dim.add(id)
471 }
472 for (const id of unique) {
473 const tone = tones[id] ?? 'orange'
474 all[id] = tone
475 if (id === BRANCH_ROW) continue
476 const chain = ancestorsOf(id, cur.root)
477 if (!chain.some(a => !open.has(a))) continue
478 for (const a of chain) {
479 if (!open.has(a)) {
480 dim.add(a)
481 all[a] = all[a] ?? tone
482 }
483 open.add(a)
484 }
485 }
486 for (const id of bright) dim.delete(id)
487 return {
488 flash: [...bright],
489 flashDim: [...dim],
490 flashOn: true,
491 flashTones: all,
492 expanded: [...open],
493 ...keepTop(cur, [...open]),
494 }
495 })
496 if (generation !== mine) return
497 blink = $.clock.after(FLASH_MS, () => {
498 if (generation !== mine) return
499 blink = null
500 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
501 })
502}
503
504async function followCwd($: EngineInterface): Promise<boolean> {
505 if (!follow) return false
506 const cwd = await cwdOf($)
507 const t = await get($)
508 if (t.root === cwd) return false
509 await reset($, cwd)
510 return true
511}
512
513type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
514type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
515type Background = { p: Pending | null; job: Job; reads: string[] }
516type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
517type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
518
519const PROOF: Record<string, (op: GitOperation) => boolean> = {
520 push: op => Boolean(op.push),
521 merge: op => op.branch?.action === 'merged',
522 rebase: op => op.branch?.action === 'rebased',
523 'pr create': op => op.pr?.action === 'created',
524 'pr merge': op => op.pr?.action === 'merged',
525 'pr comment': op => op.pr?.action === 'commented',
526}
527
528async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
529 const now = await $.clock.now()
530 const ids = actions.map(() => ++activityId)
531 await setActivities($, cur => [
532 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
533 ...actions.map((a, i) => ({
534 id: ids[i] ?? 0,
535 kind: a.kind,
536 label: a.running,
537 state: 'running' as const,
538 detail: '',
539 at: now,
540 tone: a.tone,
541 nerd: a.icon.nerd,
542 plain: a.icon.plain,
543 })),
544 ])
545 return ids
546}
547
548async function headOf($: EngineInterface, cwd: string): Promise<string> {
549 try {
550 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
551 return run.exitCode === 0 ? run.stdout.trim() : ''
552 } catch {
553 return ''
554 }
555}
556
557async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
558 const chain = chainOf(p.command)
559 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
560 return p.actions.map(a => {
561 if (a.verb === 'commit') {
562 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
563 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
564 }
565 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
566 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
567 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
568 })
569}
570
571async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
572 const now = await $.clock.now()
573 await setActivities($, cur =>
574 cur.map(a => {
575 const i = p.ids.indexOf(a.id)
576 const result = i < 0 ? undefined : results[i]
577 return result ? { ...a, ...result, at: now } : a
578 }),
579 )
580 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
581}
582
583function failAll(p: Pending): Outcome[] {
584 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
585}
586
587function proven(p: Pending | null, results: Outcome[]): GitAction[] {
588 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
589}
590
591function taskEnds(text: string): [string, string][] {
592 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
593 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
594 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
595 return id && status ? [[id, status] as [string, string]] : []
596 })
597}
598
599async function settleBackground($: EngineInterface, text: string): Promise<void> {
600 if (background.size === 0 || !text.includes('<task-notification>')) return
601 for (const [id, status] of taskEnds(text)) {
602 const task = background.get(id)
603 if (!task) continue
604 background.delete(id)
605 const ok = status === 'completed'
606 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
607 if (task.p) await finishGit($, task.p, results)
608 if (showReads) queuedReads.push(...task.reads)
609 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
610 }
611}
612
613async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
614 const map = mapper(root, prefix)
615 try {
616 const run = committed
617 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
618 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
619 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
620 } catch {
621 return []
622 }
623}
624
625async function gitDir($: EngineInterface, cwd: string): Promise<string> {
626 for (let dir = cwd, i = 0; i < 64; i++) {
627 try {
628 const dot = join(dir, '.git')
629 const st = await $.fs.stat(dot)
630 if (st.kind === 'dir') return dot
631 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
632 if (ref) return resolve(dir, ref, home)
633 } catch {
634 const up = dirname(dir)
635 if (up === dir) return ''
636 dir = up
637 continue
638 }
639 return ''
640 }
641 return ''
642}
643
644async function sync($: EngineInterface, force = false): Promise<void> {
645 const now = await $.clock.now()
646 if (!force && now - lastSync < 2_000) return
647 lastSync = now
648 searchIndex = null
649 const t = await get($)
650 if (!t.root) return
651 await loadDirs($, [t.root, ...openDirs(t)])
652 if (t.top) await refreshGit($)
653}
654
655async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
656 const t = await get($)
657 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
658 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
659 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
660}
661
662async function exists($: EngineInterface, path: string): Promise<boolean> {
663 try {
664 await $.fs.stat(path)
665 return true
666 } catch {
667 return false
668 }
669}
670
671async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
672 const reads = queuedReads
673 queuedReads = []
674 if (await followCwd($)) return
675 const t = await get($)
676 if (!t.root) return
677 const actions = jobs.flatMap(j => j.actions)
678 const writers = jobs.filter(j => !j.readOnly)
679 const since = (writers.length ? writers : jobs).reduce((a, j) => (j.since.ms < a.ms ? j.since : a), (writers[0] ?? jobs[0])?.since ?? { ms: 0, mark: '', os: 'linux' as const })
680 const writes = !jobs.every(j => j.readOnly)
681 const before = dirty.root === t.root ? dirty.files : {}
682 if (jobs.some(j => j.initRepo)) await detectRepo($)
683 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
684 const probed = await get($)
685 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
686 if (writes) {
687 searchIndex = null
688 await staleSizes($)
689 }
690 const fresh = await get($)
691 const ignored = new Set(fresh.ignored)
692 const tones: Record<string, string> = {}
693 if (writes) {
694 const found = fresh.top
695 ? await changedInRepo($, t.root, since, before, fresh.ignored)
696 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
697 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
698 await revealPaths($, hits)
699 const loaded = await get($)
700 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
701 const listed = await loadDirs($, dirs)
702 if (!fresh.top && since.os === 'win32')
703 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
704 await patch($, cur => {
705 const ids = new Set(cur.nodes.map(n => n.id))
706 return { expanded: cur.expanded.filter(id => ids.has(id)) }
707 })
708 const present = new Set((await get($)).nodes.map(n => n.id))
709 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
710 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
711 }
712 if (showReads) {
713 const found: string[] = []
714 for (const r of reads) {
715 if (found.length >= READ_REVEAL_LIMIT) break
716 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
717 }
718 if (found.length) {
719 await revealPaths($, found)
720 const shown = new Set((await get($)).nodes.map(n => n.id))
721 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
722 }
723 }
724 if (showWrites && actions.length) {
725 const final = await get($)
726 const committed = actions.some(a => a.verb === 'commit')
727 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
728 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
729 await revealPaths($, paths)
730 const shown = new Set((await get($)).nodes.map(n => n.id))
731 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
732 }
733 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
734 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
735 else if (committed) tones[BRANCH_ROW] = 'green'
736 }
737 await flash($, tones)
738}
739
740function scheduleScan($: EngineInterface, job: Job): void {
741 scanJobs.push(job)
742 if (scanning) return
743 scanning = (async () => {
744 while (scanJobs.length) {
745 const jobs = scanJobs
746 scanJobs = []
747 try {
748 await afterBash($, jobs)
749 } finally {
750 const marks = jobs.map(j => j.since.mark).filter(Boolean)
751 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
752 }
753 }
754 })().finally(() => {
755 scanning = null
756 })
757}
758
759async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
760 if (await followCwd($)) return
761 const t = await get($)
762 if (!t.root) return
763 const within = paths.map(posix).filter(p => inside(t.root, p))
764 if (within.length === 0) return
765 if (tone !== 'purple') {
766 searchIndex = null
767 await staleSizes($, within)
768 await revealPaths($, within.map(dirname))
769 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
770 await refreshGit($)
771 } else await revealPaths($, within)
772 if (!show) return
773 const tones: Record<string, string> = {}
774 for (const p of within) tones[p] = tone
775 await flash($, tones)
776}
777
778async function reveal($: EngineInterface, paths: string[]): Promise<void> {
779 if (await followCwd($)) return
780 await revealPaths($, paths)
781 await patch($, cur => {
782 const open = new Set(cur.expanded)
783 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
784 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
785 })
786}
787
788async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
789 const out: string[] = []
790 let level = [root]
791 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
792 const next: string[] = []
793 for (const dir of level) {
794 for (const n of (await list($, dir)) ?? []) {
795 if (n.kind === 'dir') {
796 if (!PRUNE.includes(n.name)) next.push(n.id)
797 } else out.push(n.id)
798 }
799 if (out.length >= limit) break
800 }
801 level = next
802 }
803 return out
804}
805
806async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
807 try {
808 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
809 const run = t.top
810 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
811 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
812 if (t.top && run.exitCode !== 0) return null
813 const paths = run.stdout.split('\0').filter(Boolean)
814 if (run.isStdoutTruncated) paths.pop()
815 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
816 } catch {
817 return null
818 }
819}
820
821function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
822 if (searchIndex?.root !== t.root) {
823 const index: { root: string; paths: Promise<string[]> } = {
824 root: t.root,
825 paths: listAll($, t).then(paths => {
826 if (paths === null && searchIndex === index) searchIndex = null
827 return paths ?? []
828 }),
829 }
830 searchIndex = index
831 }
832 return searchIndex.paths
833}
834
835async function search($: EngineInterface, query: string): Promise<string[]> {
836 if (!(await get($)).query.trim()) searchIndex = null
837 await patch($, () => ({ query }))
838 const q = query.trim().toLowerCase()
839 if (!q) return []
840 const t = await get($)
841 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
842 if ((await get($)).query !== query) return []
843 await revealPaths($, hits)
844 const shown = await get($)
845 const ids = new Set(shown.nodes.map(n => n.id))
846 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
847 if (stale.length) await loadDirs($, stale)
848 return hits
849}
850
851async function jump($: EngineInterface, query: string): Promise<void> {
852 const hits = (await search($, query)).slice(0, 10)
853 await patch($, cur => {
854 const open = new Set(cur.expanded)
855 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
856 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
857 })
858}
859
860async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
861 try {
862 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
863 return out === 'stale' || out === 'missing' ? out : 'ok'
864 } catch {
865 return 'missing'
866 }
867}
868
869async function finePointerOk($: EngineInterface): Promise<boolean> {
870 if (!(await $.env.get('HERDR_ENV'))) return true
871 try {
872 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
873 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
874 return major * 1e6 + minor * 1e3 + fix >= 9_001
875 } catch {
876 return false
877 }
878}
879
880async function openFile($: EngineInterface, path: string): Promise<void> {
881 const os = await osName($)
882 const { argv, init } = openCommand(os, path, os === 'win32' ? ((await $.env.get('PATHEXT')) ?? '') : '')
883 try {
884 const run = await $.process.run(argv, init)
885 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
886 } catch {
887 $.ui.toast(`could not open ${path} with ${argv[0]}`)
888 }
889}
890
891function byPerson(origin: PromptOrigin): boolean {
892 return origin.kind === 'composer' || origin.kind === 'bridge' || (origin.kind === 'plugin' && origin.asUser === true)
893}
894
895async function toggle($: EngineInterface, n: FileNode): Promise<void> {
896 if (n.kind !== 'dir') offerSelection = true
897 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
898 await patch($, t => {
899 const open = new Set(t.expanded)
900 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
901 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
902 })
903}
904
905async function press($: EngineInterface, n: FileNode): Promise<void> {
906 const now = await $.clock.now()
907 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
908 lastPress = { key: isDouble ? '' : n.id, at: now }
909 if (!isDouble) {
910 await toggle($, n)
911 return
912 }
913 await openNode($, n)
914}
915
916async function openNode($: EngineInterface, n: FileNode): Promise<void> {
917 if (n.kind === 'dir') {
918 follow = false
919 await reset($, n.id)
920 } else await openFile($, n.id)
921}
922
923async function loadTheme($: EngineInterface): Promise<void> {
924 const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
925 try {
926 const stat = await $.fs.stat(path)
927 if (stat.kind !== 'file') throw new Error('no theme file')
928 if (stat.mtimeMs === themeMtime) return
929 await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
930 themeMtime = stat.mtimeMs
931 } catch {
932 if (themeMtime === 0) return
933 await $.state.set(THEME, DEFAULT_THEME)
934 themeMtime = 0
935 }
936}
937
938function shortPath(path: string): string {
939 return home && inside(home, path) ? `~${path.slice(home.length)}` : path
940}
941
942export const register: Register = (on, options) => {
943 glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
944 const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
945 showReads = activity.includes('reads')
946 showWrites = activity.includes('writes')
947 followClaude = options?.follow !== 'off'
948 sizeDefault = options?.column === 'size'
949 on('session.start', async ($, e, next) => {
950 await $.command.register({ name: 'filetree', description: 'Show the file tree; args: [path] (no path = cwd)' })
951 const windows = (await $.env.get('OS')) === 'Windows_NT'
952 useDrives(windows)
953 home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
954 activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
955 void (async () => {
956 pointer = await finePointerOk($)
957 try {
958 const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
959 noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
960 } catch {
961 noNerd = true
962 }
963 await loadTheme($)
964 themePoll?.cancel()
965 themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
966 const t = await get($)
967 if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
968 await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
969 const cwd = await cwdOf($)
970 if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
971 else if (!noDock && !(await closedByPerson($))) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
972 })()
973 return next(e)
974 })
975
976 on('command.run', { command: 'filetree' }, async ($, e) => {
977 if (!e.presentation.isFullscreen) return { text: 'filetree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /filetree.' }
978 if (e.presentation.columns < 110) return { text: 'filetree shows in the sidebar, which needs a terminal at least 110 columns wide. Widen it, then run /filetree.' }
979 noDock = false
980 await $.store.delete(CLOSED)
981 const arg = (e.args ?? '').trim()
982 const cwd = await cwdOf($)
983 follow = !arg
984 const root = arg ? resolve(cwd, arg, home) : cwd
985 await reset($, root, true)
986 return { text: `File tree on ${shortPath(root)}${follow ? ' (follows the cwd)' : ''}.` }
987 })
988
989 on('ui.close', { id: PANE, origin: { kind: 'person' } }, async ($, e, next) => {
990 const result = await next(e)
991 await $.store.set(CLOSED, true)
992 return result
993 }).catch(($, e, next) => next(e))
994
995 on('tool.call', async ($, e, next) => {
996 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && e.tool !== 'Bash' && e.tool !== 'PowerShell') return next(e)
997 const shell = e.tool === 'Bash' || e.tool === 'PowerShell'
998 const command = shell ? e.command : ''
999 const cwd = shell ? await cwdOf($) : ''
1000 const actions = gitActions(command)
1001 const quiet = !actions.length && readOnly(command)
1002 const since = await sinceNow($, shell && !quiet)
1003 const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1004 const head = before?.top ? await headOf($, before.root) : ''
1005 const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1006 let result: Awaited<ReturnType<typeof next>>
1007 try {
1008 result = await next(e)
1009 } catch (err) {
1010 if (pending) await finishGit($, pending, failAll(pending))
1011 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1012 throw err
1013 }
1014 if (pending && result.deny) {
1015 const ids = pending.ids
1016 void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1017 }
1018 if (result.deny || (result.isError && !shell)) {
1019 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1020 return result
1021 }
1022 if (shell) {
1023 const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? result.result : {}
1024 const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1025 const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1026 if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1027 else void (async () => {
1028 const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1029 if (pending) await finishGit($, pending, results)
1030 queuedReads.push(...reads)
1031 scheduleScan($, { ...job, actions: proven(pending, results) })
1032 })()
1033 if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1034 } else {
1035 const file =
1036 'file_path' in e && typeof e.file_path === 'string'
1037 ? e.file_path
1038 : 'notebook_path' in e && typeof e.notebook_path === 'string'
1039 ? e.notebook_path
1040 : ''
1041 if (file) void touched($, [file], 'orange', showWrites)
1042 }
1043 return result
1044 })
1045
1046 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1047 const result = await next(e)
1048 if (result.deny || result.isError || e.tool !== 'Read' || !showReads) return result
1049 void touched($, [posix(e.file_path)], 'purple', true)
1050 return result
1051 })
1052
1053 on('ui.message', async ($, e, next) => {
1054 if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
1055 const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
1056 void sync($)
1057 const t = await get($)
1058 if (typeof data.copy === 'string') {
1059 await copyPath($, data.copy, Boolean(data.shift), e.surface)
1060 return {}
1061 }
1062 if (typeof data.scrollTo === 'number') {
1063 const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * view.max)
1064 if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1065 return {}
1066 }
1067 if (typeof data.press === 'string') {
1068 const n = t.nodes.find(x => x.id === data.press)
1069 if (!n) return {}
1070 if (t.scroll === null) await patch($, () => ({ scroll: view.from }))
1071 if (data.ctrl || data.shift) await openNode($, n)
1072 else await press($, n)
1073 return {}
1074 }
1075 if (typeof data.key !== 'string') return {}
1076 const rows = visibleRows(t)
1077 const at = rows.findIndex(r => r.node.id === t.cursor)
1078 const cur = rows[at]?.node
1079 const move = (d: number) => {
1080 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
1081 return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
1082 }
1083 if (data.key === 'up' || data.key === 'k') await move(-1)
1084 else if (data.key === 'down' || data.key === 'j') await move(1)
1085 else if (data.key === 'pageup') await move(-10)
1086 else if (data.key === 'pagedown') await move(10)
1087 else if (data.key === 'home') await move(-rows.length)
1088 else if (data.key === 'end') await move(rows.length)
1089 else if (cur && (data.key === 'y' || data.key === 'Y')) await copyPath($, cur.id, data.key === 'Y' || Boolean(data.shift), e.surface)
1090 else if (cur && (data.key === 'right' || data.key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
1091 else if (cur && (data.key === 'left' || data.key === 'h')) {
1092 if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1093 else if (cur.parent !== t.root) await patch($, () => ({ cursor: cur.parent }))
1094 } else if (cur && (data.key === 'return' || data.key === ' ')) await toggle($, cur)
1095 return {}
1096 })
1097
1098 on('classic.SessionStart', async ($, e, next) => {
1099 const result = await next(e)
1100 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
1101 void (async () => {
1102 await loadTheme($)
1103 const root = follow || !lastRoot ? await cwdOf($) : lastRoot
1104 const t = await get($)
1105 if (t.root !== root || t.nodes.length === 0) await reset($, root)
1106 })()
1107 }
1108 const gd = await gitDir($, posix(e.cwd))
1109 return gd ? { ...result, watchPaths: [...(result.watchPaths ?? []), join(gd, 'index'), join(gd, 'HEAD')] } : result
1110 })
1111
1112 on('classic.CwdChanged', async ($, e, next) => {
1113 const result = await next(e)
1114 void followCwd($)
1115 return result
1116 })
1117
1118 on('classic.FileChanged', async ($, e, next) => {
1119 const result = await next(e)
1120 if (/[\\/]\.git[\\/]|[\\/](index|HEAD)$/.test(e.file_path)) $.clock.after(300, () => void sync($, true))
1121 return result
1122 })
1123
1124 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1125 const result = await next(e)
1126 void sync($)
1127 return result
1128 })
1129
1130 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
1131 const t = await get($)
1132 const to = Math.max(0, Math.min(view.max, (t.scroll ?? view.from) + Math.sign(e.by) * Math.max(3, Math.abs(e.by))))
1133 if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1134 return {}
1135 })
1136
1137 on('prompt.submit', async ($, e, next) => {
1138 void settleBackground($, e.text)
1139 if (!(await followCwd($))) void sync($)
1140 const t = await get($)
1141 const context = [...(e.context ?? [])]
1142 if (offerSelection && t.selected && byPerson(e.origin)) {
1143 offerSelection = false
1144 if (await exists($, t.selected)) context.push(`The user has this file selected in the file tree; "this" or "it" in the prompt likely refers to it: ${t.selected}`)
1145 }
1146 const mentions = [...e.text.matchAll(/@([^\s"'`]+)/g)]
1147 .map(m => (m[1] ?? '').replace(/[.,;:!?)]+$/, ''))
1148 .filter(Boolean)
1149 .map(p => resolve(t.root, p, home))
1150 .filter(p => inside(t.root, p))
1151 if (mentions.length) {
1152 void (async () => {
1153 const found: string[] = []
1154 for (const p of mentions) {
1155 try {
1156 await $.fs.stat(p)
1157 found.push(p.replace(/\/$/, ''))
1158 } catch {
1159 continue
1160 }
1161 }
1162 if (found.length) await reveal($, found)
1163 })()
1164 }
1165 return next(context.length ? { ...e, context } : e)
1166 })
1167
1168 on('prompt.attachment', async ($, e, next) => {
1169 void settleBackground($, e.text)
1170 return next(e)
1171 })
1172
1173 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1174 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
1175 if (e.surface === 'terminal' && e.props.placement === 'inline') {
1176 noDock = true
1177 void $.ui.close({ id: PANE }).catch(() => undefined)
1178 const { Box: Empty } = $.ui.resolve(e)
1179 return <Empty />
1180 }
1181 const unicode = glyphSetting === 'plain' || (glyphSetting === 'auto' && (noNerd || e.surface === 'desktop'))
1182 const { Box, Text, Button, Input, Client } = $.ui.resolve(e)
1183 const t = await get($)
1184 const theme: Theme = (await $.state.get(THEME)).value ?? DEFAULT_THEME
1185 const now = await $.clock.now()
1186 const live = (await activities($)).filter(a => now - a.at < (a.state === 'running' ? RUNNING_MAX_MS : ACTIVITY_TTL_MS))
1187 const latest = [...live].reverse().find(a => a.state === 'running') ?? live[live.length - 1]
1188 const bright = new Set(t.flashOn ? t.flash : [])
1189 const dimmed = new Set(t.flashOn ? t.flashDim : [])
1190 const ignored = new Set(t.ignored)
1191 const untracked = new Set(t.untrackedDirs)
1192 const width = Math.max(24, e.props.bodyColumns)
1193 const rows = visibleRows(t)
1194 const fixed = 2 + (t.top ? (t.branch ? 1 : 0) : 1) + (t.selected || latest ? 1 : 0)
1195 const room = Math.max(5, (e.props.scroll?.bodyRows ?? 40) - fixed)
1196 const isLit = (id: string) => bright.has(id) || dimmed.has(id)
1197 const focus = followClaude && t.flashOn ? ([...t.flash].reverse().find(id => id !== BRANCH_ROW) ?? t.cursor) : t.cursor
1198 const at = Math.max(0, rows.findIndex(r => r.node.id === focus))
1199 const lit = followClaude && t.flashOn ? rows.findIndex(r => isLit(r.node.id)) : -1
1200 const cap = Math.max(1, Math.floor(room / 3))hooks/open.ts 57 lines1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4 'set -f',
5 'f=$1',
6 'if command -v gio >/dev/null; then',
7 ' if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8 ' case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9 ' video/* | audio/*)',
10 ' app=$(xdg-mime query default text/plain)',
11 ' IFS=:',
12 ' for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13 ' [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14 ' done',
15 ' echo "No text/plain desktop application found for $f" >&2',
16 ' exit 1',
17 ' ;;',
18 ' esac',
19 ' fi',
20 ' if (exec gio open "$f"); then exit 0; fi',
21 'fi',
22 'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26// A file Windows' default action would run opens in Notepad (a script) or is shown in Explorer (the rest), never with that action.
27const WIN_EDIT = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = (Join-Path $env:SystemRoot 'System32\\notepad.exe'); Arguments = [char]34 + $env:PANE_OPEN_TARGET + [char]34; UseShellExecute = $true }) | Out-Null"
28const WIN_REVEAL = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = (Join-Path $env:SystemRoot 'explorer.exe'); Arguments = '/select,' + [char]34 + $env:PANE_OPEN_TARGET + [char]34; UseShellExecute = $true }) | Out-Null"
29const WIN_SCRIPTS = new Set(['bat', 'cmd', 'ps1', 'psm1', 'psd1', 'psc1', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'wsh', 'wsc', 'ws', 'sct', 'hta', 'reg', 'py', 'pyw', 'sh', 'bash', 'pl', 'rb', 'php', 'tcl', 'ahk', 'au3'])
30const WIN_PROGRAMS = new Set(['exe', 'com', 'scr', 'cpl', 'pif', 'msi', 'msp', 'mst', 'msc', 'msix', 'msixbundle', 'appx', 'appxbundle', 'appinstaller', 'application', 'appref-ms', 'gadget', 'lnk', 'url', 'website', 'scf', 'jar', 'pyc', 'pyo', 'pyz', 'chm', 'diagcab', 'xbap', 'xll', 'settingcontent-ms', 'library-ms', 'search-ms', 'searchconnector-ms'])
31const URL_TARGET = /^[a-z][a-z0-9+.-]*:\/\//i
32
33export function winOpener(target: string, pathext = ''): 'open' | 'edit' | 'reveal' {
34 if (URL_TARGET.test(target)) return 'open'
35 const name = (target.split(/[\\/]/).pop() ?? '').replace(/[. ]+$/, '')
36 const ext = name.includes('.') ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : ''
37 if (!ext) return 'open'
38 if (WIN_PROGRAMS.has(ext)) return 'reveal'
39 return WIN_SCRIPTS.has(ext) || pathext.toLowerCase().split(';').includes(`.${ext}`) ? 'edit' : 'open'
40}
41
42export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string, pathext = '') {
43 const how = os === 'win32' ? winOpener(target, pathext) : 'open'
44 const argv = os === 'linux'
45 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
46 : os === 'darwin'
47 ? ['open', '--', target]
48 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', how === 'edit' ? WIN_EDIT : how === 'reveal' ? WIN_REVEAL : WIN_OPEN]
49 return {
50 argv,
51 init: {
52 timeoutMs: 10_000,
53 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: URL_TARGET.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
54 },
55 }
56}
57hooks/git.ts 262 lines1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4 kind: string
5 verb: string
6 running: string
7 done: string
8 tone: string
9 icon: { nerd: string; plain: string }
10 init?: boolean
11}
12
13const ICON = {
14 commit: { nerd: '\u{f417}', plain: '●' },
15 push: { nerd: '\u{f0552}', plain: '↑' },
16 pull: { nerd: '\u{f0553}', plain: '↓' },
17 branch: { nerd: '\u{f418}', plain: '⑂' },
18 merge: { nerd: '\u{f419}', plain: '⑃' },
19 pr: { nerd: '\u{f407}', plain: '⇄' },
20 github: { nerd: '\u{f408}', plain: '◎' },
21 stash: { nerd: '\u{f01bc}', plain: '≡' },
22 undo: { nerd: '\u{f054c}', plain: '↶' },
23 tag: { nerd: '\u{f412}', plain: '⌖' },
24 git: { nerd: '\u{e702}', plain: '±' },
25}
26
27export const TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
28 orange: { bright: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'], dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'], solid: '#f97316' },
29 green: { bright: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'], dim: ['#14532d', '#166534', '#2f7a47', '#4f9a66'], solid: '#4ade80' },
30 teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'], solid: '#2dd4bf' },
31 blue: { bright: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'], dim: ['#1e3a8a', '#1d4ed8', '#3b6fd1', '#6b93dc'], solid: '#60a5fa' },
32 purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'], solid: '#c084fc' },
33 cyan: { bright: ['#06b6d4', '#22d3ee', '#67e8f9', '#cffafe'], dim: ['#155e75', '#0e7490', '#2b8ea3', '#5aa9b8'], solid: '#22d3ee' },
34 red: { bright: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'], dim: ['#7f1d1d', '#991b1b', '#b54040', '#c96a6a'], solid: '#f87171' },
35}
36
37const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
38 commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
39 push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
40 pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
41 fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
42 checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
43 switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
44 branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
45 merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
46 rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
47 'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
48 stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
49 reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
50 restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
51 revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
52 add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
53 rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
54 mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
55 tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
56 init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
57 clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
58}
59
60const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
61 'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
62 'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
63 'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
64 'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
65 'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
66 'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
67 'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
68 'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
69}
70
71const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
72
73function segments(command: string, seps: string[] = []): string[][] {
74 const out: string[][] = []
75 let cur: string[] = []
76 let tok = ''
77 let quote = ''
78 let has = false
79 const endTok = () => {
80 if (has) cur.push(tok)
81 tok = ''
82 has = false
83 }
84 const endSeg = () => {
85 endTok()
86 if (cur.length) out.push(cur)
87 cur = []
88 }
89 for (let i = 0; i < command.length; i++) {
90 const ch = command[i] ?? ''
91 if (quote) {
92 if (ch === quote) quote = ''
93 else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
94 else tok += ch
95 } else if (ch === '"' || ch === "'") {
96 quote = ch
97 has = true
98 } else if (ch === '\\' && i + 1 < command.length) {
99 tok += command[++i]
100 has = true
101 } else if (ch === '#' && !has) {
102 while (i + 1 < command.length && command[i + 1] !== '\n') i++
103 } else if (ch === ' ' || ch === '\t') endTok()
104 else if (ch === '\n' || ch === ';') {
105 seps.push(';')
106 endSeg()
107 } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
108 tok += ch
109 has = true
110 } else if (ch === '&' || ch === '|') {
111 const twice = command[i + 1] === ch
112 if (twice) i++
113 seps.push(twice ? ch + ch : ch)
114 endSeg()
115 } else {
116 tok += ch
117 has = true
118 }
119 }
120 endSeg()
121 if (!quote) return out
122 seps.push(';')
123 return command
124 .split(/&&|\|\||;|\||\n/)
125 .map(s => s.trim().split(/\s+/).filter(Boolean))
126 .filter(s => s.length > 0)
127}
128
129function stripGlobals(tokens: string[]): string[] {
130 const out = [...tokens]
131 if (out[0] === 'env') out.shift()
132 while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
133 return out
134}
135
136function gitVerb(tokens: string[]): number {
137 let i = 1
138 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
139 if (tokens[i] === '-C' || tokens[i] === '-c') i++
140 i++
141 }
142 return i
143}
144
145function gitReadOnly(verb: string, rest: string[]): boolean {
146 if (!verb || READ_ONLY_GIT.has(verb)) return true
147 if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
148 if (verb === 'branch') return rest.every(t => t.startsWith('-'))
149 if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
150 return false
151}
152
153export function chainOf(command: string): { size: number; and: boolean } {
154 const seps: string[] = []
155 const size = segments(command, seps).length
156 return { size, and: seps.every(s => s === '&&') }
157}
158
159export function gitActions(command: string): GitAction[] {
160 const out: GitAction[] = []
161 for (const raw of segments(command)) {
162 const tokens = stripGlobals(raw)
163 const head = tokens[0]?.split('/').pop()
164 if (head === 'git') {
165 const i = gitVerb(tokens)
166 const verb = tokens[i] ?? ''
167 const rest = tokens.slice(i + 1)
168 if (gitReadOnly(verb, rest)) continue
169 const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
170 if (spec) out.push({ kind: `git ${verb}`, ...spec })
171 } else if (head === 'gh') {
172 const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
173 const spec = GH_VERBS[pair]
174 if (spec) out.push({ kind: `gh ${pair}`, ...spec })
175 }
176 }
177 return out
178}
179
180export const BRANCH_ICON = ICON.branch
181
182const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
183const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
184const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
185
186function tidy(path: string): string {
187 const s = path.replace(/\/+$/, '')
188 return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
189}
190
191export function resolve(cwd: string, p: string, home = ''): string {
192 let path = posix(p)
193 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
194 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
195 const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
196 for (const seg of path.slice(drive.length).split('/')) {
197 if (!seg || seg === '.') continue
198 if (seg === '..') {
199 if (parts.length > 1) parts.pop()
200 } else parts.push(seg)
201 }
202 return tidy(parts.join('/'))
203}
204
205const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
206
207export function readOnly(command: string): boolean {
208 const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
209 if (/[^>]>[^>&]|>>/.test(bare)) return false
210 return segments(command).every(raw => {
211 const tokens = stripGlobals(raw)
212 const head = tokens[0]?.split('/').pop() ?? ''
213 if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
214 if (head === 'git') {
215 const i = gitVerb(tokens)
216 return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
217 }
218 if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
219 if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
220 return READERS.has(head)
221 })
222}
223
224export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
225 let cwd = sessionCwd
226 const out = new Set<string>()
227 let listsPaths = false
228 for (const raw of segments(command)) {
229 const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
230 const head = tokens[0]?.split('/').pop() ?? ''
231 if (head === 'cd' && tokens[1]) {
232 cwd = resolve(cwd, tokens[1], home)
233 continue
234 }
235 if (!READERS.has(head)) continue
236 if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
237 if (OUTPUT_PATHS.has(head)) listsPaths = true
238 const args = tokens.slice(1)
239 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
240 for (let i = 0; i < args.length; i++) {
241 const a = args[i] ?? ''
242 if (a.startsWith('-')) {
243 if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
244 continue
245 }
246 if (skipPattern) {
247 skipPattern = false
248 continue
249 }
250 if (/[*?<>|]/.test(a)) continue
251 out.add(resolve(cwd, a, home))
252 }
253 }
254 if (listsPaths) {
255 for (const line of stdout.split('\n').slice(0, 400)) {
256 const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
257 if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
258 }
259 }
260 return [...out].slice(0, 60)
261}
262hooks/rows.tsx 143 lines1import type { ClientModule } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
5export type RowsProps = {
6 rows: RowSpec[]
7 active: string
8 activeBg: string
9 hoverBg: string
10 tones: Record<string, { bright: string[]; dim: string[] }>
11 spinner?: string[]
12 pointer?: boolean
13 bar?: { pos: number; size: number; thumb: string; track: string }
14}
15type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
16
17const TICK_MS = 90
18const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
19
20function shimmer(i: number, phase: number, len: number, palette: string[]): string {
21 const band = ((phase * 1.6) % (len + 8)) - 4
22 const d = Math.abs(i - band)
23 return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
24}
25
26const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
27 const { Box, Text } = surface.elements
28 let state = surface.state
29 if (state === undefined) {
30 state = { hover: -1, phase: 0, drag: false, ref: {} }
31 surface.setState(state)
32 }
33 const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
34 if (lit && !state.ref.stop) {
35 state.ref.stop = surface.every(TICK_MS, () => {
36 const cur = surface.state
37 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
38 })
39 } else if (!lit && state.ref.stop) {
40 state.ref.stop()
41 state.ref.stop = undefined
42 }
43 if (props.pointer === false) {
44 state.ref.unpoint?.()
45 state.ref.unpoint = undefined
46 } else state.ref.unpoint = surface.onPointer(e => {
47 const cur = surface.state ?? state
48 const span = Math.max(1, props.rows.length - 1)
49 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
50 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
51 if (e.type === 'up' || e.type === 'leave') {
52 surface.setState({ ...cur, drag: false })
53 return
54 }
55 if (e.type === 'down' || e.type === 'move') {
56 if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
57 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
58 return
59 }
60 }
61 if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
62 if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
63 return
64 }
65 if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
66 const row = props.rows[e.y]
67 if (e.type === 'down' && e.button === 'right' && row?.id) {
68 surface.post({ copy: row.id, shift: Boolean(e.shift) })
69 return
70 }
71 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
72 let x = 0
73 for (const seg of row.left) {
74 const w = [...seg.t].length
75 if (seg.tab && e.x >= x && e.x < x + w) {
76 surface.post({ tab: seg.tab })
77 return
78 }
79 x += w
80 }
81 if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
82 })
83 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
84 const frames = props.spinner?.length ? props.spinner : FRAMES
85 const draw = (s: Seg) => {
86 if (s.spin) {
87 return (
88 <Text color={s.c} bold={s.b}>
89 {s.t + (frames[state.phase % frames.length] ?? '')}
90 </Text>
91 )
92 }
93 const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
94 if (!palette) {
95 return (
96 <Text color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
97 {s.t}
98 </Text>
99 )
100 }
101 if (s.one) {
102 return (
103 <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
104 {s.t}
105 </Text>
106 )
107 }
108 const chars = [...s.t]
109 return (
110 <Text bold={s.b}>
111 {chars.map((ch, i) => (
112 <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
113 ))}
114 </Text>
115 )
116 }
117 return (
118 <Box flexDirection="column">
119 {props.rows.map((r, i) => (
120 <Box
121 flexDirection="row"
122 height={1}
123 overflow="hidden"
124 backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
125 >
126 <Box flexShrink={1} overflow="hidden">
127 {r.left.map(draw)}
128 </Box>
129 <Box flexGrow={1} />
130 {r.right.map(draw)}
131 {props.bar && (
132 <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
133 {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
134 </Text>
135 )}
136 </Box>
137 ))}
138 </Box>
139 )
140}
141
142export default Rows
143hooks/icons.ts 132 lines1import type { FileNode } from '../types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5export const CHEVRON_OPEN = cp(0xf47c)
6export const CHEVRON_CLOSED = cp(0xf460)
7
8const NAMES: Record<string, number> = {
9 'agents.md': 0xf0354,
10 'changelog.md': 0xf0354,
11 'contributing.md': 0xf0354,
12 'todo.md': 0xf0354,
13 'readme.md': 0xf0354,
14 license: 0xe60a,
15 dockerfile: 0xf0868,
16 makefile: 0xf1064,
17 'cmakelists.txt': 0xf1064,
18 justfile: 0xf05b7,
19 gemfile: 0xf0d2d,
20 rakefile: 0xf0d2d,
21 'cargo.toml': 0xe6b2,
22 'cargo.lock': 0xe6b2,
23 'pyproject.toml': 0xe6b2,
24 'requirements.txt': 0xf160e,
25 'go.mod': 0xf0afa,
26 'go.sum': 0xf07d3,
27 'package.json': 0xf0626,
28 'package-lock.json': 0xf0626,
29 'tsconfig.json': 0xf0626,
30 '.prettierrc': 0xf0626,
31 '.eslintrc': 0xf0626,
32 'pnpm-lock.yaml': 0xe6a8,
33 'yarn.lock': 0xe6a8,
34 '.gitignore': 0xf02a2,
35 '.gitmodules': 0xf0493,
36 '.editorconfig': 0xe652,
37}
38
39const EXTS: Record<string, number> = {
40 lua: 0xe620,
41 py: 0xe606,
42 pyi: 0xe606,
43 js: 0xe60c,
44 mjs: 0xe60c,
45 cjs: 0xe60c,
46 ts: 0xe628,
47 jsx: 0xe625,
48 tsx: 0xe7ba,
49 json: 0xe60b,
50 jsonc: 0xe60b,
51 yaml: 0xe6a8,
52 yml: 0xe6a8,
53 sh: 0xe795,
54 bash: 0xe795,
55 zsh: 0xe795,
56 fish: 0xe795,
57 md: 0xf0354,
58 markdown: 0xf0354,
59 css: 0xe6b8,
60 scss: 0xf031c,
61 html: 0xe736,
62 htm: 0xe736,
63 go: 0xe627,
64 rs: 0xe68b,
65 rb: 0xe791,
66 php: 0xe608,
67 java: 0xe738,
68 cs: 0xf031b,
69 sql: 0xe706,
70 graphql: 0xf20e,
71 gql: 0xf20e,
72 xml: 0xf05c0,
73 toml: 0xe6b2,
74 ini: 0xf0bc2,
75 conf: 0xf0493,
76 pdf: 0xeaeb,
77 svg: 0xf0721,
78 jpg: 0xf0225,
79 jpeg: 0xf0225,
80 png: 0xe60d,
81 gif: 0xf0d78,
82 webp: 0xf021f,
83 mp4: 0xf022b,
84 mkv: 0xf022b,
85 mov: 0xf022b,
86 mp3: 0xf0223,
87 wav: 0xf0223,
88 flac: 0xf0223,
89 zip: 0xf05c4,
90 gz: 0xf05c4,
91 xz: 0xf05c4,
92 tar: 0xf05c4,
93 qml: 0xf375,
94 cpp: 0xe61d,
95 cc: 0xe61d,
96 cxx: 0xe61d,
97 c: 0xe61e,
98 h: 0xf0af5,
99 hpp: 0xf0af5,
100 vue: 0xe6a0,
101 svelte: 0xe697,
102 csv: 0xe64a,
103 txt: 0xf09aa,
104}
105
106export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
107 if (n.kind === 'link') return cp(0xf481)
108 if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
109 const lower = n.name.toLowerCase()
110 const named = NAMES[lower]
111 if (named) return cp(named)
112 const dot = lower.lastIndexOf('.')
113 const ext = dot >= 0 ? lower.slice(dot + 1) : ''
114 return cp(EXTS[ext] ?? 0xf0214)
115}
116
117export const GIT_COLOR: Record<string, string> = {
118 A: '#98c379',
119 '?': '#98c379',
120 R: '#61afef',
121 C: '#61afef',
122 M: '#e5c07b',
123 T: '#e5c07b',
124}
125
126const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
127
128export function stronger(a: string | undefined, b: string): string {
129 if (!a) return b
130 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
131}
132hooks/tree.ts 331 lines1import type { Branch, FileNode, FileTree, Theme } from '../types'
2import { stronger } from './icons'
3
4export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; size: number; isLink: boolean }
5export type Row = { node: FileNode; depth: number; open: boolean }
6
7const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8
9export const DEFAULT_THEME: Theme = {
10 fg: '',
11 accent: '#5b9bd5',
12 muted: '#808a96',
13 urgent: '#d0605e',
14 selection: '#6b7280',
15 bg: '',
16}
17
18export function emptyTree(root: string): FileTree {
19 return {
20 root,
21 nodes: [],
22 expanded: [],
23 cursor: '',
24 selected: '',
25 query: '',
26 showHidden: true,
27 showSize: false,
28 dirSizes: {},
29 git: {},
30 diff: {},
31 ignored: [],
32 untrackedDirs: [],
33 top: '',
34 prefix: '',
35 branch: null,
36 counts: {},
37 flash: [],
38 flashDim: [],
39 flashOn: false,
40 flashTones: {},
41 scroll: null,
42 }
43}
44
45export function parseTheme(toml: string): Theme {
46 const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
47 return {
48 fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
49 accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
50 muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
51 urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
52 selection: get('selection') ?? DEFAULT_THEME.selection,
53 bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
54 }
55}
56
57export function join(dir: string, name: string): string {
58 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
59}
60
61let drives = false
62
63export function dirname(path: string): string {
64 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
65 const i = path.lastIndexOf('/')
66 return i <= 0 ? '/' : path.slice(0, i)
67}
68
69export function useDrives(on: boolean): void {
70 drives = on
71}
72
73export function drivesOn(): boolean {
74 return drives
75}
76
77export function posix(path: string): string {
78 if (!drives) return path
79 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
80}
81
82export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
83 if (dirs.length === 0) return nodes
84 const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
85 const gone = new Set(dirs)
86 return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
87}
88
89export function isAbsolute(path: string): boolean {
90 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
91}
92
93export function inside(root: string, path: string): boolean {
94 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
95}
96
97export function toNodes(dir: string, list: Entry[]): FileNode[] {
98 return list
99 .map(e => ({
100 id: join(dir, e.name),
101 parent: dir,
102 name: e.name,
103 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
104 hidden: e.name.startsWith('.'),
105 mtime: e.mtimeMs,
106 size: e.size,
107 loaded: false,
108 }))
109 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
110}
111
112export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
113 if (listed.size === 0) return nodes
114 const keep = new Map<string, FileNode>()
115 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
116 const freshIds = new Set<string>()
117 for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
118 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
119 const under = (id: string) => {
120 if (gone.size === 0) return false
121 for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
122 return false
123 }
124 const fresh: FileNode[] = []
125 for (const kids of listed.values()) {
126 for (const k of kids) {
127 if (under(k.id)) continue
128 const old = keep.get(k.id)
129 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
130 }
131 }
132 const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
133 return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
134}
135
136export type GitStatus = {
137 git: Record<string, string>
138 ignored: string[]
139 untrackedDirs: string[]
140 branch: Branch | null
141 files: Record<string, Change>
142}
143
144export type Change = 'new' | 'mod' | 'del'
145
146export function parseBranch(record: string): Branch {
147 const body = record.replace(/^## /, '')
148 if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
149 const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
150 const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
151 const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
152 const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
153 return { head, upstream, ahead, behind }
154}
155
156export function mapper(root: string, prefix: string): (rel: string) => string | null {
157 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
158}
159
160export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
161 const map = mapper(root, prefix)
162 const git: Record<string, string> = {}
163 const ignored: string[] = []
164 const untrackedDirs: string[] = []
165 const files: Record<string, Change> = {}
166 let branch: Branch | null = null
167 const parts = stdout.split('\0')
168 for (let i = 0; i < parts.length; i++) {
169 const rec = parts[i] ?? ''
170 if (rec.startsWith('## ')) {
171 branch = parseBranch(rec)
172 continue
173 }
174 if (rec.length < 4) continue
175 const xy = rec.slice(0, 2)
176 const raw = rec.slice(3)
177 if (xy[0] === 'R' || xy[0] === 'C') i++
178 const path = map(raw)
179 if (!path) continue
180 if (xy === '!!') {
181 ignored.push(path)
182 continue
183 }
184 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
185 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
186 const letter =
187 xy === '??'
188 ? '?'
189 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
190 ? 'U'
191 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
192 if (!letter) continue
193 git[path] = stronger(git[path], letter)
194 let dir = dirname(path)
195 while (dir.length >= root.length && dir !== '/') {
196 git[dir] = stronger(git[dir], letter)
197 if (dir === root) break
198 dir = dirname(dir)
199 }
200 }
201 return { git, ignored, untrackedDirs, branch, files }
202}
203
204export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
205 const map = mapper(root, prefix)
206 const parts = stdout.split('\0')
207 for (let i = 0; i < parts.length; i++) {
208 const rec = parts[i] ?? ''
209 if (!rec) continue
210 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
211 if (!m) continue
212 let path = m[3] ?? ''
213 if (path === '') {
214 i += 2
215 path = parts[i] ?? ''
216 }
217 if (!path) continue
218 const add = m[1] === '-' ? 0 : Number(m[1])
219 const del = m[2] === '-' ? 0 : Number(m[2])
220 const abs = map(path)
221 if (!abs) continue
222 const prev = into[abs] ?? [0, 0]
223 into[abs] = [prev[0] + add, prev[1] + del]
224 }
225}
226
227export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
228 const out: Record<string, [number, number]> = { ...diff }
229 for (const [path, [add, del]] of Object.entries(diff)) {
230 let dir = dirname(path)
231 while (dir.length >= root.length && dir !== '/') {
232 const prev = out[dir] ?? [0, 0]
233 out[dir] = [prev[0] + add, prev[1] + del]
234 if (dir === root) break
235 dir = dirname(dir)
236 }
237 }
238 return out
239}
240
241export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
242 const out: Record<string, [number, number, number]> = {}
243 const slot = { new: 0, mod: 1, del: 2 } as const
244 for (const [path, change] of Object.entries(files)) {
245 let dir = dirname(path)
246 while (dir.length >= root.length && dir !== '/') {
247 const cur = out[dir] ?? [0, 0, 0]
248 cur[slot[change]] += 1
249 out[dir] = cur
250 if (dir === root) break
251 dir = dirname(dir)
252 }
253 }
254 return out
255}
256
257export function underAny(id: string, set: Set<string>, root: string): boolean {
258 if (set.size === 0) return false
259 let cur = id
260 while (cur.length >= root.length) {
261 if (set.has(cur)) return true
262 const up = dirname(cur)
263 if (up === cur) break
264 cur = up
265 }
266 return false
267}
268
269export function ancestorsOf(id: string, root: string): string[] {
270 const out: string[] = []
271 if (!root) return out
272 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
273 return out
274}
275
276export function relative(root: string, path: string): string {
277 return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
278}
279
280export function visibleRows(t: FileTree): Row[] {
281 const kids = new Map<string, FileNode[]>()
282 for (const n of t.nodes) {
283 if (!t.showHidden && n.hidden) continue
284 const list = kids.get(n.parent)
285 if (list) list.push(n)
286 else kids.set(n.parent, [n])
287 }
288 const q = t.query.trim().toLowerCase()
289 let keep: Set<string> | null = null
290 if (q) {
291 keep = new Set()
292 for (const n of t.nodes) {
293 if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
294 keep.add(n.id)
295 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
296 }
297 }
298 const open = new Set(t.expanded)
299 const rows: Row[] = []
300 const walk = (dir: string, depth: number) => {
301 for (const n of kids.get(dir) ?? []) {
302 if (keep && !keep.has(n.id)) continue
303 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
304 rows.push({ node: n, depth, open: isOpen })
305 if (isOpen) walk(n.id, depth + 1)
306 }
307 }
308 walk(t.root, 0)
309 return rows
310}
311
312export function stamp(ms: number): string {
313 if (!ms) return ''
314 const d = new Date(ms)
315 const p = (v: number) => String(v).padStart(2, '0')
316 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
317}
318
319export function formatSize(bytes: number): string {
320 if (bytes < 0) return '?'
321 if (bytes < 1024) return `${bytes} B`
322 const units = ['K', 'M', 'G', 'T']
323 let v = bytes / 1024
324 let i = 0
325 while (v >= 1024 && i < units.length - 1) {
326 v /= 1024
327 i++
328 }
329 return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
330}
331types/index.d.ts 74 lines1export type FileNode = {
2 id: string
3 parent: string
4 name: string
5 kind: 'dir' | 'file' | 'link'
6 hidden: boolean
7 mtime: number
8 size: number
9 loaded: boolean
10}
11
12export type Branch = {
13 head: string
14 upstream: string
15 ahead: number
16 behind: number
17}
18
19export type Activity = {
20 id: number
21 kind: string
22 label: string
23 state: 'running' | 'done' | 'failed'
24 detail: string
25 at: number
26 tone: string
27 nerd: string
28 plain: string
29}
30
31export type FileTree = {
32 root: string
33 nodes: FileNode[]
34 expanded: string[]
35 cursor: string
36 selected: string
37 query: string
38 showHidden: boolean
39 showSize: boolean
40 dirSizes: Record<string, number>
41 git: Record<string, string>
42 diff: Record<string, [number, number]>
43 ignored: string[]
44 untrackedDirs: string[]
45 top: string
46 prefix: string
47 branch: Branch | null
48 counts: Record<string, [number, number, number]>
49 flash: string[]
50 flashDim: string[]
51 flashOn: boolean
52 flashTones: Record<string, string>
53 scroll: number | null
54}
55
56export type Theme = {
57 fg: string
58 accent: string
59 muted: string
60 urgent: string
61 selection: string
62 bg: string
63}
64
65declare module 'claude-code' {
66 interface PluginState {
67 filetree: {
68 tree: FileTree
69 theme: Theme
70 activity: Activity[]
71 }
72 }
73}
74