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

A one-line band above the Claude Code prompt, a roomy Cockpit panel beside the conversation, and seven community mods, installed as one plugin. The band shows only what you glance at. The panel has everything else: prompt-cache countdown, context, plan limits with their reset times, session cost, your next tasks and project shortcuts. Everything that spends tokens or redraws your screen starts off.
◔ 42m ctx 72.5k Next Write docs/specs/PLAN-2.md for the phase 2 slices Continue Details
The band is drawn with Claude's own text and buttons, so it takes the app's fonts and theme, truncates the task by itself, and cannot flash or resize. Details (or /cockpit) opens the panel as a tab in the right-hand side panel.
<img src="docs/images/band.png" alt="The band above the prompt in the Claude desktop app: working, context 704.8k at 70 percent, the next task, and a Details button" width="720">
<img src="docs/images/panel.png" alt="The Cockpit panel in the side panel: prompt cache with a green bar, context at 70 percent, plan limits with reset times, session cost, next up and shortcuts" width="320">
These are screenshots of the real desktop app, showing the dark theme.
| Plugin | What it does | Default here | Origin |
|---|---|---|---|
| cockpit | The band, the Cockpit panel, next-task list, Continue / What's next / Compact buttons, guards, and a cheat-sheet skill | on | this repo (MIT) |
| cache-tax | Shows the rewrite price before a cold send; opt-in keep-warm for the prompt cache | warns, never drops; keep-warm off | karanb192/cache-tax |
claude-reflect (reflect-mod) | Notices corrections and offers to save them as rules in CLAUDE.md | paused | BayramAnnakov/claude-reflect |
| skins | Redraws the transcript: tool rows, diff cards, table and code cards, spinner | off, its own band off | hellosverre/claude-skins |
| filetree | IDE-style file tree in the sidebar with git status and read/write activity | opens with /filetree (does not dock by itself) | data-goblin/claude-code-filetree |
| replay-theater | Step through the last turn's file edits, one diff at a time | on | anthropics/claude-code-playground |
| blast-radius | Holds rm -r, git reset --hard, git clean, force push and migrations until you press Proceed | on | anthropics/claude-code-playground |
| savvy-progress | Progress bar and agents panel for flows and subagents | idle until something reports | JohnnyVizz/claude-kit |
The seven community mods are vendored here at pinned commits, with small patches (listed in each folder's SOURCE.md) so they start quiet and stack above each other instead of replacing each other's band. Their original licences are kept.
Needs Claude Code 2.1.287 or later.
/plugin marketplace add cGradying/claude-code-cockpit
/plugin install cockpit@janvin-mods
or from a terminal:
claude plugin marketplace add cGradying/claude-code-cockpit
claude plugin install cockpit@janvin-mods --scope user
cockpit lists the seven mods as dependencies, so installing it installs them all. If a dependency does not install by itself, install it by name, for example claude plugin install skins@janvin-mods. Then restart Claude Code (or the desktop app) and open a new session.
To remove everything: /plugin, pick cockpit, uninstall, and do the same for each mod you no longer want. Each one is independent.
The band. One line above the prompt: the cache countdown (◔ 42m, green, then amber, then red), context, and the next task. The 5-hour and weekly limits join the line only once they pass 60%, and 9router down shows only if it was up and stopped. Buttons sit at the right; click them, or focus the band (ctrl+x then tab) and press the key.
| Button | Key | Does |
|---|---|---|
| Continue | c | Sends "continue with the next task" for the first open line of .claude/NEXT.md, and asks Claude to check it off when done. Hidden while a turn runs. |
| Details | d | Opens the Cockpit panel. |
| Compact | k | Appears past 70% context. Runs /compact and tells it to keep the plan, open tasks, PRs, decisions and open bugs. |
The Cockpit panel. One tab in the side panel with room for each number: prompt cache (time left, a bar, what happens if it lapses), context (tokens, percent, window size, Compact), plan limits (each with a bar and when it resets), this session (cost, 9router), your next five tasks (Continue, What's next?), and your project shortcuts. Bars are solid boxes sized in percent and follow the app's light or dark theme. Open it with Details or /cockpit.
Guards that run quietly. A shell command carrying a Claude or Anthropic co-author line is refused. A prompt containing a pasted API key is held once (press Enter again to send it anyway). Prompts about UI or design get a hidden nudge toward the impeccable skill if you have it.
Commands. /cockpit, /next, /keepwarm, /cache-tax, /reflect-pause, /reflect-queue, /skin, /filetree, /replay, /agents-info. Details in docs/COMMANDS.md; inside Claude Code, the cockpit:help skill prints the same cheat sheet.
Three things in the bundle can cost tokens or change your screen, so they start off:
/keepwarm. The cold-send guard only warns by default: it shows the rewrite price and still sends your message./reflect-pause turns it on. remember: <rule> works either way and needs no model./skin noir turns it on.How to change each: docs/CONFIGURATION.md.
NEXT.md, quick.json./filetree (set autoOpen to on to dock it at session start). It needs a wide window, and upstream says it does not load in WSL sessions of the desktop app.The code in cockpit/ is MIT, Copyright (c) 2026 cGradying (see LICENSE). Each vendored mod keeps its own licence file and authorship: the two Anthropic mods are Apache-2.0, the rest are MIT. See THIRD_PARTY_NOTICES.md. Thanks to the authors of all seven.
hooks/register.tsx 1432 lines1import { type BuiltinToolResults, type EngineInterface, 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 SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
40const BRANCH_ROW = '#branch'
41const FLASH_MS = 2700
42const RUNNING_MAX_MS = 600_000
43const NO_REPO_DEPTH = 4
44const DOUBLE_MS = 450
45const FIND_LIMIT = 200
46const READ_REVEAL_LIMIT = 12
47const SEARCH_REVEAL_LIMIT = 60
48const ACTIVITY_TTL_MS = 45_000
49const ADD_COLOR = '#98c379'
50const DEL_COLOR = '#e06c75'
51const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
52const THEME_POLL_MS = 2000
53const FONT_SCRIPT =
54 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
55 'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
56 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
57 '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 ' +
58 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
59 '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; ' +
60 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
61const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
62const HOME_PRUNE = ['Library', 'AppData', '.Trash']
63const SIZE_TIMEOUT_MS = 15_000
64const SIZE_JOBS = 2
65const SIZE_WALK_LIMIT = 50_000
66
67let blink: Timer | null = null
68let themePoll: Timer | null = null
69let themeMtime: number | null = null
70let generation = 0
71let lastPress = { key: '', at: 0 }
72let noNerd = false
73let glyphSetting = 'auto'
74let follow = true
75let followClaude = true
76let scanning: Promise<void> | null = null
77let scanJobs: Job[] = []
78let gitRun: Promise<void> | null = null
79let gitNext: Promise<void> | null = null
80let queuedReads: string[] = []
81let showReads = true
82let showWrites = true
83let searchIndex: { root: string; paths: Promise<string[]> } | null = null
84let activityId = 0
85let pointer = true
86let view = { from: 0, max: 0 }
87let lastSync = 0
88let noDock = false
89let home = ''
90let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
91let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
92let markId = 0
93let listSeq = 0
94let lastRoot = ''
95let sizeDefault = false
96let sizeEpoch = 0
97let sizeActive = 0
98let sizeQueue: string[] = []
99const sizing = new Set<string>()
100const listLatest = new Map<string, number>()
101const unreadable = new Set<string>()
102const background = new Map<string, Background>()
103
104function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
105 platform ??= (async () => {
106 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
107 try {
108 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
109 } catch {
110 return 'linux'
111 }
112 })()
113 return platform
114}
115
116async function cwdOf($: EngineInterface): Promise<string> {
117 return posix(await $.session.cwd())
118}
119
120function clean(segs: Seg[]): Seg[] {
121 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
122 return segs
123}
124
125function faint(hex: string): string {
126 const v = parseInt(hex.slice(1), 16)
127 const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
128 return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
129}
130
131async function get($: EngineInterface): Promise<FileTree> {
132 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
133}
134
135async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
136 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
137}
138
139function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
140 return put($, t => ({ ...t, ...fn(t) }))
141}
142
143async function activities($: EngineInterface): Promise<Activity[]> {
144 return (await $.state.get(ACTIVITY)).value ?? []
145}
146
147async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
148 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
149}
150
151async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
152 try {
153 const entries = await $.fs.list(dir)
154 const resolved = await Promise.all(
155 entries.map(async e => {
156 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
157 try {
158 const target = await $.fs.stat(join(dir, e.name))
159 return target.kind === 'dir'
160 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
161 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
162 } catch {
163 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
164 }
165 }),
166 )
167 unreadable.delete(dir)
168 return toNodes(dir, resolved)
169 } catch (err) {
170 if (!(await exists($, dir))) return []
171 if (!unreadable.has(dir)) {
172 unreadable.add(dir)
173 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
174 }
175 return null
176 }
177}
178
179async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
180 const root = (await get($)).root
181 const seq = ++listSeq
182 for (const dir of dirs) listLatest.set(dir, seq)
183 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
184 const listed = new Map<string, FileNode[]>()
185 for (const [dir, kids] of results) {
186 if (listLatest.get(dir) !== seq) continue
187 listLatest.delete(dir)
188 if (kids) listed.set(dir, kids)
189 }
190 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
191 return listed
192}
193
194async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
195 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
196}
197
198async function detectRepo($: EngineInterface): Promise<void> {
199 const t = await get($)
200 if (!t.root) return
201 let top = ''
202 let prefix = ''
203 try {
204 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
205 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
206 prefix = p.trim()
207 top = tl.trim()
208 } catch {
209 top = ''
210 }
211 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
212}
213
214async function readGit($: EngineInterface): Promise<void> {
215 const t = await get($)
216 if (!t.root || !t.top) return
217 const root = t.root
218 try {
219 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
220 if (status.exitCode !== 0) return
221 const parsed = parseGit(status.stdout, root, t.prefix)
222 const diff: Record<string, [number, number]> = {}
223 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
224 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
225 const files = { ...parsed.files }
226 if (parsed.untrackedDirs.length) {
227 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
228 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
229 }
230 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
231 dirty = { root, files }
232 await patch($, cur =>
233 cur.root === root
234 ? {
235 git: parsed.git,
236 ignored: parsed.ignored,
237 untrackedDirs: parsed.untrackedDirs,
238 branch: parsed.branch,
239 counts: rollCounts(files, root),
240 diff: rollUp(diff, root),
241 }
242 : {},
243 )
244 } catch {
245 return
246 }
247}
248
249function refreshGit($: EngineInterface): Promise<void> {
250 if (!gitRun) {
251 gitRun = readGit($).finally(() => {
252 gitRun = null
253 })
254 return gitRun
255 }
256 const rerun = () => {
257 gitNext = null
258 return refreshGit($)
259 }
260 gitNext ??= gitRun.then(rerun, rerun)
261 return gitNext
262}
263
264async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
265 const prev = await get($)
266 generation += 1
267 blink?.cancel()
268 blink = null
269 lastRoot = root
270 searchIndex = null
271 sizeEpoch += 1
272 sizeQueue = []
273 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
274 const title = `Files: ${root.split('/').pop() || root}`
275 if (focus) await $.ui.open({ id: PANE, title, focus: true })
276 else if (!noDock) await $.ui.open({ id: PANE, title })
277 await loadDirs($, [root])
278 await detectRepo($)
279 await refreshGit($)
280}
281
282async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
283 const tried = new Set<string>()
284 for (let pass = 0; pass < 32; pass++) {
285 const t = await get($)
286 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
287 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
288 const need = new Set<string>()
289 for (const p of paths) {
290 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
291 if (!loaded.has(dir)) {
292 if (!tried.has(dir)) need.add(dir)
293 break
294 }
295 }
296 }
297 if (need.size === 0) return
298 for (const dir of need) tried.add(dir)
299 await loadDirs($, [...need])
300 }
301}
302
303function pruneArgs(root: string, ignored: string[] = []): string[] {
304 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
305 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
306}
307
308async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
309 const stats = await Promise.all(
310 paths.map(async p => {
311 try {
312 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
313 } catch {
314 return ''
315 }
316 }),
317 )
318 return stats.filter(Boolean)
319}
320
321function broad(root: string): boolean {
322 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
323}
324
325function rootOfDisk(root: string): boolean {
326 return root === '/' || /^[A-Za-z]:\/$/.test(root)
327}
328
329async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
330 if (rootOfDisk(root)) return []
331 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
332 try {
333 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 })
334 return run.stdout.split('\0').filter(p => p && p !== root)
335 } catch {
336 return []
337 }
338}
339
340async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
341 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
342 if (dirty.root !== root) return { hits: [], gone: [] }
343 const entries = Object.entries(dirty.files)
344 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
345 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
346 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
347}
348
349type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
350
351async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
352 const ms = await $.clock.now()
353 const os = await osName($)
354 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
355 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
356 try {
357 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
358 return { ms, mark, os }
359 } catch {
360 return { ms, mark: '', os }
361 }
362}
363
364async function walkSize($: EngineInterface, dir: string): Promise<number> {
365 let total = 0
366 let seen = 0
367 let level = [dir]
368 while (level.length) {
369 const next: string[] = []
370 for (const d of level) {
371 let entries
372 try {
373 entries = await $.fs.list(d)
374 } catch {
375 continue
376 }
377 for (const e of entries) {
378 if (++seen > SIZE_WALK_LIMIT) return -1
379 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
380 else if (e.kind === 'file') total += e.size
381 }
382 }
383 level = next
384 }
385 return total
386}
387
388async function dirSize($: EngineInterface, dir: string): Promise<number> {
389 if (rootOfDisk(dir)) return -1
390 if ((await osName($)) === 'win32') return walkSize($, dir)
391 try {
392 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
393 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
394 return Number.isFinite(kb) ? kb * 1024 : -1
395 } catch {
396 return -1
397 }
398}
399
400function pumpSizes($: EngineInterface): void {
401 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
402 const dir = sizeQueue.shift() ?? ''
403 const epoch = sizeEpoch
404 sizing.add(dir)
405 sizeActive += 1
406 void dirSize($, dir)
407 .then(async bytes => {
408 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
409 const cur = await get($)
410 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
411 })
412 .catch(() => undefined)
413 .finally(() => {
414 sizing.delete(dir)
415 sizeActive -= 1
416 pumpSizes($)
417 })
418 }
419}
420
421function wantSizes($: EngineInterface, dirs: string[]): void {
422 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
423 pumpSizes($)
424}
425
426async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
427 sizeEpoch += 1
428 sizeQueue = []
429 await patch($, cur => {
430 if (!paths) return { dirSizes: {} }
431 const keep: Record<string, number> = {}
432 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
433 return { dirSizes: keep }
434 })
435}
436
437function openDirs(t: FileTree): string[] {
438 const open = new Set(t.expanded)
439 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
440}
441
442function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
443 if (followClaude) return { scroll: null }
444 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
445 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
446 return at < 0 ? {} : { scroll: at }
447}
448
449async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
450 const unique = Object.keys(tones)
451 if (unique.length === 0) return
452 const mine = ++generation
453 const root = (await get($)).root
454 blink?.cancel()
455 blink = null
456 await patch($, cur => {
457 if (cur.root !== root) return {}
458 const open = new Set(cur.expanded)
459 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
460 const dim = new Set<string>()
461 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
462 if (cur.flashOn) {
463 for (const id of cur.flashDim) dim.add(id)
464 }
465 for (const id of unique) {
466 const tone = tones[id] ?? 'orange'
467 all[id] = tone
468 if (id === BRANCH_ROW) continue
469 const chain = ancestorsOf(id, cur.root)
470 if (!chain.some(a => !open.has(a))) continue
471 for (const a of chain) {
472 if (!open.has(a)) {
473 dim.add(a)
474 all[a] = all[a] ?? tone
475 }
476 open.add(a)
477 }
478 }
479 for (const id of bright) dim.delete(id)
480 return {
481 flash: [...bright],
482 flashDim: [...dim],
483 flashOn: true,
484 flashTones: all,
485 expanded: [...open],
486 ...keepTop(cur, [...open]),
487 }
488 })
489 if (generation !== mine) return
490 blink = $.clock.after(FLASH_MS, () => {
491 if (generation !== mine) return
492 blink = null
493 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
494 })
495}
496
497async function followCwd($: EngineInterface): Promise<boolean> {
498 if (!follow) return false
499 const cwd = await cwdOf($)
500 const t = await get($)
501 if (t.root === cwd) return false
502 await reset($, cwd)
503 return true
504}
505
506type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
507type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
508type Background = { p: Pending | null; job: Job; reads: string[] }
509type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
510type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
511
512const PROOF: Record<string, (op: GitOperation) => boolean> = {
513 push: op => Boolean(op.push),
514 merge: op => op.branch?.action === 'merged',
515 rebase: op => op.branch?.action === 'rebased',
516 'pr create': op => op.pr?.action === 'created',
517 'pr merge': op => op.pr?.action === 'merged',
518 'pr comment': op => op.pr?.action === 'commented',
519}
520
521async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
522 const now = await $.clock.now()
523 const ids = actions.map(() => ++activityId)
524 await setActivities($, cur => [
525 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
526 ...actions.map((a, i) => ({
527 id: ids[i] ?? 0,
528 kind: a.kind,
529 label: a.running,
530 state: 'running' as const,
531 detail: '',
532 at: now,
533 tone: a.tone,
534 nerd: a.icon.nerd,
535 plain: a.icon.plain,
536 })),
537 ])
538 return ids
539}
540
541async function headOf($: EngineInterface, cwd: string): Promise<string> {
542 try {
543 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
544 return run.exitCode === 0 ? run.stdout.trim() : ''
545 } catch {
546 return ''
547 }
548}
549
550async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
551 const chain = chainOf(p.command)
552 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
553 return p.actions.map(a => {
554 if (a.verb === 'commit') {
555 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
556 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
557 }
558 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
559 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
560 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
561 })
562}
563
564async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
565 const now = await $.clock.now()
566 await setActivities($, cur =>
567 cur.map(a => {
568 const i = p.ids.indexOf(a.id)
569 const result = i < 0 ? undefined : results[i]
570 return result ? { ...a, ...result, at: now } : a
571 }),
572 )
573 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
574}
575
576function failAll(p: Pending): Outcome[] {
577 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
578}
579
580function proven(p: Pending | null, results: Outcome[]): GitAction[] {
581 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
582}
583
584function taskEnds(text: string): [string, string][] {
585 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
586 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
587 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
588 return id && status ? [[id, status] as [string, string]] : []
589 })
590}
591
592async function settleBackground($: EngineInterface, text: string): Promise<void> {
593 if (background.size === 0 || !text.includes('<task-notification>')) return
594 for (const [id, status] of taskEnds(text)) {
595 const task = background.get(id)
596 if (!task) continue
597 background.delete(id)
598 const ok = status === 'completed'
599 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
600 if (task.p) await finishGit($, task.p, results)
601 if (showReads) queuedReads.push(...task.reads)
602 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
603 }
604}
605
606async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
607 const map = mapper(root, prefix)
608 try {
609 const run = committed
610 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
611 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
612 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
613 } catch {
614 return []
615 }
616}
617
618async function gitDir($: EngineInterface, cwd: string): Promise<string> {
619 for (let dir = cwd, i = 0; i < 64; i++) {
620 try {
621 const dot = join(dir, '.git')
622 const st = await $.fs.stat(dot)
623 if (st.kind === 'dir') return dot
624 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
625 if (ref) return resolve(dir, ref, home)
626 } catch {
627 const up = dirname(dir)
628 if (up === dir) return ''
629 dir = up
630 continue
631 }
632 return ''
633 }
634 return ''
635}
636
637async function sync($: EngineInterface, force = false): Promise<void> {
638 const now = await $.clock.now()
639 if (!force && now - lastSync < 2_000) return
640 lastSync = now
641 searchIndex = null
642 const t = await get($)
643 if (!t.root) return
644 await loadDirs($, [t.root, ...openDirs(t)])
645 if (t.top) await refreshGit($)
646}
647
648async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
649 const t = await get($)
650 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
651 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
652 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
653}
654
655async function exists($: EngineInterface, path: string): Promise<boolean> {
656 try {
657 await $.fs.stat(path)
658 return true
659 } catch {
660 return false
661 }
662}
663
664async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
665 const reads = queuedReads
666 queuedReads = []
667 if (await followCwd($)) return
668 const t = await get($)
669 if (!t.root) return
670 const actions = jobs.flatMap(j => j.actions)
671 const writers = jobs.filter(j => !j.readOnly)
672 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 })
673 const writes = !jobs.every(j => j.readOnly)
674 const before = dirty.root === t.root ? dirty.files : {}
675 if (jobs.some(j => j.initRepo)) await detectRepo($)
676 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
677 const probed = await get($)
678 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
679 if (writes) {
680 searchIndex = null
681 await staleSizes($)
682 }
683 const fresh = await get($)
684 const ignored = new Set(fresh.ignored)
685 const tones: Record<string, string> = {}
686 if (writes) {
687 const found = fresh.top
688 ? await changedInRepo($, t.root, since, before, fresh.ignored)
689 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
690 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
691 await revealPaths($, hits)
692 const loaded = await get($)
693 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
694 const listed = await loadDirs($, dirs)
695 if (!fresh.top && since.os === 'win32')
696 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
697 await patch($, cur => {
698 const ids = new Set(cur.nodes.map(n => n.id))
699 return { expanded: cur.expanded.filter(id => ids.has(id)) }
700 })
701 const present = new Set((await get($)).nodes.map(n => n.id))
702 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
703 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
704 }
705 if (showReads) {
706 const found: string[] = []
707 for (const r of reads) {
708 if (found.length >= READ_REVEAL_LIMIT) break
709 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
710 }
711 if (found.length) {
712 await revealPaths($, found)
713 const shown = new Set((await get($)).nodes.map(n => n.id))
714 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
715 }
716 }
717 if (showWrites && actions.length) {
718 const final = await get($)
719 const committed = actions.some(a => a.verb === 'commit')
720 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
721 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
722 await revealPaths($, paths)
723 const shown = new Set((await get($)).nodes.map(n => n.id))
724 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
725 }
726 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
727 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
728 else if (committed) tones[BRANCH_ROW] = 'green'
729 }
730 await flash($, tones)
731}
732
733function scheduleScan($: EngineInterface, job: Job): void {
734 scanJobs.push(job)
735 if (scanning) return
736 scanning = (async () => {
737 while (scanJobs.length) {
738 const jobs = scanJobs
739 scanJobs = []
740 try {
741 await afterBash($, jobs)
742 } finally {
743 const marks = jobs.map(j => j.since.mark).filter(Boolean)
744 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
745 }
746 }
747 })().finally(() => {
748 scanning = null
749 })
750}
751
752async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
753 if (await followCwd($)) return
754 const t = await get($)
755 if (!t.root) return
756 const within = paths.map(posix).filter(p => inside(t.root, p))
757 if (within.length === 0) return
758 if (tone !== 'purple') {
759 searchIndex = null
760 await staleSizes($, within)
761 await revealPaths($, within.map(dirname))
762 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
763 await refreshGit($)
764 } else await revealPaths($, within)
765 if (!show) return
766 const tones: Record<string, string> = {}
767 for (const p of within) tones[p] = tone
768 await flash($, tones)
769}
770
771async function reveal($: EngineInterface, paths: string[]): Promise<void> {
772 if (await followCwd($)) return
773 await revealPaths($, paths)
774 await patch($, cur => {
775 const open = new Set(cur.expanded)
776 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
777 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
778 })
779}
780
781async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
782 const out: string[] = []
783 let level = [root]
784 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
785 const next: string[] = []
786 for (const dir of level) {
787 for (const n of (await list($, dir)) ?? []) {
788 if (n.kind === 'dir') {
789 if (!PRUNE.includes(n.name)) next.push(n.id)
790 } else out.push(n.id)
791 }
792 if (out.length >= limit) break
793 }
794 level = next
795 }
796 return out
797}
798
799async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
800 try {
801 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
802 const run = t.top
803 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
804 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
805 if (t.top && run.exitCode !== 0) return null
806 const paths = run.stdout.split('\0').filter(Boolean)
807 if (run.isStdoutTruncated) paths.pop()
808 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
809 } catch {
810 return null
811 }
812}
813
814function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
815 if (searchIndex?.root !== t.root) {
816 const index: { root: string; paths: Promise<string[]> } = {
817 root: t.root,
818 paths: listAll($, t).then(paths => {
819 if (paths === null && searchIndex === index) searchIndex = null
820 return paths ?? []
821 }),
822 }
823 searchIndex = index
824 }
825 return searchIndex.paths
826}
827
828async function search($: EngineInterface, query: string): Promise<string[]> {
829 if (!(await get($)).query.trim()) searchIndex = null
830 await patch($, () => ({ query }))
831 const q = query.trim().toLowerCase()
832 if (!q) return []
833 const t = await get($)
834 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
835 if ((await get($)).query !== query) return []
836 await revealPaths($, hits)
837 const shown = await get($)
838 const ids = new Set(shown.nodes.map(n => n.id))
839 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
840 if (stale.length) await loadDirs($, stale)
841 return hits
842}
843
844async function jump($: EngineInterface, query: string): Promise<void> {
845 const hits = (await search($, query)).slice(0, 10)
846 await patch($, cur => {
847 const open = new Set(cur.expanded)
848 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
849 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
850 })
851}
852
853async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
854 try {
855 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
856 return out === 'stale' || out === 'missing' ? out : 'ok'
857 } catch {
858 return 'missing'
859 }
860}
861
862async function finePointerOk($: EngineInterface): Promise<boolean> {
863 if (!(await $.env.get('HERDR_ENV'))) return true
864 try {
865 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
866 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
867 return major * 1e6 + minor * 1e3 + fix >= 9_001
868 } catch {
869 return false
870 }
871}
872
873async function openFile($: EngineInterface, path: string): Promise<void> {
874 const { argv, init } = openCommand(await osName($), path)
875 try {
876 const run = await $.process.run(argv, init)
877 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
878 } catch {
879 $.ui.toast(`could not open ${path} with ${argv[0]}`)
880 }
881}
882
883async function toggle($: EngineInterface, n: FileNode): Promise<void> {
884 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
885 await patch($, t => {
886 const open = new Set(t.expanded)
887 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
888 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
889 })
890}
891
892async function press($: EngineInterface, n: FileNode): Promise<void> {
893 const now = await $.clock.now()
894 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
895 lastPress = { key: isDouble ? '' : n.id, at: now }
896 if (!isDouble) {
897 await toggle($, n)
898 return
899 }
900 await openNode($, n)
901}
902
903async function openNode($: EngineInterface, n: FileNode): Promise<void> {
904 if (n.kind === 'dir') {
905 follow = false
906 await reset($, n.id)
907 } else await openFile($, n.id)
908}
909
910async function loadTheme($: EngineInterface): Promise<void> {
911 const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
912 try {
913 const stat = await $.fs.stat(path)
914 if (stat.kind !== 'file') throw new Error('no theme file')
915 if (stat.mtimeMs === themeMtime) return
916 await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
917 themeMtime = stat.mtimeMs
918 } catch {
919 if (themeMtime === 0) return
920 await $.state.set(THEME, DEFAULT_THEME)
921 themeMtime = 0
922 }
923}
924
925function shortPath(path: string): string {
926 return home && inside(home, path) ? `~${path.slice(home.length)}` : path
927}
928
929export const register: Register = (on, options) => {
930 glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
931 const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
932 showReads = activity.includes('reads')
933 showWrites = activity.includes('writes')
934 followClaude = options?.follow !== 'off'
935 // cockpit patch: do not dock a sidebar by itself. /filetree opens it; autoOpen 'on' restores upstream's behaviour.
936 noDock = options?.autoOpen !== 'on'
937 sizeDefault = options?.column === 'size'
938 on('session.start', async ($, e, next) => {
939 await $.command.register({ name: 'filetree', description: 'Show the file tree; args: [path] (no path = cwd)' })
940 const windows = (await $.env.get('OS')) === 'Windows_NT'
941 useDrives(windows)
942 home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
943 activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
944 void (async () => {
945 pointer = await finePointerOk($)
946 try {
947 const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
948 noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
949 } catch {
950 noNerd = true
951 }
952 await loadTheme($)
953 themePoll?.cancel()
954 themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
955 const t = await get($)
956 if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
957 await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
958 const cwd = await cwdOf($)
959 if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
960 else if (!noDock) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
961 })()
962 return next(e)
963 })
964
965 on('command.run', { command: 'filetree' }, async ($, e) => {
966 if (!e.presentation.isFullscreen) return { text: 'filetree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /filetree.' }
967 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.' }
968 noDock = false
969 const arg = (e.args ?? '').trim()
970 const cwd = await cwdOf($)
971 follow = !arg
972 const root = arg ? resolve(cwd, arg, home) : cwd
973 await reset($, root, true)
974 return { text: `File tree on ${shortPath(root)}${follow ? ' (follows the cwd)' : ''}.` }
975 })
976
977 on('tool.call', async ($, e, next) => {
978 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && e.tool !== 'Bash') return next(e)
979 const command = e.tool === 'Bash' ? e.command : ''
980 const cwd = e.tool === 'Bash' ? await cwdOf($) : ''
981 const actions = gitActions(command)
982 const quiet = !actions.length && readOnly(command)
983 const since = await sinceNow($, e.tool === 'Bash' && !quiet)
984 const before = actions.some(a => a.verb === 'commit') ? await get($) : null
985 const head = before?.top ? await headOf($, before.root) : ''
986 const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
987 let result: Awaited<ReturnType<typeof next>>
988 try {
989 result = await next(e)
990 } catch (err) {
991 if (pending) await finishGit($, pending, failAll(pending))
992 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
993 throw err
994 }
995 if (pending && result.deny) {
996 const ids = pending.ids
997 void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
998 }
999 if (result.deny || (result.isError && e.tool !== 'Bash')) {
1000 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1001 return result
1002 }
1003 if (e.tool === 'Bash') {
1004 const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? result.result : {}
1005 const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1006 const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1007 if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1008 else void (async () => {
1009 const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1010 if (pending) await finishGit($, pending, results)
1011 queuedReads.push(...reads)
1012 scheduleScan($, { ...job, actions: proven(pending, results) })
1013 })()
1014 if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1015 } else {
1016 const file =
1017 'file_path' in e && typeof e.file_path === 'string'
1018 ? e.file_path
1019 : 'notebook_path' in e && typeof e.notebook_path === 'string'
1020 ? e.notebook_path
1021 : ''
1022 if (file) void touched($, [file], 'orange', showWrites)
1023 }
1024 return result
1025 })
1026
1027 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1028 const result = await next(e)
1029 if (result.deny || result.isError || e.tool !== 'Read' || !showReads) return result
1030 void touched($, [posix(e.file_path)], 'purple', true)
1031 return result
1032 })
1033
1034 on('ui.message', async ($, e, next) => {
1035 if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
1036 const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
1037 void sync($)
1038 const t = await get($)
1039 if (typeof data.copy === 'string') {
1040 await copyPath($, data.copy, Boolean(data.shift), e.surface)
1041 return {}
1042 }
1043 if (typeof data.scrollTo === 'number') {
1044 const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * view.max)
1045 if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1046 return {}
1047 }
1048 if (typeof data.press === 'string') {
1049 const n = t.nodes.find(x => x.id === data.press)
1050 if (!n) return {}
1051 if (t.scroll === null) await patch($, () => ({ scroll: view.from }))
1052 if (data.ctrl || data.shift) await openNode($, n)
1053 else await press($, n)
1054 return {}
1055 }
1056 if (typeof data.key !== 'string') return {}
1057 const rows = visibleRows(t)
1058 const at = rows.findIndex(r => r.node.id === t.cursor)
1059 const cur = rows[at]?.node
1060 const move = (d: number) => {
1061 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
1062 return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
1063 }
1064 if (data.key === 'up' || data.key === 'k') await move(-1)
1065 else if (data.key === 'down' || data.key === 'j') await move(1)
1066 else if (data.key === 'pageup') await move(-10)
1067 else if (data.key === 'pagedown') await move(10)
1068 else if (data.key === 'home') await move(-rows.length)
1069 else if (data.key === 'end') await move(rows.length)
1070 else if (cur && (data.key === 'y' || data.key === 'Y')) await copyPath($, cur.id, data.key === 'Y' || Boolean(data.shift), e.surface)
1071 else if (cur && (data.key === 'right' || data.key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
1072 else if (cur && (data.key === 'left' || data.key === 'h')) {
1073 if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1074 else if (cur.parent !== t.root) await patch($, () => ({ cursor: cur.parent }))
1075 } else if (cur && data.key === 'return') await (cur.kind !== 'dir' ? openNode($, cur) : toggle($, cur))
1076 else if (cur && data.key === ' ') await toggle($, cur)
1077 return {}
1078 })
1079
1080 on('classic.SessionStart', async ($, e, next) => {
1081 const result = await next(e)
1082 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
1083 void (async () => {
1084 await loadTheme($)
1085 const root = follow || !lastRoot ? await cwdOf($) : lastRoot
1086 const t = await get($)
1087 if (t.root !== root || t.nodes.length === 0) await reset($, root)
1088 })()
1089 }
1090 const gd = await gitDir($, posix(e.cwd))
1091 return gd ? { ...result, watchPaths: [...(result.watchPaths ?? []), join(gd, 'index'), join(gd, 'HEAD')] } : result
1092 })
1093
1094 on('classic.CwdChanged', async ($, e, next) => {
1095 const result = await next(e)
1096 void followCwd($)
1097 return result
1098 })
1099
1100 on('classic.FileChanged', async ($, e, next) => {
1101 const result = await next(e)
1102 if (/[\\/]\.git[\\/]|[\\/](index|HEAD)$/.test(e.file_path)) $.clock.after(300, () => void sync($, true))
1103 return result
1104 })
1105
1106 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1107 const result = await next(e)
1108 void sync($)
1109 return result
1110 })
1111
1112 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
1113 const t = await get($)
1114 const to = Math.max(0, Math.min(view.max, (t.scroll ?? view.from) + Math.sign(e.by) * Math.max(3, Math.abs(e.by))))
1115 if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1116 return {}
1117 })
1118
1119 on('prompt.submit', async ($, e, next) => {
1120 void settleBackground($, e.text)
1121 if (!(await followCwd($))) void sync($)
1122 const t = await get($)
1123 const context = [...(e.context ?? [])]
1124 if (t.selected && (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}`)
1125 const mentions = [...e.text.matchAll(/@([^\s"'`]+)/g)]
1126 .map(m => (m[1] ?? '').replace(/[.,;:!?)]+$/, ''))
1127 .filter(Boolean)
1128 .map(p => resolve(t.root, p, home))
1129 .filter(p => inside(t.root, p))
1130 if (mentions.length) {
1131 void (async () => {
1132 const found: string[] = []
1133 for (const p of mentions) {
1134 try {
1135 await $.fs.stat(p)
1136 found.push(p.replace(/\/$/, ''))
1137 } catch {
1138 continue
1139 }
1140 }
1141 if (found.length) await reveal($, found)
1142 })()
1143 }
1144 return next(context.length ? { ...e, context } : e)
1145 })
1146
1147 on('prompt.attachment', async ($, e, next) => {
1148 void settleBackground($, e.text)
1149 return next(e)
1150 })
1151
1152 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1153 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
1154 if (e.surface === 'terminal' && e.props.placement === 'inline') {
1155 noDock = true
1156 void $.ui.close({ id: PANE }).catch(() => undefined)
1157 const { Box: Empty } = $.ui.resolve(e)
1158 return <Empty />
1159 }
1160 const unicode = glyphSetting === 'plain' || (glyphSetting === 'auto' && (noNerd || e.surface === 'desktop'))
1161 const { Box, Text, Button, Input, Client } = $.ui.resolve(e)
1162 const t = await get($)
1163 const theme: Theme = (await $.state.get(THEME)).value ?? DEFAULT_THEME
1164 const now = await $.clock.now()
1165 const live = (await activities($)).filter(a => now - a.at < (a.state === 'running' ? RUNNING_MAX_MS : ACTIVITY_TTL_MS))
1166 const latest = [...live].reverse().find(a => a.state === 'running') ?? live[live.length - 1]
1167 const bright = new Set(t.flashOn ? t.flash : [])
1168 const dimmed = new Set(t.flashOn ? t.flashDim : [])
1169 const ignored = new Set(t.ignored)
1170 const untracked = new Set(t.untrackedDirs)
1171 const width = Math.max(24, e.props.bodyColumns)
1172 const rows = visibleRows(t)
1173 const fixed = 2 + (t.top ? (t.branch ? 1 : 0) : 1) + (t.selected || latest ? 1 : 0)
1174 const room = Math.max(5, (e.props.scroll?.bodyRows ?? 40) - fixed)
1175 const isLit = (id: string) => bright.has(id) || dimmed.has(id)
1176 const focus = followClaude && t.flashOn ? ([...t.flash].reverse().find(id => id !== BRANCH_ROW) ?? t.cursor) : t.cursor
1177 const at = Math.max(0, rows.findIndex(r => r.node.id === focus))
1178 const lit = followClaude && t.flashOn ? rows.findIndex(r => isLit(r.node.id)) : -1
1179 const cap = Math.max(1, Math.floor(room / 3))
1180 let from = Math.max(0, Math.min(lit >= 0 && at - lit < room - 2 ? Math.max(0, lit - 1) : at - Math.floor(room / 2), rows.length - room))
1181 let pinned = followClaude && t.flashOn ? rows.slice(0, from).filter(r => isLit(r.node.id)).slice(-cap) : []
1182 if (pinned.length) {
1183 const rest = Math.max(3, room - pinned.length)
1184 from = Math.max(0, Math.min(at - Math.floor(rest / 2), rows.length - rest))
1185 pinned = rows.slice(0, from).filter(r => isLit(r.node.id)).slice(-cap)
1186 }
1187 const max = Math.max(0, rows.length - room)
1188 if (t.scroll !== null) {
1189 from = Math.max(0, Math.min(t.scroll, max))
1190 pinned = []
1191 }
1192 view = { from, max }
1193 const shown = rows.slice(from, from + room - pinned.length)
1194 if (t.showSize) wantSizes($, [...pinned, ...shown].filter(r => r.node.kind === 'dir' && !(r.node.id in t.dirSizes)).map(r => r.node.id))
1195 const totals: [number, number] = t.top ? (t.diff[t.root] ?? [0, 0]) : [0, 0]
1196 const countSegs = (c: [number, number, number] | undefined): Seg[] => {
1197 if (!c) return []
1198 const out: Seg[] = []
1199 if (c[0] > 0) out.push({ t: ` ?:${c[0]}`, c: GIT_COLOR['?'] ?? ADD_COLOR })
1200 if (c[1] > 0) out.push({ t: ` M:${c[1]}`, c: GIT_COLOR.M ?? '#e5c07b' })hooks/open.ts 41 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
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28 const argv = os === 'linux'
29 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30 : os === 'darwin'
31 ? ['open', '--', target]
32 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33 return {
34 argv,
35 init: {
36 timeoutMs: 10_000,
37 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38 },
39 }
40}
41hooks/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