SLOPSHOPPER

filetree

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

newpaneguardcommandtoastprompt
v0.2.24+cockpit.1MITupdated 2026-10-10cGradying/claude-code-cockpit/filetree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · filetree
│ ┃ 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 │ ┃ │ ┃ › /filetree │ ┃ ⎿ filetree: 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

claude-code-cockpit

A one-line band above the Claude Code prompt, a roomy Cockpit panel beside the conversation, and seven community mods, installed as one plugin. The band shows only what you glance at. The panel has everything else: prompt-cache countdown, context, plan limits with their reset times, session cost, your next tasks and project shortcuts. Everything that spends tokens or redraws your screen starts off.

 ◔ 42m   ctx 72.5k   Next  Write docs/specs/PLAN-2.md for the phase 2 slices        Continue   Details

The band is drawn with Claude's own text and buttons, so it takes the app's fonts and theme, truncates the task by itself, and cannot flash or resize. Details (or /cockpit) opens the panel as a tab in the right-hand side panel.

<img src="docs/images/band.png" alt="The band above the prompt in the Claude desktop app: working, context 704.8k at 70 percent, the next task, and a Details button" width="720">

<img src="docs/images/panel.png" alt="The Cockpit panel in the side panel: prompt cache with a green bar, context at 70 percent, plan limits with reset times, session cost, next up and shortcuts" width="320">

These are screenshots of the real desktop app, showing the dark theme.

What is inside

PluginWhat it doesDefault hereOrigin
cockpitThe band, the Cockpit panel, next-task list, Continue / What's next / Compact buttons, guards, and a cheat-sheet skillonthis repo (MIT)
cache-taxShows the rewrite price before a cold send; opt-in keep-warm for the prompt cachewarns, never drops; keep-warm offkaranb192/cache-tax
claude-reflect (reflect-mod)Notices corrections and offers to save them as rules in CLAUDE.mdpausedBayramAnnakov/claude-reflect
skinsRedraws the transcript: tool rows, diff cards, table and code cards, spinneroff, its own band offhellosverre/claude-skins
filetreeIDE-style file tree in the sidebar with git status and read/write activityopens with /filetree (does not dock by itself)data-goblin/claude-code-filetree
replay-theaterStep through the last turn's file edits, one diff at a timeonanthropics/claude-code-playground
blast-radiusHolds rm -r, git reset --hard, git clean, force push and migrations until you press Proceedonanthropics/claude-code-playground
savvy-progressProgress bar and agents panel for flows and subagentsidle until something reportsJohnnyVizz/claude-kit

The seven community mods are vendored here at pinned commits, with small patches (listed in each folder's SOURCE.md) so they start quiet and stack above each other instead of replacing each other's band. Their original licences are kept.

Install

Needs Claude Code 2.1.287 or later.

/plugin marketplace add cGradying/claude-code-cockpit
/plugin install cockpit@janvin-mods

or from a terminal:

claude plugin marketplace add cGradying/claude-code-cockpit
claude plugin install cockpit@janvin-mods --scope user

cockpit lists the seven mods as dependencies, so installing it installs them all. If a dependency does not install by itself, install it by name, for example claude plugin install skins@janvin-mods. Then restart Claude Code (or the desktop app) and open a new session.

To remove everything: /plugin, pick cockpit, uninstall, and do the same for each mod you no longer want. Each one is independent.

A quick tour

The band. One line above the prompt: the cache countdown (◔ 42m, green, then amber, then red), context, and the next task. The 5-hour and weekly limits join the line only once they pass 60%, and 9router down shows only if it was up and stopped. Buttons sit at the right; click them, or focus the band (ctrl+x then tab) and press the key.

ButtonKeyDoes
ContinuecSends "continue with the next task" for the first open line of .claude/NEXT.md, and asks Claude to check it off when done. Hidden while a turn runs.
DetailsdOpens the Cockpit panel.
CompactkAppears past 70% context. Runs /compact and tells it to keep the plan, open tasks, PRs, decisions and open bugs.

The Cockpit panel. One tab in the side panel with room for each number: prompt cache (time left, a bar, what happens if it lapses), context (tokens, percent, window size, Compact), plan limits (each with a bar and when it resets), this session (cost, 9router), your next five tasks (Continue, What's next?), and your project shortcuts. Bars are solid boxes sized in percent and follow the app's light or dark theme. Open it with Details or /cockpit.

Guards that run quietly. A shell command carrying a Claude or Anthropic co-author line is refused. A prompt containing a pasted API key is held once (press Enter again to send it anyway). Prompts about UI or design get a hidden nudge toward the impeccable skill if you have it.

Commands. /cockpit, /next, /keepwarm, /cache-tax, /reflect-pause, /reflect-queue, /skin, /filetree, /replay, /agents-info. Details in docs/COMMANDS.md; inside Claude Code, the cockpit:help skill prints the same cheat sheet.

Quiet by default

Three things in the bundle can cost tokens or change your screen, so they start off:

  • Keep-warm (cache-tax) sends a real model request about every 50 minutes while armed. It is off; you arm it with /keepwarm. The cold-send guard only warns by default: it shows the rewrite price and still sends your message.
  • Correction checking (claude-reflect) sends short prompts to Haiku. It starts paused; /reflect-pause turns it on. remember: <rule> works either way and needs no model.
  • Skins redraw the whole transcript. It starts off; /skin noir turns it on.

How to change each: docs/CONFIGURATION.md.

Documentation

Status and limits

  • Written for Claude Code's mods feature, which is early access and may change between releases.
  • The band and panel were built by looking at the real desktop app, and are tested with Claude Code's plugin test kit on the terminal and desktop surfaces. They have been run in the desktop app's Code tab; the terminal layout is covered by tests but has had less eyes on it. Please open an issue with a screenshot if something looks wrong.
  • The file tree opens with /filetree (set autoOpen to on to dock it at session start). It needs a wide window, and upstream says it does not load in WSL sessions of the desktop app.
  • Cache timings assume the one-hour prompt cache. With a five-minute cache the countdown will be wrong.

Credits and licences

The code in cockpit/ is MIT, Copyright (c) 2026 cGradying (see LICENSE). Each vendored mod keeps its own licence file and authorship: the two Anthropic mods are Apache-2.0, the rest are MIT. See THIRD_PARTY_NOTICES.md. Thanks to the authors of all seven.

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