A themed IDE-style file tree for Claude Code: git status, lines changed, and a shimmer on the files Claude reads and writes, in six color presets

오른쪽 사이드바에 작업 폴더의 파일 트리를 띄우는 모드입니다. git 상태와 바뀐 줄 수를 보여 주고, Claude가 읽고 쓰고 커밋하는 파일을 반짝임으로 표시합니다. 색은 6가지 테마 중에서 고릅니다.
Claude Code 2.1.287 이상, 전체화면 레이아웃(/tui fullscreen), 가로 110칸 이상의 터미널이 필요합니다.
/plugin marketplace add KKK12142/claude-mods
/plugin install prism-tree@claude-mods
설치 후 /prism-tree를 실행합니다. /prism-tree <경로>로 다른 폴더를 고정할 수도 있습니다.
| 명령 | 동작 |
|---|---|
/prism-tree off | 패널을 닫고 파일 감시·git 조회를 멈춤. 새 세션에서도 꺼진 채로 시작 |
/prism-tree on | 다시 켜고 현재 작업 폴더로 패널을 엶 |
/prism-tree, /prism-tree <경로> | 꺼져 있었다면 켜면서 엶 |
on·off라는 이름의 폴더를 열려면 ./on처럼 경로로 씁니다.
| 테마 | 느낌 |
|---|---|
aurora (기본) | 청록 강조, 차가운 파스텔 |
ember | 호박색 강조, 따뜻한 색조 |
forest | 라임 강조, 녹색 계열 |
mono | 회색조, 삭제와 실패만 빨강 |
paper | 밝은 터미널용, 짙은 청록 강조 |
classic | 원본 filetree 색 |
/config의 prism-tree 항목에서 Theme을 고르거나, 패널 머리줄의 ❖ 버튼으로 순서대로 바꿉니다. 버튼으로 고른 테마는 Theme 설정을 바꾸기 전까지 다음 세션에도 유지됩니다.
활동별 색(읽기, 쓰기, 커밋, push, pull, 실패)은 테마 안에서 각자 다른 톤으로 표시됩니다.
모두 /config의 prism-tree 항목에 있습니다.
aurora, ember, forest, mono, paper, classicreads and writes(기본), writes, reads, noneon(기본)이면 Claude가 건드린 파일로 트리가 따라가고, off면 보던 위치를 유지date(기본) 또는 sizeauto, nerd, plainclaude-code-filetree(Kurt Buhler, MIT 라이선스)를 바탕으로 만들었습니다(LICENSE 참고). 트리, git, 활동 감지 로직은 원본에서 왔고, 이 버전은 고정 색과 Omarchy 테마 파일 읽기를 내장 테마 프리셋과 테마 전환 버튼으로 바꿨습니다.
hooks/register.tsx 1475 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 } from './git'
6import type { RowSpec, RowsProps, Seg } from './rows'
7import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon } from './icons'
8import { DEFAULT_PRESET, gitColors, isPreset, nextPreset, palette } from './themes'
9import {
10 ancestorsOf,
11 type Change,
12 dirname,
13 emptyTree,
14 formatSize,
15 inside,
16 isAbsolute,
17 join,
18 mapper,
19 parseGit,
20 parseNumstat,
21 posix,
22 relative,
23 dropBelow,
24 useDrives,
25 rollCounts,
26 replaceChildren,
27 rollUp,
28 stamp,
29 toNodes,
30 underAny,
31 visibleRows,
32} from './tree'
33
34const TREE = { plugin: 'prism-tree', key: 'tree' } as const
35const THEME = { plugin: 'prism-tree', key: 'theme' } as const
36const ACTIVITY = { plugin: 'prism-tree', key: 'activity' } as const
37const PANE = 'prism-tree'
38const THEME_STORE = 'theme'
39const ENABLED_STORE = 'enabled'
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 FONT_SCRIPT =
50 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
51 'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
52 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
53 '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 ' +
54 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
55 '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; ' +
56 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
57const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
58const HOME_PRUNE = ['Library', 'AppData', '.Trash']
59const SIZE_TIMEOUT_MS = 15_000
60const SIZE_JOBS = 2
61const SIZE_WALK_LIMIT = 50_000
62
63let blink: Timer | null = null
64let optionTheme = DEFAULT_PRESET
65let generation = 0
66let lastPress = { key: '', at: 0 }
67let noNerd = false
68let glyphSetting = 'auto'
69let follow = true
70let followClaude = true
71let scanning: Promise<void> | null = null
72let scanJobs: Job[] = []
73let gitRun: Promise<void> | null = null
74let gitNext: Promise<void> | null = null
75let queuedReads: string[] = []
76let showReads = true
77let showWrites = true
78let searchIndex: { root: string; paths: Promise<string[]> } | null = null
79let activityId = 0
80let pointer = true
81let view = { from: 0, max: 0 }
82let lastSync = 0
83let noDock = false
84let enabled = true
85let prepared = false
86let windows = false
87let home = ''
88let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
89let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
90let markId = 0
91let listSeq = 0
92let lastRoot = ''
93let sizeDefault = false
94let sizeEpoch = 0
95let sizeActive = 0
96let sizeQueue: string[] = []
97const sizing = new Set<string>()
98const listLatest = new Map<string, number>()
99const unreadable = new Set<string>()
100const background = new Map<string, Background>()
101
102function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
103 platform ??= (async () => {
104 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
105 try {
106 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
107 } catch {
108 return 'linux'
109 }
110 })()
111 return platform
112}
113
114async function cwdOf($: EngineInterface): Promise<string> {
115 return posix(await $.session.cwd())
116}
117
118function clean(segs: Seg[]): Seg[] {
119 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
120 return segs
121}
122
123async function get($: EngineInterface): Promise<FileTree> {
124 return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
125}
126
127async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
128 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
129}
130
131function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
132 return put($, t => ({ ...t, ...fn(t) }))
133}
134
135async function activities($: EngineInterface): Promise<Activity[]> {
136 return (await $.state.get(ACTIVITY)).value ?? []
137}
138
139async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
140 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
141}
142
143async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
144 try {
145 const entries = await $.fs.list(dir)
146 const resolved = await Promise.all(
147 entries.map(async e => {
148 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
149 try {
150 const target = await $.fs.stat(join(dir, e.name))
151 return target.kind === 'dir'
152 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
153 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
154 } catch {
155 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
156 }
157 }),
158 )
159 unreadable.delete(dir)
160 return toNodes(dir, resolved)
161 } catch (err) {
162 if (!(await exists($, dir))) return []
163 if (!unreadable.has(dir)) {
164 unreadable.add(dir)
165 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
166 }
167 return null
168 }
169}
170
171async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
172 const root = (await get($)).root
173 const seq = ++listSeq
174 for (const dir of dirs) listLatest.set(dir, seq)
175 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
176 const listed = new Map<string, FileNode[]>()
177 for (const [dir, kids] of results) {
178 if (listLatest.get(dir) !== seq) continue
179 listLatest.delete(dir)
180 if (kids) listed.set(dir, kids)
181 }
182 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
183 return listed
184}
185
186async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
187 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
188}
189
190async function detectRepo($: EngineInterface): Promise<void> {
191 const t = await get($)
192 if (!t.root) return
193 let top = ''
194 let prefix = ''
195 try {
196 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
197 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
198 prefix = p.trim()
199 top = tl.trim()
200 } catch {
201 top = ''
202 }
203 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
204}
205
206async function readGit($: EngineInterface): Promise<void> {
207 const t = await get($)
208 if (!t.root || !t.top) return
209 const root = t.root
210 try {
211 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
212 if (status.exitCode !== 0) return
213 const parsed = parseGit(status.stdout, root, t.prefix)
214 const diff: Record<string, [number, number]> = {}
215 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
216 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
217 const files = { ...parsed.files }
218 if (parsed.untrackedDirs.length) {
219 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
220 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
221 }
222 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
223 dirty = { root, files }
224 await patch($, cur =>
225 cur.root === root
226 ? {
227 git: parsed.git,
228 ignored: parsed.ignored,
229 untrackedDirs: parsed.untrackedDirs,
230 branch: parsed.branch,
231 counts: rollCounts(files, root),
232 diff: rollUp(diff, root),
233 }
234 : {},
235 )
236 } catch {
237 return
238 }
239}
240
241function refreshGit($: EngineInterface): Promise<void> {
242 if (!gitRun) {
243 gitRun = readGit($).finally(() => {
244 gitRun = null
245 })
246 return gitRun
247 }
248 const rerun = () => {
249 gitNext = null
250 return refreshGit($)
251 }
252 gitNext ??= gitRun.then(rerun, rerun)
253 return gitNext
254}
255
256async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
257 if (!enabled) return
258 const prev = await get($)
259 generation += 1
260 blink?.cancel()
261 blink = null
262 lastRoot = root
263 searchIndex = null
264 sizeEpoch += 1
265 sizeQueue = []
266 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
267 const title = `Files: ${root.split('/').pop() || root}`
268 if (focus) await $.ui.open({ id: PANE, title, focus: true })
269 else if (!noDock) await $.ui.open({ id: PANE, title })
270 await loadDirs($, [root])
271 await detectRepo($)
272 await refreshGit($)
273}
274
275async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
276 const tried = new Set<string>()
277 for (let pass = 0; pass < 32; pass++) {
278 const t = await get($)
279 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
280 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
281 const need = new Set<string>()
282 for (const p of paths) {
283 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
284 if (!loaded.has(dir)) {
285 if (!tried.has(dir)) need.add(dir)
286 break
287 }
288 }
289 }
290 if (need.size === 0) return
291 for (const dir of need) tried.add(dir)
292 await loadDirs($, [...need])
293 }
294}
295
296function pruneArgs(root: string, ignored: string[] = []): string[] {
297 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
298 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
299}
300
301async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
302 const stats = await Promise.all(
303 paths.map(async p => {
304 try {
305 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
306 } catch {
307 return ''
308 }
309 }),
310 )
311 return stats.filter(Boolean)
312}
313
314function broad(root: string): boolean {
315 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
316}
317
318function rootOfDisk(root: string): boolean {
319 return root === '/' || /^[A-Za-z]:\/$/.test(root)
320}
321
322async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
323 if (rootOfDisk(root)) return []
324 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
325 try {
326 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 })
327 return run.stdout.split('\0').filter(p => p && p !== root)
328 } catch {
329 return []
330 }
331}
332
333async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
334 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
335 if (dirty.root !== root) return { hits: [], gone: [] }
336 const entries = Object.entries(dirty.files)
337 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
338 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
339 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
340}
341
342type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
343
344async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
345 const ms = await $.clock.now()
346 const os = await osName($)
347 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
348 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/prism-tree-${await $.session.id()}-${++markId}.mark`
349 try {
350 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
351 return { ms, mark, os }
352 } catch {
353 return { ms, mark: '', os }
354 }
355}
356
357async function walkSize($: EngineInterface, dir: string): Promise<number> {
358 let total = 0
359 let seen = 0
360 let level = [dir]
361 while (level.length) {
362 const next: string[] = []
363 for (const d of level) {
364 let entries
365 try {
366 entries = await $.fs.list(d)
367 } catch {
368 continue
369 }
370 for (const e of entries) {
371 if (++seen > SIZE_WALK_LIMIT) return -1
372 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
373 else if (e.kind === 'file') total += e.size
374 }
375 }
376 level = next
377 }
378 return total
379}
380
381async function dirSize($: EngineInterface, dir: string): Promise<number> {
382 if (rootOfDisk(dir)) return -1
383 if ((await osName($)) === 'win32') return walkSize($, dir)
384 try {
385 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
386 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
387 return Number.isFinite(kb) ? kb * 1024 : -1
388 } catch {
389 return -1
390 }
391}
392
393function pumpSizes($: EngineInterface): void {
394 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
395 const dir = sizeQueue.shift() ?? ''
396 const epoch = sizeEpoch
397 sizing.add(dir)
398 sizeActive += 1
399 void dirSize($, dir)
400 .then(async bytes => {
401 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
402 const cur = await get($)
403 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
404 })
405 .catch(() => undefined)
406 .finally(() => {
407 sizing.delete(dir)
408 sizeActive -= 1
409 pumpSizes($)
410 })
411 }
412}
413
414function wantSizes($: EngineInterface, dirs: string[]): void {
415 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
416 pumpSizes($)
417}
418
419async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
420 sizeEpoch += 1
421 sizeQueue = []
422 await patch($, cur => {
423 if (!paths) return { dirSizes: {} }
424 const keep: Record<string, number> = {}
425 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
426 return { dirSizes: keep }
427 })
428}
429
430function openDirs(t: FileTree): string[] {
431 const open = new Set(t.expanded)
432 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
433}
434
435function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
436 if (followClaude) return { scroll: null }
437 const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
438 const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
439 return at < 0 ? {} : { scroll: at }
440}
441
442async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
443 const unique = Object.keys(tones)
444 if (unique.length === 0) return
445 const mine = ++generation
446 const root = (await get($)).root
447 blink?.cancel()
448 blink = null
449 await patch($, cur => {
450 if (cur.root !== root) return {}
451 const open = new Set(cur.expanded)
452 const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
453 const dim = new Set<string>()
454 const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
455 if (cur.flashOn) {
456 for (const id of cur.flashDim) dim.add(id)
457 }
458 for (const id of unique) {
459 const tone = tones[id] ?? 'orange'
460 all[id] = tone
461 if (id === BRANCH_ROW) continue
462 const chain = ancestorsOf(id, cur.root)
463 if (!chain.some(a => !open.has(a))) continue
464 for (const a of chain) {
465 if (!open.has(a)) {
466 dim.add(a)
467 all[a] = all[a] ?? tone
468 }
469 open.add(a)
470 }
471 }
472 for (const id of bright) dim.delete(id)
473 return {
474 flash: [...bright],
475 flashDim: [...dim],
476 flashOn: true,
477 flashTones: all,
478 expanded: [...open],
479 ...keepTop(cur, [...open]),
480 }
481 })
482 if (generation !== mine) return
483 blink = $.clock.after(FLASH_MS, () => {
484 if (generation !== mine) return
485 blink = null
486 void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
487 })
488}
489
490async function followCwd($: EngineInterface): Promise<boolean> {
491 if (!follow) return false
492 const cwd = await cwdOf($)
493 const t = await get($)
494 if (t.root === cwd) return false
495 await reset($, cwd)
496 return true
497}
498
499type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
500type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
501type Background = { p: Pending | null; job: Job; reads: string[] }
502type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
503type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
504
505const PROOF: Record<string, (op: GitOperation) => boolean> = {
506 push: op => Boolean(op.push),
507 merge: op => op.branch?.action === 'merged',
508 rebase: op => op.branch?.action === 'rebased',
509 'pr create': op => op.pr?.action === 'created',
510 'pr merge': op => op.pr?.action === 'merged',
511 'pr comment': op => op.pr?.action === 'commented',
512}
513
514async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
515 const now = await $.clock.now()
516 const ids = actions.map(() => ++activityId)
517 await setActivities($, cur => [
518 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
519 ...actions.map((a, i) => ({
520 id: ids[i] ?? 0,
521 kind: a.kind,
522 label: a.running,
523 state: 'running' as const,
524 detail: '',
525 at: now,
526 tone: a.tone,
527 nerd: a.icon.nerd,
528 plain: a.icon.plain,
529 })),
530 ])
531 return ids
532}
533
534async function headOf($: EngineInterface, cwd: string): Promise<string> {
535 try {
536 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
537 return run.exitCode === 0 ? run.stdout.trim() : ''
538 } catch {
539 return ''
540 }
541}
542
543async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
544 const chain = chainOf(p.command)
545 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
546 return p.actions.map(a => {
547 if (a.verb === 'commit') {
548 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
549 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
550 }
551 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
552 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
553 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
554 })
555}
556
557async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
558 const now = await $.clock.now()
559 await setActivities($, cur =>
560 cur.map(a => {
561 const i = p.ids.indexOf(a.id)
562 const result = i < 0 ? undefined : results[i]
563 return result ? { ...a, ...result, at: now } : a
564 }),
565 )
566 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
567}
568
569function failAll(p: Pending): Outcome[] {
570 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
571}
572
573function proven(p: Pending | null, results: Outcome[]): GitAction[] {
574 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
575}
576
577function taskEnds(text: string): [string, string][] {
578 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
579 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
580 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
581 return id && status ? [[id, status] as [string, string]] : []
582 })
583}
584
585async function settleBackground($: EngineInterface, text: string): Promise<void> {
586 if (background.size === 0 || !text.includes('<task-notification>')) return
587 for (const [id, status] of taskEnds(text)) {
588 const task = background.get(id)
589 if (!task) continue
590 background.delete(id)
591 const ok = status === 'completed'
592 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
593 if (task.p) await finishGit($, task.p, results)
594 if (showReads) queuedReads.push(...task.reads)
595 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
596 }
597}
598
599async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
600 const map = mapper(root, prefix)
601 try {
602 const run = committed
603 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
604 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
605 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
606 } catch {
607 return []
608 }
609}
610
611async function gitDir($: EngineInterface, cwd: string): Promise<string> {
612 for (let dir = cwd, i = 0; i < 64; i++) {
613 try {
614 const dot = join(dir, '.git')
615 const st = await $.fs.stat(dot)
616 if (st.kind === 'dir') return dot
617 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
618 if (ref) return resolve(dir, ref, home)
619 } catch {
620 const up = dirname(dir)
621 if (up === dir) return ''
622 dir = up
623 continue
624 }
625 return ''
626 }
627 return ''
628}
629
630async function sync($: EngineInterface, force = false): Promise<void> {
631 const now = await $.clock.now()
632 if (!force && now - lastSync < 2_000) return
633 lastSync = now
634 searchIndex = null
635 const t = await get($)
636 if (!t.root) return
637 await loadDirs($, [t.root, ...openDirs(t)])
638 if (t.top) await refreshGit($)
639}
640
641async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
642 const t = await get($)
643 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
644 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
645 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
646}
647
648async function exists($: EngineInterface, path: string): Promise<boolean> {
649 try {
650 await $.fs.stat(path)
651 return true
652 } catch {
653 return false
654 }
655}
656
657async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
658 const reads = queuedReads
659 queuedReads = []
660 if (await followCwd($)) return
661 const t = await get($)
662 if (!t.root) return
663 const actions = jobs.flatMap(j => j.actions)
664 const writers = jobs.filter(j => !j.readOnly)
665 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 })
666 const writes = !jobs.every(j => j.readOnly)
667 const before = dirty.root === t.root ? dirty.files : {}
668 if (jobs.some(j => j.initRepo)) await detectRepo($)
669 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
670 const probed = await get($)
671 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
672 if (writes) {
673 searchIndex = null
674 await staleSizes($)
675 }
676 const fresh = await get($)
677 const ignored = new Set(fresh.ignored)
678 const tones: Record<string, string> = {}
679 if (writes) {
680 const found = fresh.top
681 ? await changedInRepo($, t.root, since, before, fresh.ignored)
682 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
683 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
684 await revealPaths($, hits)
685 const loaded = await get($)
686 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
687 const listed = await loadDirs($, dirs)
688 if (!fresh.top && since.os === 'win32')
689 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
690 await patch($, cur => {
691 const ids = new Set(cur.nodes.map(n => n.id))
692 return { expanded: cur.expanded.filter(id => ids.has(id)) }
693 })
694 const present = new Set((await get($)).nodes.map(n => n.id))
695 const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
696 if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
697 }
698 if (showReads) {
699 const found: string[] = []
700 for (const r of reads) {
701 if (found.length >= READ_REVEAL_LIMIT) break
702 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
703 }
704 if (found.length) {
705 await revealPaths($, found)
706 const shown = new Set((await get($)).nodes.map(n => n.id))
707 for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
708 }
709 }
710 if (showWrites && actions.length) {
711 const final = await get($)
712 const committed = actions.some(a => a.verb === 'commit')
713 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
714 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
715 await revealPaths($, paths)
716 const shown = new Set((await get($)).nodes.map(n => n.id))
717 for (const x of paths) if (shown.has(x)) tones[x] = 'green'
718 }
719 const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
720 if (pushLike) tones[BRANCH_ROW] = pushLike.tone
721 else if (committed) tones[BRANCH_ROW] = 'green'
722 }
723 await flash($, tones)
724}
725
726function scheduleScan($: EngineInterface, job: Job): void {
727 scanJobs.push(job)
728 if (scanning) return
729 scanning = (async () => {
730 while (scanJobs.length) {
731 const jobs = scanJobs
732 scanJobs = []
733 try {
734 await afterBash($, jobs)
735 } finally {
736 const marks = jobs.map(j => j.since.mark).filter(Boolean)
737 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
738 }
739 }
740 })().finally(() => {
741 scanning = null
742 })
743}
744
745async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
746 if (await followCwd($)) return
747 const t = await get($)
748 if (!t.root) return
749 const within = paths.map(posix).filter(p => inside(t.root, p))
750 if (within.length === 0) return
751 if (tone !== 'purple') {
752 searchIndex = null
753 await staleSizes($, within)
754 await revealPaths($, within.map(dirname))
755 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
756 await refreshGit($)
757 } else await revealPaths($, within)
758 if (!show) return
759 const tones: Record<string, string> = {}
760 for (const p of within) tones[p] = tone
761 await flash($, tones)
762}
763
764async function reveal($: EngineInterface, paths: string[]): Promise<void> {
765 if (await followCwd($)) return
766 await revealPaths($, paths)
767 await patch($, cur => {
768 const open = new Set(cur.expanded)
769 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
770 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
771 })
772}
773
774async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
775 const out: string[] = []
776 let level = [root]
777 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
778 const next: string[] = []
779 for (const dir of level) {
780 for (const n of (await list($, dir)) ?? []) {
781 if (n.kind === 'dir') {
782 if (!PRUNE.includes(n.name)) next.push(n.id)
783 } else out.push(n.id)
784 }
785 if (out.length >= limit) break
786 }
787 level = next
788 }
789 return out
790}
791
792async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
793 try {
794 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
795 const run = t.top
796 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
797 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
798 if (t.top && run.exitCode !== 0) return null
799 const paths = run.stdout.split('\0').filter(Boolean)
800 if (run.isStdoutTruncated) paths.pop()
801 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
802 } catch {
803 return null
804 }
805}
806
807function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
808 if (searchIndex?.root !== t.root) {
809 const index: { root: string; paths: Promise<string[]> } = {
810 root: t.root,
811 paths: listAll($, t).then(paths => {
812 if (paths === null && searchIndex === index) searchIndex = null
813 return paths ?? []
814 }),
815 }
816 searchIndex = index
817 }
818 return searchIndex.paths
819}
820
821async function search($: EngineInterface, query: string): Promise<string[]> {
822 if (!(await get($)).query.trim()) searchIndex = null
823 await patch($, () => ({ query }))
824 const q = query.trim().toLowerCase()
825 if (!q) return []
826 const t = await get($)
827 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
828 if ((await get($)).query !== query) return []
829 await revealPaths($, hits)
830 const shown = await get($)
831 const ids = new Set(shown.nodes.map(n => n.id))
832 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
833 if (stale.length) await loadDirs($, stale)
834 return hits
835}
836
837async function jump($: EngineInterface, query: string): Promise<void> {
838 const hits = (await search($, query)).slice(0, 10)
839 await patch($, cur => {
840 const open = new Set(cur.expanded)
841 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
842 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
843 })
844}
845
846async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
847 try {
848 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
849 return out === 'stale' || out === 'missing' ? out : 'ok'
850 } catch {
851 return 'missing'
852 }
853}
854
855async function finePointerOk($: EngineInterface): Promise<boolean> {
856 if (!(await $.env.get('HERDR_ENV'))) return true
857 try {
858 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
859 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
860 return major * 1e6 + minor * 1e3 + fix >= 9_001
861 } catch {
862 return false
863 }
864}
865
866async function openFile($: EngineInterface, path: string): Promise<void> {
867 const { argv, init } = openCommand(await osName($), path)
868 try {
869 const run = await $.process.run(argv, init)
870 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
871 } catch {
872 $.ui.toast(`could not open ${path} with ${argv[0]}`)
873 }
874}
875
876async function toggle($: EngineInterface, n: FileNode): Promise<void> {
877 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
878 await patch($, t => {
879 const open = new Set(t.expanded)
880 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
881 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
882 })
883}
884
885async function press($: EngineInterface, n: FileNode): Promise<void> {
886 const now = await $.clock.now()
887 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
888 lastPress = { key: isDouble ? '' : n.id, at: now }
889 if (!isDouble) {
890 await toggle($, n)
891 return
892 }
893 await openNode($, n)
894}
895
896async function openNode($: EngineInterface, n: FileNode): Promise<void> {
897 if (n.kind === 'dir') {
898 follow = false
899 await reset($, n.id)
900 } else await openFile($, n.id)
901}
902
903// A preset picked with the header button is kept in the store with the
904// config option it was picked under; once the option changes, the option wins.
905async function restoreTheme($: EngineInterface): Promise<void> {
906 let want = ''
907 try {
908 const saved = (await $.store.get(THEME_STORE)) as { pick?: unknown; from?: unknown } | undefined
909 if (saved && saved.from === optionTheme && isPreset(saved.pick)) want = saved.pick
910 } catch {
911 want = ''
912 }
913 if (((await $.state.get(THEME)).value ?? '') !== want) await $.state.set(THEME, want)
914}
915
916async function cycleTheme($: EngineInterface): Promise<void> {
917 let picked = optionTheme
918 await update($, THEME, cur => (picked = nextPreset(isPreset(cur) ? cur : optionTheme)))
919 try {
920 await $.store.set(THEME_STORE, { pick: picked, from: optionTheme })
921 } catch {
922 // kept for this session only
923 }
924 $.ui.toast(`Theme: ${palette(picked).label}`)
925}
926
927async function prepare($: EngineInterface): Promise<void> {
928 prepared = true
929 pointer = await finePointerOk($)
930 try {
931 const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
932 noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
933 } catch {
934 noNerd = true
935 }
936 await restoreTheme($)
937 const t = await get($)
938 if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
939 await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
940}
941
942async function setEnabled($: EngineInterface, on: boolean): Promise<void> {
943 enabled = on
944 try {
945 await $.store.set(ENABLED_STORE, on)
946 } catch {
947 // kept for this session only
948 }
949}
950
951function shortPath(path: string): string {
952 return home && inside(home, path) ? `~${path.slice(home.length)}` : path
953}
954
955export const register: Register = (on, options) => {
956 glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
957 const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
958 showReads = activity.includes('reads')
959 showWrites = activity.includes('writes')
960 followClaude = options?.follow !== 'off'
961 optionTheme = isPreset(options?.theme) ? options.theme : DEFAULT_PRESET
962 sizeDefault = options?.column === 'size'
963 on('session.start', async ($, e, next) => {
964 await $.command.register({ name: 'prism-tree', description: 'Show or hide the themed file tree; args: on | off | [path] (no path = cwd)' })
965 windows = (await $.env.get('OS')) === 'Windows_NT'
966 useDrives(windows)
967 home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
968 activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
969 try {
970 enabled = (await $.store.get(ENABLED_STORE)) !== false
971 } catch {
972 enabled = true
973 }
974 if (!enabled) return next(e)
975 void (async () => {
976 await prepare($)
977 const t = await get($)
978 const cwd = await cwdOf($)
979 if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
980 else if (!noDock) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
981 })()
982 return next(e)
983 })
984
985 on('command.run', { command: 'prism-tree' }, async ($, e) => {
986 const word = (e.args ?? '').trim().toLowerCase()
987 if (word === 'off') {
988 await setEnabled($, false)
989 generation += 1
990 blink?.cancel()
991 blink = null
992 await $.ui.close({ id: PANE }).catch(() => undefined)
993 return { text: 'prism-tree off. It stays off in new sessions until /prism-tree on.' }
994 }
995 if (!e.presentation.isFullscreen) return { text: 'prism-tree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /prism-tree.' }
996 if (e.presentation.columns < 110) return { text: 'prism-tree shows in the sidebar, which needs a terminal at least 110 columns wide. Widen it, then run /prism-tree.' }
997 await setEnabled($, true)
998 if (!prepared) await prepare($)
999 noDock = false
1000 const arg = word === 'on' ? '' : (e.args ?? '').trim()
1001 const cwd = await cwdOf($)
1002 follow = !arg
1003 const root = arg ? resolve(cwd, arg, home) : cwd
1004 await reset($, root, true)
1005 return { text: `File tree on ${shortPath(root)}${follow ? ' (follows the cwd)' : ''}.` }
1006 })
1007
1008 on('tool.call', async ($, e, next) => {
1009 if (!enabled) return next(e)
1010 if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && e.tool !== 'Bash') return next(e)
1011 const command = e.tool === 'Bash' ? e.command : ''
1012 const cwd = e.tool === 'Bash' ? await cwdOf($) : ''
1013 const actions = gitActions(command)
1014 const quiet = !actions.length && readOnly(command)
1015 const since = await sinceNow($, e.tool === 'Bash' && !quiet)
1016 const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1017 const head = before?.top ? await headOf($, before.root) : ''
1018 const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1019 let result: Awaited<ReturnType<typeof next>>
1020 try {
1021 result = await next(e)
1022 } catch (err) {
1023 if (pending) await finishGit($, pending, failAll(pending))
1024 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1025 throw err
1026 }
1027 if (pending && result.deny) {
1028 const ids = pending.ids
1029 void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1030 }
1031 if (result.deny || (result.isError && e.tool !== 'Bash')) {
1032 if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1033 return result
1034 }
1035 if (e.tool === 'Bash') {
1036 const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? result.result : {}
1037 const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1038 const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1039 if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1040 else void (async () => {
1041 const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1042 if (pending) await finishGit($, pending, results)
1043 queuedReads.push(...reads)
1044 scheduleScan($, { ...job, actions: proven(pending, results) })
1045 })()
1046 if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1047 } else {
1048 const file =
1049 'file_path' in e && typeof e.file_path === 'string'
1050 ? e.file_path
1051 : 'notebook_path' in e && typeof e.notebook_path === 'string'
1052 ? e.notebook_path
1053 : ''
1054 if (file) void touched($, [file], 'orange', showWrites)
1055 }
1056 return result
1057 })
1058
1059 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1060 const result = await next(e)
1061 if (!enabled || result.deny || result.isError || e.tool !== 'Read' || !showReads) return result
1062 void touched($, [posix(e.file_path)], 'purple', true)
1063 return result
1064 })
1065
1066 on('ui.message', async ($, e, next) => {
1067 if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
1068 const data = e.data as { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
1069 void sync($)
1070 const t = await get($)
1071 if (typeof data.copy === 'string') {
1072 await copyPath($, data.copy, Boolean(data.shift), e.surface)
1073 return {}
1074 }
1075 if (typeof data.scrollTo === 'number') {
1076 const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * view.max)
1077 if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1078 return {}
1079 }
1080 if (typeof data.press === 'string') {
1081 const n = t.nodes.find(x => x.id === data.press)
1082 if (!n) return {}
1083 if (t.scroll === null) await patch($, () => ({ scroll: view.from }))
1084 if (data.ctrl || data.shift) await openNode($, n)
1085 else await press($, n)
1086 return {}
1087 }
1088 if (typeof data.key !== 'string') return {}
1089 const rows = visibleRows(t)
1090 const at = rows.findIndex(r => r.node.id === t.cursor)
1091 const cur = rows[at]?.node
1092 const move = (d: number) => {
1093 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
1094 return target ? patch($, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
1095 }
1096 if (data.key === 'up' || data.key === 'k') await move(-1)
1097 else if (data.key === 'down' || data.key === 'j') await move(1)
1098 else if (data.key === 'pageup') await move(-10)
1099 else if (data.key === 'pagedown') await move(10)
1100 else if (data.key === 'home') await move(-rows.length)
1101 else if (data.key === 'end') await move(rows.length)
1102 else if (cur && (data.key === 'y' || data.key === 'Y')) await copyPath($, cur.id, data.key === 'Y' || Boolean(data.shift), e.surface)
1103 else if (cur && (data.key === 'right' || data.key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle($, cur)
1104 else if (cur && (data.key === 'left' || data.key === 'h')) {
1105 if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle($, cur)
1106 else if (cur.parent !== t.root) await patch($, () => ({ cursor: cur.parent }))
1107 } else if (cur && data.key === 'return') await (cur.kind !== 'dir' ? openNode($, cur) : toggle($, cur))
1108 else if (cur && data.key === ' ') await toggle($, cur)
1109 return {}
1110 })
1111
1112 on('classic.SessionStart', async ($, e, next) => {
1113 const result = await next(e)
1114 if (!enabled) return result
1115 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') {
1116 void (async () => {
1117 await restoreTheme($)
1118 const root = follow || !lastRoot ? await cwdOf($) : lastRoot
1119 const t = await get($)
1120 if (t.root !== root || t.nodes.length === 0) await reset($, root)
1121 })()
1122 }
1123 const gd = await gitDir($, posix(e.cwd))
1124 return gd ? { ...result, watchPaths: [...(result.watchPaths ?? []), join(gd, 'index'), join(gd, 'HEAD')] } : result
1125 })
1126
1127 on('classic.CwdChanged', async ($, e, next) => {
1128 const result = await next(e)
1129 if (enabled) void followCwd($)
1130 return result
1131 })
1132
1133 on('classic.FileChanged', async ($, e, next) => {
1134 const result = await next(e)
1135 if (enabled && /[\\/]\.git[\\/]|[\\/](index|HEAD)$/.test(e.file_path)) $.clock.after(300, () => void sync($, true))
1136 return result
1137 })
1138
1139 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1140 const result = await next(e)
1141 void sync($)
1142 return result
1143 })
1144
1145 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
1146 const t = await get($)
1147 const to = Math.max(0, Math.min(view.max, (t.scroll ?? view.from) + Math.sign(e.by) * Math.max(3, Math.abs(e.by))))
1148 if (to !== t.scroll) await patch($, () => ({ scroll: to }))
1149 return {}
1150 })
1151
1152 on('prompt.submit', async ($, e, next) => {
1153 if (!enabled) return next(e)
1154 void settleBackground($, e.text)
1155 if (!(await followCwd($))) void sync($)
1156 const t = await get($)
1157 const context = [...(e.context ?? [])]
1158 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}`)
1159 const mentions = [...e.text.matchAll(/@([^\s"'`]+)/g)]
1160 .map(m => (m[1] ?? '').replace(/[.,;:!?)]+$/, ''))
1161 .filter(Boolean)
1162 .map(p => resolve(t.root, p, home))
1163 .filter(p => inside(t.root, p))
1164 if (mentions.length) {
1165 void (async () => {
1166 const found: string[] = []
1167 for (const p of mentions) {
1168 try {
1169 await $.fs.stat(p)
1170 found.push(p.replace(/\/$/, ''))
1171 } catch {
1172 continue
1173 }
1174 }
1175 if (found.length) await reveal($, found)
1176 })()
1177 }
1178 return next(context.length ? { ...e, context } : e)
1179 })
1180
1181 on('prompt.attachment', async ($, e, next) => {
1182 if (!enabled) return next(e)
1183 void settleBackground($, e.text)
1184 return next(e)
1185 })
1186
1187 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
1188 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
1189 if (!enabled || (e.surface === 'terminal' && e.props.placement === 'inline')) {
1190 if (enabled) noDock = true
1191 void $.ui.close({ id: PANE }).catch(() => undefined)
1192 const { Box: Empty } = $.ui.resolve(e)
1193 return <Empty />
1194 }
1195 const unicode = glyphSetting === 'plain' || (glyphSetting === 'auto' && (noNerd || e.surface === 'desktop'))
1196 const { Box, Text, Button, Input, Client } = $.ui.resolve(e)
1197 const t = await get($)
1198 const picked = (await $.state.get(THEME)).value
1199 const pal = palette(isPreset(picked) ? picked : optionTheme)
1200 const theme: Theme = pal.basehooks/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 252 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
27const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
28 commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
29 push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
30 pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
31 fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
32 checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
33 switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
34 branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
35 merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
36 rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
37 'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
38 stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
39 reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
40 restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
41 revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
42 add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
43 rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
44 mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
45 tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
46 init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
47 clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
48}
49
50const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
51 'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
52 'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
53 'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
54 'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
55 'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
56 'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
57 'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
58 'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
59}
60
61const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
62
63function segments(command: string, seps: string[] = []): string[][] {
64 const out: string[][] = []
65 let cur: string[] = []
66 let tok = ''
67 let quote = ''
68 let has = false
69 const endTok = () => {
70 if (has) cur.push(tok)
71 tok = ''
72 has = false
73 }
74 const endSeg = () => {
75 endTok()
76 if (cur.length) out.push(cur)
77 cur = []
78 }
79 for (let i = 0; i < command.length; i++) {
80 const ch = command[i] ?? ''
81 if (quote) {
82 if (ch === quote) quote = ''
83 else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
84 else tok += ch
85 } else if (ch === '"' || ch === "'") {
86 quote = ch
87 has = true
88 } else if (ch === '\\' && i + 1 < command.length) {
89 tok += command[++i]
90 has = true
91 } else if (ch === '#' && !has) {
92 while (i + 1 < command.length && command[i + 1] !== '\n') i++
93 } else if (ch === ' ' || ch === '\t') endTok()
94 else if (ch === '\n' || ch === ';') {
95 seps.push(';')
96 endSeg()
97 } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
98 tok += ch
99 has = true
100 } else if (ch === '&' || ch === '|') {
101 const twice = command[i + 1] === ch
102 if (twice) i++
103 seps.push(twice ? ch + ch : ch)
104 endSeg()
105 } else {
106 tok += ch
107 has = true
108 }
109 }
110 endSeg()
111 if (!quote) return out
112 seps.push(';')
113 return command
114 .split(/&&|\|\||;|\||\n/)
115 .map(s => s.trim().split(/\s+/).filter(Boolean))
116 .filter(s => s.length > 0)
117}
118
119function stripGlobals(tokens: string[]): string[] {
120 const out = [...tokens]
121 if (out[0] === 'env') out.shift()
122 while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
123 return out
124}
125
126function gitVerb(tokens: string[]): number {
127 let i = 1
128 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
129 if (tokens[i] === '-C' || tokens[i] === '-c') i++
130 i++
131 }
132 return i
133}
134
135function gitReadOnly(verb: string, rest: string[]): boolean {
136 if (!verb || READ_ONLY_GIT.has(verb)) return true
137 if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
138 if (verb === 'branch') return rest.every(t => t.startsWith('-'))
139 if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
140 return false
141}
142
143export function chainOf(command: string): { size: number; and: boolean } {
144 const seps: string[] = []
145 const size = segments(command, seps).length
146 return { size, and: seps.every(s => s === '&&') }
147}
148
149export function gitActions(command: string): GitAction[] {
150 const out: GitAction[] = []
151 for (const raw of segments(command)) {
152 const tokens = stripGlobals(raw)
153 const head = tokens[0]?.split('/').pop()
154 if (head === 'git') {
155 const i = gitVerb(tokens)
156 const verb = tokens[i] ?? ''
157 const rest = tokens.slice(i + 1)
158 if (gitReadOnly(verb, rest)) continue
159 const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
160 if (spec) out.push({ kind: `git ${verb}`, ...spec })
161 } else if (head === 'gh') {
162 const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
163 const spec = GH_VERBS[pair]
164 if (spec) out.push({ kind: `gh ${pair}`, ...spec })
165 }
166 }
167 return out
168}
169
170export const BRANCH_ICON = ICON.branch
171
172const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
173const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
174const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
175
176function tidy(path: string): string {
177 const s = path.replace(/\/+$/, '')
178 return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
179}
180
181export function resolve(cwd: string, p: string, home = ''): string {
182 let path = posix(p)
183 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
184 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
185 const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
186 for (const seg of path.slice(drive.length).split('/')) {
187 if (!seg || seg === '.') continue
188 if (seg === '..') {
189 if (parts.length > 1) parts.pop()
190 } else parts.push(seg)
191 }
192 return tidy(parts.join('/'))
193}
194
195const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
196
197export function readOnly(command: string): boolean {
198 const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
199 if (/[^>]>[^>&]|>>/.test(bare)) return false
200 return segments(command).every(raw => {
201 const tokens = stripGlobals(raw)
202 const head = tokens[0]?.split('/').pop() ?? ''
203 if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
204 if (head === 'git') {
205 const i = gitVerb(tokens)
206 return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
207 }
208 if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
209 if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
210 return READERS.has(head)
211 })
212}
213
214export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
215 let cwd = sessionCwd
216 const out = new Set<string>()
217 let listsPaths = false
218 for (const raw of segments(command)) {
219 const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
220 const head = tokens[0]?.split('/').pop() ?? ''
221 if (head === 'cd' && tokens[1]) {
222 cwd = resolve(cwd, tokens[1], home)
223 continue
224 }
225 if (!READERS.has(head)) continue
226 if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
227 if (OUTPUT_PATHS.has(head)) listsPaths = true
228 const args = tokens.slice(1)
229 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
230 for (let i = 0; i < args.length; i++) {
231 const a = args[i] ?? ''
232 if (a.startsWith('-')) {
233 if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
234 continue
235 }
236 if (skipPattern) {
237 skipPattern = false
238 continue
239 }
240 if (/[*?<>|]/.test(a)) continue
241 out.add(resolve(cwd, a, home))
242 }
243 }
244 if (listsPaths) {
245 for (const line of stdout.split('\n').slice(0, 400)) {
246 const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
247 if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
248 }
249 }
250 return [...out].slice(0, 60)
251}
252hooks/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 123 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
117const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
118
119export function stronger(a: string | undefined, b: string): string {
120 if (!a) return b
121 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
122}
123hooks/themes.ts 141 lines1import type { Theme } from '../types'
2
3// The tone slots the tree flashes with. The names are the slots' historic
4// colors (reads were purple, writes orange, commits green, pushes teal,
5// pulls blue, failures red); each preset fills every slot with its own hue.
6export const TONE_SLOTS = ['orange', 'green', 'teal', 'blue', 'purple', 'cyan', 'red'] as const
7export type ToneSlot = (typeof TONE_SLOTS)[number]
8export type Tone = { bright: string[]; dim: string[]; solid: string }
9
10export type Preset = {
11 label: string
12 // A one-cell mark drawn before the header (East Asian width Neutral, so a
13 // CJK terminal still draws it one cell wide).
14 mark: string
15 light: boolean
16 base: Theme
17 hover: string
18 // What the shimmer band blends toward at its peak.
19 glow: string
20 git: { add: string; del: string; mod: string; ren: string }
21 hues: Record<ToneSlot, string>
22}
23
24export type Palette = Preset & { name: string; tones: Record<string, Tone> }
25
26const PRESETS: Record<string, Preset> = {
27 aurora: {
28 label: 'Aurora',
29 mark: '✦',
30 light: false,
31 base: { fg: '', accent: '#5eead4', muted: '#7b8794', urgent: '#fb7185', selection: '#16384a', bg: '' },
32 hover: '#11252f',
33 glow: '#c4b5fd',
34 git: { add: '#6ee7b7', del: '#fb7185', mod: '#fde68a', ren: '#7dd3fc' },
35 hues: { orange: '#f0abfc', green: '#6ee7b7', teal: '#5eead4', blue: '#7dd3fc', purple: '#a5b4fc', cyan: '#67e8f9', red: '#fb7185' },
36 },
37 ember: {
38 label: 'Ember',
39 mark: '✶',
40 light: false,
41 base: { fg: '', accent: '#f59e0b', muted: '#8f8272', urgent: '#ef4444', selection: '#4a2c17', bg: '' },
42 hover: '#2e1d11',
43 glow: '#fde68a',
44 git: { add: '#bef264', del: '#f87171', mod: '#fcd34d', ren: '#fdba74' },
45 hues: { orange: '#fb923c', green: '#a3e635', teal: '#facc15', blue: '#fdba74', purple: '#c4b5fd', cyan: '#fcd34d', red: '#f87171' },
46 },
47 forest: {
48 label: 'Forest',
49 mark: '✿',
50 light: false,
51 base: { fg: '', accent: '#84cc16', muted: '#7d8a76', urgent: '#f43f5e', selection: '#203320', bg: '' },
52 hover: '#172417',
53 glow: '#ecfccb',
54 git: { add: '#86efac', del: '#fda4af', mod: '#facc15', ren: '#93c5fd' },
55 hues: { orange: '#eab308', green: '#4ade80', teal: '#2dd4bf', blue: '#93c5fd', purple: '#a78bfa', cyan: '#5eead4', red: '#f87171' },
56 },
57 mono: {
58 label: 'Mono',
59 mark: '✱',
60 light: false,
61 base: { fg: '', accent: '#e5e7eb', muted: '#6b7280', urgent: '#f87171', selection: '#3a3f47', bg: '' },
62 hover: '#2a2d33',
63 glow: '#ffffff',
64 git: { add: '#d4d4d8', del: '#f87171', mod: '#a1a1aa', ren: '#d4d4d8' },
65 hues: { orange: '#f4f4f5', green: '#d4d4d8', teal: '#e4e4e7', blue: '#a1a1aa', purple: '#c4c4cc', cyan: '#e4e4e7', red: '#f87171' },
66 },
67 paper: {
68 label: 'Paper',
69 mark: '✧',
70 light: true,
71 base: { fg: '', accent: '#0f766e', muted: '#6b7280', urgent: '#b91c1c', selection: '#d6e9f8', bg: '' },
72 hover: '#eaf3fb',
73 glow: '#111827',
74 git: { add: '#15803d', del: '#b91c1c', mod: '#a16207', ren: '#1d4ed8' },
75 hues: { orange: '#c2410c', green: '#15803d', teal: '#0f766e', blue: '#1d4ed8', purple: '#7e22ce', cyan: '#0e7490', red: '#b91c1c' },
76 },
77 classic: {
78 label: 'Classic',
79 mark: '',
80 light: false,
81 base: { fg: '', accent: '#5b9bd5', muted: '#808a96', urgent: '#d0605e', selection: '#6b7280', bg: '' },
82 hover: '#3c4048',
83 glow: '#ffffff',
84 git: { add: '#98c379', del: '#e06c75', mod: '#e5c07b', ren: '#61afef' },
85 hues: { orange: '#f97316', green: '#22c55e', teal: '#14b8a6', blue: '#3b82f6', purple: '#a855f7', cyan: '#06b6d4', red: '#ef4444' },
86 },
87}
88
89export const THEME_NAMES = Object.keys(PRESETS)
90export const DEFAULT_PRESET = 'aurora'
91
92function rgb(hex: string): [number, number, number] {
93 const v = parseInt(hex.replace('#', '').slice(0, 6), 16)
94 return [(v >> 16) & 255, (v >> 8) & 255, v & 255]
95}
96
97export function mix(a: string, b: string, t: number): string {
98 const x = rgb(a)
99 const y = rgb(b)
100 return `#${x.map((c, i) => Math.round(c + ((y[i] ?? c) - c) * t).toString(16).padStart(2, '0')).join('')}`
101}
102
103// A shimmer ramp: index 0 is the hue itself, index 3 the band's peak. Dark
104// presets dim toward black and peak toward their glow; the light preset dims
105// toward white (a faded hue on paper) and peaks toward its dark glow.
106export function ramp(hue: string, glow: string, light: boolean): Tone {
107 const fade = light ? '#ffffff' : '#000000'
108 return {
109 bright: [hue, mix(hue, glow, 0.3), mix(hue, glow, 0.55), mix(hue, glow, 0.8)],
110 dim: [mix(hue, fade, 0.58), mix(hue, fade, 0.46), mix(hue, fade, 0.32), mix(hue, fade, 0.18)],
111 solid: hue,
112 }
113}
114
115const cache = new Map<string, Palette>()
116
117export function isPreset(name: unknown): name is string {
118 return typeof name === 'string' && name in PRESETS
119}
120
121export function palette(name: unknown): Palette {
122 const key = isPreset(name) ? name : DEFAULT_PRESET
123 const hit = cache.get(key)
124 if (hit) return hit
125 const p = PRESETS[key] as Preset
126 const tones: Record<string, Tone> = {}
127 for (const slot of TONE_SLOTS) tones[slot] = ramp(p.hues[slot], p.glow, p.light)
128 const built: Palette = { ...p, name: key, tones }
129 cache.set(key, built)
130 return built
131}
132
133export function nextPreset(name: string): string {
134 const i = THEME_NAMES.indexOf(name)
135 return THEME_NAMES[(i + 1) % THEME_NAMES.length] ?? DEFAULT_PRESET
136}
137
138export function gitColors(p: Palette): Record<string, string> {
139 return { A: p.git.add, '?': p.git.add, R: p.git.ren, C: p.git.ren, M: p.git.mod, T: p.git.mod }
140}
141hooks/tree.ts 310 lines1import type { Branch, FileNode, FileTree } 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 function emptyTree(root: string): FileTree {
10 return {
11 root,
12 nodes: [],
13 expanded: [],
14 cursor: '',
15 selected: '',
16 query: '',
17 showHidden: true,
18 showSize: false,
19 dirSizes: {},
20 git: {},
21 diff: {},
22 ignored: [],
23 untrackedDirs: [],
24 top: '',
25 prefix: '',
26 branch: null,
27 counts: {},
28 flash: [],
29 flashDim: [],
30 flashOn: false,
31 flashTones: {},
32 scroll: null,
33 }
34}
35
36export function join(dir: string, name: string): string {
37 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
38}
39
40let drives = false
41
42export function dirname(path: string): string {
43 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
44 const i = path.lastIndexOf('/')
45 return i <= 0 ? '/' : path.slice(0, i)
46}
47
48export function useDrives(on: boolean): void {
49 drives = on
50}
51
52export function drivesOn(): boolean {
53 return drives
54}
55
56export function posix(path: string): string {
57 if (!drives) return path
58 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
59}
60
61export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
62 if (dirs.length === 0) return nodes
63 const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
64 const gone = new Set(dirs)
65 return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
66}
67
68export function isAbsolute(path: string): boolean {
69 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
70}
71
72export function inside(root: string, path: string): boolean {
73 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
74}
75
76export function toNodes(dir: string, list: Entry[]): FileNode[] {
77 return list
78 .map(e => ({
79 id: join(dir, e.name),
80 parent: dir,
81 name: e.name,
82 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
83 hidden: e.name.startsWith('.'),
84 mtime: e.mtimeMs,
85 size: e.size,
86 loaded: false,
87 }))
88 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
89}
90
91export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
92 if (listed.size === 0) return nodes
93 const keep = new Map<string, FileNode>()
94 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
95 const freshIds = new Set<string>()
96 for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
97 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
98 const under = (id: string) => {
99 if (gone.size === 0) return false
100 for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
101 return false
102 }
103 const fresh: FileNode[] = []
104 for (const kids of listed.values()) {
105 for (const k of kids) {
106 if (under(k.id)) continue
107 const old = keep.get(k.id)
108 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
109 }
110 }
111 const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
112 return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
113}
114
115export type GitStatus = {
116 git: Record<string, string>
117 ignored: string[]
118 untrackedDirs: string[]
119 branch: Branch | null
120 files: Record<string, Change>
121}
122
123export type Change = 'new' | 'mod' | 'del'
124
125export function parseBranch(record: string): Branch {
126 const body = record.replace(/^## /, '')
127 if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
128 const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
129 const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
130 const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
131 const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
132 return { head, upstream, ahead, behind }
133}
134
135export function mapper(root: string, prefix: string): (rel: string) => string | null {
136 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
137}
138
139export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
140 const map = mapper(root, prefix)
141 const git: Record<string, string> = {}
142 const ignored: string[] = []
143 const untrackedDirs: string[] = []
144 const files: Record<string, Change> = {}
145 let branch: Branch | null = null
146 const parts = stdout.split('\0')
147 for (let i = 0; i < parts.length; i++) {
148 const rec = parts[i] ?? ''
149 if (rec.startsWith('## ')) {
150 branch = parseBranch(rec)
151 continue
152 }
153 if (rec.length < 4) continue
154 const xy = rec.slice(0, 2)
155 const raw = rec.slice(3)
156 if (xy[0] === 'R' || xy[0] === 'C') i++
157 const path = map(raw)
158 if (!path) continue
159 if (xy === '!!') {
160 ignored.push(path)
161 continue
162 }
163 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
164 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
165 const letter =
166 xy === '??'
167 ? '?'
168 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
169 ? 'U'
170 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
171 if (!letter) continue
172 git[path] = stronger(git[path], letter)
173 let dir = dirname(path)
174 while (dir.length >= root.length && dir !== '/') {
175 git[dir] = stronger(git[dir], letter)
176 if (dir === root) break
177 dir = dirname(dir)
178 }
179 }
180 return { git, ignored, untrackedDirs, branch, files }
181}
182
183export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
184 const map = mapper(root, prefix)
185 const parts = stdout.split('\0')
186 for (let i = 0; i < parts.length; i++) {
187 const rec = parts[i] ?? ''
188 if (!rec) continue
189 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
190 if (!m) continue
191 let path = m[3] ?? ''
192 if (path === '') {
193 i += 2
194 path = parts[i] ?? ''
195 }
196 if (!path) continue
197 const add = m[1] === '-' ? 0 : Number(m[1])
198 const del = m[2] === '-' ? 0 : Number(m[2])
199 const abs = map(path)
200 if (!abs) continue
201 const prev = into[abs] ?? [0, 0]
202 into[abs] = [prev[0] + add, prev[1] + del]
203 }
204}
205
206export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
207 const out: Record<string, [number, number]> = { ...diff }
208 for (const [path, [add, del]] of Object.entries(diff)) {
209 let dir = dirname(path)
210 while (dir.length >= root.length && dir !== '/') {
211 const prev = out[dir] ?? [0, 0]
212 out[dir] = [prev[0] + add, prev[1] + del]
213 if (dir === root) break
214 dir = dirname(dir)
215 }
216 }
217 return out
218}
219
220export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
221 const out: Record<string, [number, number, number]> = {}
222 const slot = { new: 0, mod: 1, del: 2 } as const
223 for (const [path, change] of Object.entries(files)) {
224 let dir = dirname(path)
225 while (dir.length >= root.length && dir !== '/') {
226 const cur = out[dir] ?? [0, 0, 0]
227 cur[slot[change]] += 1
228 out[dir] = cur
229 if (dir === root) break
230 dir = dirname(dir)
231 }
232 }
233 return out
234}
235
236export function underAny(id: string, set: Set<string>, root: string): boolean {
237 if (set.size === 0) return false
238 let cur = id
239 while (cur.length >= root.length) {
240 if (set.has(cur)) return true
241 const up = dirname(cur)
242 if (up === cur) break
243 cur = up
244 }
245 return false
246}
247
248export function ancestorsOf(id: string, root: string): string[] {
249 const out: string[] = []
250 if (!root) return out
251 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
252 return out
253}
254
255export function relative(root: string, path: string): string {
256 return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
257}
258
259export function visibleRows(t: FileTree): Row[] {
260 const kids = new Map<string, FileNode[]>()
261 for (const n of t.nodes) {
262 if (!t.showHidden && n.hidden) continue
263 const list = kids.get(n.parent)
264 if (list) list.push(n)
265 else kids.set(n.parent, [n])
266 }
267 const q = t.query.trim().toLowerCase()
268 let keep: Set<string> | null = null
269 if (q) {
270 keep = new Set()
271 for (const n of t.nodes) {
272 if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
273 keep.add(n.id)
274 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
275 }
276 }
277 const open = new Set(t.expanded)
278 const rows: Row[] = []
279 const walk = (dir: string, depth: number) => {
280 for (const n of kids.get(dir) ?? []) {
281 if (keep && !keep.has(n.id)) continue
282 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
283 rows.push({ node: n, depth, open: isOpen })
284 if (isOpen) walk(n.id, depth + 1)
285 }
286 }
287 walk(t.root, 0)
288 return rows
289}
290
291export function stamp(ms: number): string {
292 if (!ms) return ''
293 const d = new Date(ms)
294 const p = (v: number) => String(v).padStart(2, '0')
295 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
296}
297
298export function formatSize(bytes: number): string {
299 if (bytes < 0) return '?'
300 if (bytes < 1024) return `${bytes} B`
301 const units = ['K', 'M', 'G', 'T']
302 let v = bytes / 1024
303 let i = 0
304 while (v >= 1024 && i < units.length - 1) {
305 v /= 1024
306 i++
307 }
308 return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
309}
310types/index.d.ts 76 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 'prism-tree': {
68 tree: FileTree
69 // The preset picked with the header's theme button; undefined until
70 // one is picked, and until then the config option's preset applies.
71 theme: string
72 activity: Activity[]
73 }
74 }
75}
76