SLOPSHOPPER

prism-tree

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

newpaneguardcommandtoastprompt
v0.2.0MITupdated 2026-10-09KKK12142/claude-mods/prism-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prism-tree
│ ┃ Files: app ✕ › fix the failing auth test and add an audit log call │ ┃ ✦ app󰁝 󰋜 󰑐 󰈈 󰋊 󰏘 │ ┃  no git repo · git status starts after git ⏺ Read(src/auth.ts) │ ┃ init ⎿ Read 6 lines │ ┃ / : search ⏎ jump ⏺ Update(src/auth.ts) │ ┃ ▣ client module ./rows.tsx ⎿ Added 2 lines, removed 1 line │ ┃ 󰕒 pushed ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /prism-tree │ ┃ ⎿ prism-tree: File tree on /work/app (follows the cwd). │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Files: app
✦ app󰁝 󰋜 󰑐 󰈈 󰋊 󰏘  no git repo · git status starts after git init / : search ⏎ jump ▣ client module ./rows.tsx 󰕒 pushed
README

prism-tree

오른쪽 사이드바에 작업 폴더의 파일 트리를 띄우는 모드입니다. 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 항목에 있습니다.

  • Theme: aurora, ember, forest, mono, paper, classic
  • Claude activity: 반짝일 활동. reads and writes(기본), writes, reads, none
  • Follow Claude: on(기본)이면 Claude가 건드린 파일로 트리가 따라가고, off면 보던 위치를 유지
  • Right column: date(기본) 또는 size
  • Glyphs: auto, nerd, plain

출처

claude-code-filetree(Kurt Buhler, MIT 라이선스)를 바탕으로 만들었습니다(LICENSE 참고). 트리, git, 활동 감지 로직은 원본에서 왔고, 이 버전은 고정 색과 Omarchy 테마 파일 읽기를 내장 테마 프리셋과 테마 전환 버튼으로 바꿨습니다.

Source 8 files
hooks/register.tsx 1475 lines
1import { 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.base
hooks/open.ts 41 lines
1export 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}
41
hooks/git.ts 252 lines
1import { 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}
252
hooks/rows.tsx 143 lines
1import 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
143
hooks/icons.ts 123 lines
1import 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}
123
hooks/themes.ts 141 lines
1import 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}
141
hooks/tree.ts 310 lines
1import 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}
310
types/index.d.ts 76 lines
1export 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