SLOPSHOPPER

savvy-status

Savvy Status: one pane per session with the Main Agent on top and every subagent below as a crab dressed for its role. Hover any agent to see a live file tree…

newpanebandguardcommandtoast
v0.4.0MITupdated 2026-10-15rakavanagh/savvy-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-status
│ ┃ Savvy Status ✕ › fix the failing auth test and add an audit log call │ ┃ 31% 5h ▰▰▱▱▱▱▱▱ │ ┃ ⏺ Read(src/auth.ts) │ ┃ strip-refresh not a git repo ↻ Reload ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ crab-main Main Agent ✓ ⎿ 3 pass, 1 fail │ ┃ main · — │ ┃ ████████████████████ ctx 0% · 0 ≈$0.00 0:… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ──────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ ──────────── │ ┃ Sub-agents ON for this session › /savvy-status │ ┃ No sub-agents yet this session. ⎿ savvy-status: File tree on /work/app (follows the cwd). │ ┃ │ ┃ crab-settings⚙ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Savvy Status
31% 5h ▰▰▱▱▱▱▱▱ strip-refresh not a git repo ↻ Reload crab-main Main Agent ✓ main · — ████████████████████ ctx 0% · 0 ≈$0.00 0:00 ──────────────────────────────────────────────────────── Sub-agents ON for this session No sub-agents yet this session. crab-settings⚙
README

🦀 Savvy Status

Watch your Claude Code subagents work as a crew of crabs, and keep them inside a budget.

<img src="assets/crew.svg" alt="The Savvy Status crew: main, scout, architect, builder, worker, reviewer and tester crabs" width="770">

One pane per session: the Main Agent on top, every subagent below it dressed for its role,<br> a live file tree of what each one reads and writes, and a gear crab that runs the whole crew.

Claude Code mod Version Tests License


✨ What you get

🦀 A crab for every agentEach subagent appears as a pixel crab in its role's costume, walking while it works. Its card shows the model, effort, steps, context gauge, cost estimate and run time.
🌳 Live file treesHover any agent to see the files it has read (purple) and written (orange), with the newest ones shimmering. The main tree follows Claude around the repo, with git status and an optional size column.
⚙️ The gear crabHover the gear crab (bottom right) for the delegation switch, model / effort / look per role, a cap on how many agents run at once, and your own orchestration rules.
🪙 Haiku 5.5 budget guardScout and Worker run on Haiku 5.5, which costs 5x more per request once its context passes 100k tokens. Their gauge fills toward 100k, and each one is told to wrap up at 80k and stop at 95k.
📊 Usage bandYour plan's 5-hour and 7-day limits sit across the top of the pane.
🌿 Branch stripBranches, open PRs with their checks, uncommitted changes, and a one-click merge sweep.

👥 The crew

Six roles ship in agents/. With delegation on, Claude hands work to them and the briefing tells it which one fits.

CrabRoleModel · effortDoesEdits files?
🧭scoutHaiku · lowSearches code and the web, inventories, traces usages; returns facts with path:line—
📐architectOpus · highTurns a goal into a step-by-step plan with risks and checks—
🔨builderOpus · mediumImplements changes that need judgment while they are made✅
🪛workerHaiku · lowApplies a fully described, mechanical edit exactly as written✅
📋reviewerOpus · highReviews a diff, result, plan or document; findings ranked most severe first—
🧪testerSonnet · mediumRuns tests, builds and repros; pins down the root cause without changing code—

Every model and effort above is a default you can change per role in the gear crab's Roles tab.

🪙 Keeping Haiku in the cheap tier

Haiku 5.5 bills a request at $0.10 / $0.50 per million tokens while its prompt stays within 100k tokens, and at $0.50 / $2.50 past it. Savvy Status keeps Haiku agents under that line in four ways:

  1. Small briefs. The orchestration rules size each Haiku brief to finish well under 80k and keep handoffs lean: paths and line ranges, never pasted file contents.
  2. A gauge that means something. A Haiku 5.5 card reads ctx/100k, turning amber at 80k and red at 95k.
  3. A note at the right moment. When a tool result would take the agent's next request past 80k, a note rides along with it: finish the item you are on and report. At 95k it says stop and write your final report.
  4. A small starting context. Haiku agents get a short tool list, so they start at about 6k tokens instead of about 53k.

In a live test, a Scout reading a large file was warned on the read that crossed 80k and stopped at 92k. Before the guard, the same run ended at 119k, billed at 5x.

📦 Install

Savvy Status is a Claude Code mod. Point Claude Code at this folder, either for one session:

claude --plugin-dir /path/to/savvy-status

or for every session, in ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/savvy-status"
  }
}

After changing the code, run /reload-plugins in a session to pick it up.

🕹️ Commands and tools

/savvy-status [path]Show the pane, rooted at path (default: the current directory)
/subagents on · off · statusFlip delegation for this session, or check it; with no argument, opens the settings in the pane
progress toolThe main agent lays out a planned flow so the pane can match runs to tasks
step toolA subagent reports its steps, filling its progress bar

Options

Set these in Claude Code's plugin settings:

OptionChoicesDefault
Claude activityreads and writes · writes · reads · nonereads and writes
Glyphsauto · nerd · plainauto
Follow Claudeon · offon
Right columndate · sizedate
Sub-agents languageauto · en · ruauto

🧪 Development

claude plugin test .
claude plugin validate .

plugin test needs a recent Claude Code (2.1.29x or later). Tests run the mod inside the engine's own test host, on every surface it draws for (terminal and desktop), with no file system, network or process access.

agents/        the six role definitions
hooks/         the mod: register.tsx (pane, crew, budget guard), tree, git, rows, strip
scripts/       branch sweep and its regression check
tests/         engine-hosted tests
assets/        README art, drawn by the mod's own crab renderer

🙏 Credits

Savvy Status merges two MIT-licensed mods:

  • filetree by Kurt Buhler: the tree, git status and activity shimmer
  • savvy-progress by johnnyvizz: the subagents panel, progress bar, and the Pixel Clawd crabs (from DockCrab)

More contributors are listed in ACKNOWLEDGEMENTS.md. Released under the MIT License, which carries each author's notice.

[!NOTE] Savvy Status is an unofficial community project. It is not affiliated with, sponsored by, or endorsed by Anthropic. Claude and Claude Code are trademarks of Anthropic.

Source 8 files
hooks/register.tsx 4062 lines
1import { atom, type BuiltinToolResults, type EngineInterface, read, type Register, type RenderElement, type RenderInput, type Timer, update } from 'claude-code'
2import { openCommand } from './open'
3
4import type {
5  Activity,
6  AgentRun,
7  CrewSettings,
8  EffortChoice,
9  FileNode,
10  FileTree,
11  Flow,
12  KnownAgent,
13  ModeState,
14  ModelChoice,
15  Panel,
16  Phase,
17  PlannedTask,
18  RoleSetting,
19  SessionMode,
20  Theme,
21  StripPr,
22  StripState,
23  Touch,
24  UsageLimit,
25} from '../types'
26import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve, TONES } from './git'
27import type { RowSpec, RowsProps, Seg } from './rows'
28import { countChanges, parseBranches, parseDryRun, parsePrs, parseReport } from './strip'
29import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR } from './icons'
30import {
31  ancestorsOf,
32  type Change,
33  DEFAULT_THEME,
34  dirname,
35  emptyTree,
36  formatSize,
37  inside,
38  isAbsolute,
39  join,
40  mapper,
41  parseGit,
42  parseNumstat,
43  parseTheme,
44  posix,
45  relative,
46  dropBelow,
47  useDrives,
48  rollCounts,
49  replaceChildren,
50  rollUp,
51  stamp,
52  toNodes,
53  underAny,
54  visibleRows,
55} from './tree'
56
57const TREE = { plugin: 'savvy-status', key: 'tree' } as const
58const THEME = { plugin: 'savvy-status', key: 'theme' } as const
59const ACTIVITY = { plugin: 'savvy-status', key: 'activity' } as const
60const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
61const BRANCH_ROW = '#branch'
62const FLASH_MS = 2700
63const RUNNING_MAX_MS = 600_000
64const NO_REPO_DEPTH = 4
65const DOUBLE_MS = 450
66const FIND_LIMIT = 200
67const READ_REVEAL_LIMIT = 12
68const SEARCH_REVEAL_LIMIT = 60
69const ACTIVITY_TTL_MS = 45_000
70const ADD_COLOR = '#98c379'
71const DEL_COLOR = '#e06c75'
72const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
73const THEME_POLL_MS = 2000
74const FONT_SCRIPT =
75  'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
76  'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
77  '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
78  '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 ' +
79  'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
80  '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; ' +
81  'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
82const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
83const HOME_PRUNE = ['Library', 'AppData', '.Trash']
84const SIZE_TIMEOUT_MS = 15_000
85const SIZE_JOBS = 2
86const SIZE_WALK_LIMIT = 50_000
87
88let blink: Timer | null = null
89let themePoll: Timer | null = null
90let themeMtime: number | null = null
91let generation = 0
92let lastPress = { key: '', at: 0 }
93let noNerd = false
94let glyphSetting = 'auto'
95let follow = true
96let followClaude = true
97let scanning: Promise<void> | null = null
98let scanJobs: Job[] = []
99let gitRun: Promise<void> | null = null
100let gitNext: Promise<void> | null = null
101let queuedReads: string[] = []
102let showReads = true
103let showWrites = true
104let searchIndex: { root: string; paths: Promise<string[]> } | null = null
105let activityId = 0
106let pointer = true
107let view = { from: 0, max: 0 }
108let lastSync = 0
109let home = ''
110let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
111let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
112let markId = 0
113let listSeq = 0
114let lastRoot = ''
115// A settings tab is open under Sub-agents: the pane scrolls as a whole, and the search box takes no
116// focus (focusing it would scroll it back into view at the top).
117let sizeDefault = false
118let sizeEpoch = 0
119let sizeActive = 0
120let sizeQueue: string[] = []
121const sizing = new Set<string>()
122const listLatest = new Map<string, number>()
123const unreadable = new Set<string>()
124const background = new Map<string, Background>()
125
126function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
127  platform ??= (async () => {
128    if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
129    try {
130      return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
131    } catch {
132      return 'linux'
133    }
134  })()
135  return platform
136}
137
138async function cwdOf($: EngineInterface): Promise<string> {
139  return posix(await $.session.cwd())
140}
141
142function clean(segs: Seg[]): Seg[] {
143  for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
144  return segs
145}
146
147function faint(hex: string): string {
148  const v = parseInt(hex.slice(1), 16)
149  const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
150  return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
151}
152
153async function get($: EngineInterface): Promise<FileTree> {
154  return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
155}
156
157async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
158  await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
159}
160
161function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
162  return put($, t => ({ ...t, ...fn(t) }))
163}
164
165async function activities($: EngineInterface): Promise<Activity[]> {
166  return (await $.state.get(ACTIVITY)).value ?? []
167}
168
169async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
170  await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
171}
172
173async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
174  try {
175    const entries = await $.fs.list(dir)
176    const resolved = await Promise.all(
177      entries.map(async e => {
178        if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
179        try {
180          const target = await $.fs.stat(join(dir, e.name))
181          return target.kind === 'dir'
182            ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
183            : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
184        } catch {
185          return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
186        }
187      }),
188    )
189    unreadable.delete(dir)
190    return toNodes(dir, resolved)
191  } catch (err) {
192    if (!(await exists($, dir))) return []
193    if (!unreadable.has(dir)) {
194      unreadable.add(dir)
195      $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
196    }
197    return null
198  }
199}
200
201async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
202  const root = (await get($)).root
203  const seq = ++listSeq
204  for (const dir of dirs) listLatest.set(dir, seq)
205  const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
206  const listed = new Map<string, FileNode[]>()
207  for (const [dir, kids] of results) {
208    if (listLatest.get(dir) !== seq) continue
209    listLatest.delete(dir)
210    if (kids) listed.set(dir, kids)
211  }
212  await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
213  return listed
214}
215
216async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
217  return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
218}
219
220async function detectRepo($: EngineInterface): Promise<void> {
221  const t = await get($)
222  if (!t.root) return
223  let top = ''
224  let prefix = ''
225  try {
226    const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
227    const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
228    prefix = p.trim()
229    top = tl.trim()
230  } catch {
231    top = ''
232  }
233  await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
234}
235
236async function readGit($: EngineInterface): Promise<void> {
237  const t = await get($)
238  if (!t.root || !t.top) return
239  const root = t.root
240  try {
241    const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
242    if (status.exitCode !== 0) return
243    const parsed = parseGit(status.stdout, root, t.prefix)
244    const diff: Record<string, [number, number]> = {}
245    const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
246    if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
247    const files = { ...parsed.files }
248    if (parsed.untrackedDirs.length) {
249      const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
250      if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
251    }
252    for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
253    dirty = { root, files }
254    await patch($, cur =>
255      cur.root === root
256        ? {
257            git: parsed.git,
258            ignored: parsed.ignored,
259            untrackedDirs: parsed.untrackedDirs,
260            branch: parsed.branch,
261            counts: rollCounts(files, root),
262            diff: rollUp(diff, root),
263          }
264        : {},
265    )
266  } catch {
267    return
268  }
269}
270
271function refreshGit($: EngineInterface): Promise<void> {
272  if (!gitRun) {
273    gitRun = readGit($).finally(() => {
274      gitRun = null
275    })
276    return gitRun
277  }
278  const rerun = () => {
279    gitNext = null
280    return refreshGit($)
281  }
282  gitNext ??= gitRun.then(rerun, rerun)
283  return gitNext
284}
285
286async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
287  const prev = await get($)
288  generation += 1
289  blink?.cancel()
290  blink = null
291  lastRoot = root
292  searchIndex = null
293  sizeEpoch += 1
294  sizeQueue = []
295  await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
296  await openPane($, focus ? 'asked' : 'files', focus)
297  await loadDirs($, [root])
298  await detectRepo($)
299  await refreshGit($)
300}
301
302async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
303  const tried = new Set<string>()
304  for (let pass = 0; pass < 32; pass++) {
305    const t = await get($)
306    const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
307    if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
308    const need = new Set<string>()
309    for (const p of paths) {
310      for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
311        if (!loaded.has(dir)) {
312          if (!tried.has(dir)) need.add(dir)
313          break
314        }
315      }
316    }
317    if (need.size === 0) return
318    for (const dir of need) tried.add(dir)
319    await loadDirs($, [...need])
320  }
321}
322
323function pruneArgs(root: string, ignored: string[] = []): string[] {
324  const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
325  return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
326}
327
328async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
329  const stats = await Promise.all(
330    paths.map(async p => {
331      try {
332        return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
333      } catch {
334        return ''
335      }
336    }),
337  )
338  return stats.filter(Boolean)
339}
340
341function broad(root: string): boolean {
342  return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
343}
344
345function rootOfDisk(root: string): boolean {
346  return root === '/' || /^[A-Za-z]:\/$/.test(root)
347}
348
349async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
350  if (rootOfDisk(root)) return []
351  const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
352  try {
353    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 })
354    return run.stdout.split('\0').filter(p => p && p !== root)
355  } catch {
356    return []
357  }
358}
359
360async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
361  if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
362  if (dirty.root !== root) return { hits: [], gone: [] }
363  const entries = Object.entries(dirty.files)
364  const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
365  const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
366  return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
367}
368
369type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
370
371async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
372  const ms = await $.clock.now()
373  const os = await osName($)
374  if (os !== 'darwin' || !writes) return { ms, mark: '', os }
375  const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
376  try {
377    await $.process.run(['touch', mark], { timeoutMs: 3_000 })
378    return { ms, mark, os }
379  } catch {
380    return { ms, mark: '', os }
381  }
382}
383
384async function walkSize($: EngineInterface, dir: string): Promise<number> {
385  let total = 0
386  let seen = 0
387  let level = [dir]
388  while (level.length) {
389    const next: string[] = []
390    for (const d of level) {
391      let entries
392      try {
393        entries = await $.fs.list(d)
394      } catch {
395        continue
396      }
397      for (const e of entries) {
398        if (++seen > SIZE_WALK_LIMIT) return -1
399        if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
400        else if (e.kind === 'file') total += e.size
401      }
402    }
403    level = next
404  }
405  return total
406}
407
408async function dirSize($: EngineInterface, dir: string): Promise<number> {
409  if (rootOfDisk(dir)) return -1
410  if ((await osName($)) === 'win32') return walkSize($, dir)
411  try {
412    const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
413    const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
414    return Number.isFinite(kb) ? kb * 1024 : -1
415  } catch {
416    return -1
417  }
418}
419
420function pumpSizes($: EngineInterface): void {
421  while (sizeActive < SIZE_JOBS && sizeQueue.length) {
422    const dir = sizeQueue.shift() ?? ''
423    const epoch = sizeEpoch
424    sizing.add(dir)
425    sizeActive += 1
426    void dirSize($, dir)
427      .then(async bytes => {
428        if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
429        const cur = await get($)
430        if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
431      })
432      .catch(() => undefined)
433      .finally(() => {
434        sizing.delete(dir)
435        sizeActive -= 1
436        pumpSizes($)
437      })
438  }
439}
440
441function wantSizes($: EngineInterface, dirs: string[]): void {
442  for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
443  pumpSizes($)
444}
445
446async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
447  sizeEpoch += 1
448  sizeQueue = []
449  await patch($, cur => {
450    if (!paths) return { dirSizes: {} }
451    const keep: Record<string, number> = {}
452    for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
453    return { dirSizes: keep }
454  })
455}
456
457function openDirs(t: FileTree): string[] {
458  const open = new Set(t.expanded)
459  return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
460}
461
462function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
463  if (followClaude) return { scroll: null }
464  const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
465  const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
466  return at < 0 ? {} : { scroll: at }
467}
468
469async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
470  const unique = Object.keys(tones)
471  if (unique.length === 0) return
472  const mine = ++generation
473  const root = (await get($)).root
474  blink?.cancel()
475  blink = null
476  await patch($, cur => {
477    if (cur.root !== root) return {}
478    const open = new Set(cur.expanded)
479    const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
480    const dim = new Set<string>()
481    const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
482    if (cur.flashOn) {
483      for (const id of cur.flashDim) dim.add(id)
484    }
485    for (const id of unique) {
486      const tone = tones[id] ?? 'orange'
487      all[id] = tone
488      if (id === BRANCH_ROW) continue
489      const chain = ancestorsOf(id, cur.root)
490      if (!chain.some(a => !open.has(a))) continue
491      for (const a of chain) {
492        if (!open.has(a)) {
493          dim.add(a)
494          all[a] = all[a] ?? tone
495        }
496        open.add(a)
497      }
498    }
499    for (const id of bright) dim.delete(id)
500    return {
501      flash: [...bright],
502      flashDim: [...dim],
503      flashOn: true,
504      flashTones: all,
505      expanded: [...open],
506      ...keepTop(cur, [...open]),
507    }
508  })
509  if (generation !== mine) return
510  blink = $.clock.after(FLASH_MS, () => {
511    if (generation !== mine) return
512    blink = null
513    void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
514  })
515}
516
517async function followCwd($: EngineInterface): Promise<boolean> {
518  if (!follow) return false
519  const cwd = await cwdOf($)
520  const t = await get($)
521  if (t.root === cwd) return false
522  await reset($, cwd)
523  return true
524}
525
526type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
527type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
528type Background = { p: Pending | null; job: Job; reads: string[] }
529type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
530type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
531
532const PROOF: Record<string, (op: GitOperation) => boolean> = {
533  push: op => Boolean(op.push),
534  merge: op => op.branch?.action === 'merged',
535  rebase: op => op.branch?.action === 'rebased',
536  'pr create': op => op.pr?.action === 'created',
537  'pr merge': op => op.pr?.action === 'merged',
538  'pr comment': op => op.pr?.action === 'commented',
539}
540
541async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
542  const now = await $.clock.now()
543  const ids = actions.map(() => ++activityId)
544  await setActivities($, cur => [
545    ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
546    ...actions.map((a, i) => ({
547      id: ids[i] ?? 0,
548      kind: a.kind,
549      label: a.running,
550      state: 'running' as const,
551      detail: '',
552      at: now,
553      tone: a.tone,
554      nerd: a.icon.nerd,
555      plain: a.icon.plain,
556    })),
557  ])
558  return ids
559}
560
561async function headOf($: EngineInterface, cwd: string): Promise<string> {
562  try {
563    const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
564    return run.exitCode === 0 ? run.stdout.trim() : ''
565  } catch {
566    return ''
567  }
568}
569
570async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
571  const chain = chainOf(p.command)
572  const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
573  return p.actions.map(a => {
574    if (a.verb === 'commit') {
575      const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
576      return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
577    }
578    if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
579    if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
580    return { state: 'done', label: `ran ${a.kind}`, detail: '' }
581  })
582}
583
584async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
585  const now = await $.clock.now()
586  await setActivities($, cur =>
587    cur.map(a => {
588      const i = p.ids.indexOf(a.id)
589      const result = i < 0 ? undefined : results[i]
590      return result ? { ...a, ...result, at: now } : a
591    }),
592  )
593  $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
594}
595
596function failAll(p: Pending): Outcome[] {
597  return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
598}
599
600function proven(p: Pending | null, results: Outcome[]): GitAction[] {
601  return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
602}
603
604function taskEnds(text: string): [string, string][] {
605  return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
606    const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
607    const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
608    return id && status ? [[id, status] as [string, string]] : []
609  })
610}
611
612async function settleBackground($: EngineInterface, text: string): Promise<void> {
613  if (background.size === 0 || !text.includes('<task-notification>')) return
614  for (const [id, status] of taskEnds(text)) {
615    const task = background.get(id)
616    if (!task) continue
617    background.delete(id)
618    const ok = status === 'completed'
619    const results = task.p ? await outcomes($, task.p, ok, undefined) : []
620    if (task.p) await finishGit($, task.p, results)
621    if (showReads) queuedReads.push(...task.reads)
622    scheduleScan($, { ...task.job, actions: proven(task.p, results) })
623  }
624}
625
626async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
627  const map = mapper(root, prefix)
628  try {
629    const run = committed
630      ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
631      : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
632    return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
633  } catch {
634    return []
635  }
636}
637
638async function gitDir($: EngineInterface, cwd: string): Promise<string> {
639  for (let dir = cwd, i = 0; i < 64; i++) {
640    try {
641      const dot = join(dir, '.git')
642      const st = await $.fs.stat(dot)
643      if (st.kind === 'dir') return dot
644      const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
645      if (ref) return resolve(dir, ref, home)
646    } catch {
647      const up = dirname(dir)
648      if (up === dir) return ''
649      dir = up
650      continue
651    }
652    return ''
653  }
654  return ''
655}
656
657async function sync($: EngineInterface, force = false): Promise<void> {
658  const now = await $.clock.now()
659  if (!force && now - lastSync < 2_000) return
660  lastSync = now
661  searchIndex = null
662  const t = await get($)
663  if (!t.root) return
664  await loadDirs($, [t.root, ...openDirs(t)])
665  if (t.top) await refreshGit($)
666}
667
668async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
669  const t = await get($)
670  const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
671  const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
672  $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
673}
674
675async function exists($: EngineInterface, path: string): Promise<boolean> {
676  try {
677    await $.fs.stat(path)
678    return true
679  } catch {
680    return false
681  }
682}
683
684async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
685  const reads = queuedReads
686  queuedReads = []
687  if (await followCwd($)) return
688  const t = await get($)
689  if (!t.root) return
690  const actions = jobs.flatMap(j => j.actions)
691  const writers = jobs.filter(j => !j.readOnly)
692  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 })
693  const writes = !jobs.every(j => j.readOnly)
694  const before = dirty.root === t.root ? dirty.files : {}
695  if (jobs.some(j => j.initRepo)) await detectRepo($)
696  else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
697  const probed = await get($)
698  if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
699  if (writes) {
700    searchIndex = null
701    await staleSizes($)
702  }
703  const fresh = await get($)
704  const ignored = new Set(fresh.ignored)
705  const tones: Record<string, string> = {}
706  if (writes) {
707    const found = fresh.top
708      ? await changedInRepo($, t.root, since, before, fresh.ignored)
709      : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
710    const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
711    await revealPaths($, hits)
712    const loaded = await get($)
713    const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
714    const listed = await loadDirs($, dirs)
715    if (!fresh.top && since.os === 'win32')
716      for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
717    await patch($, cur => {
718      const ids = new Set(cur.nodes.map(n => n.id))
719      return { expanded: cur.expanded.filter(id => ids.has(id)) }
720    })
721    const present = new Set((await get($)).nodes.map(n => n.id))
722    const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
723    if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
724  }
725  if (showReads) {
726    const found: string[] = []
727    for (const r of reads) {
728      if (found.length >= READ_REVEAL_LIMIT) break
729      if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
730    }
731    if (found.length) {
732      await revealPaths($, found)
733      const shown = new Set((await get($)).nodes.map(n => n.id))
734      for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
735    }
736  }
737  if (showWrites && actions.length) {
738    const final = await get($)
739    const committed = actions.some(a => a.verb === 'commit')
740    if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
741      const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
742      await revealPaths($, paths)
743      const shown = new Set((await get($)).nodes.map(n => n.id))
744      for (const x of paths) if (shown.has(x)) tones[x] = 'green'
745    }
746    const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
747    if (pushLike) tones[BRANCH_ROW] = pushLike.tone
748    else if (committed) tones[BRANCH_ROW] = 'green'
749  }
750  if (Object.keys(tones).length) void openPane($, 'files')
751  await flash($, tones)
752}
753
754function scheduleScan($: EngineInterface, job: Job): void {
755  scanJobs.push(job)
756  if (scanning) return
757  scanning = (async () => {
758    while (scanJobs.length) {
759      const jobs = scanJobs
760      scanJobs = []
761      try {
762        await afterBash($, jobs)
763      } finally {
764        const marks = jobs.map(j => j.since.mark).filter(Boolean)
765        if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
766      }
767    }
768  })().finally(() => {
769    scanning = null
770  })
771}
772
773async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
774  void openPane($, 'files')
775  if (await followCwd($)) return
776  const t = await get($)
777  if (!t.root) return
778  const within = paths.map(posix).filter(p => inside(t.root, p))
779  if (within.length === 0) return
780  if (tone !== 'purple') {
781    searchIndex = null
782    await staleSizes($, within)
783    await revealPaths($, within.map(dirname))
784    await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
785    await refreshGit($)
786  } else await revealPaths($, within)
787  if (!show) return
788  const tones: Record<string, string> = {}
789  for (const p of within) tones[p] = tone
790  await flash($, tones)
791}
792
793async function reveal($: EngineInterface, paths: string[]): Promise<void> {
794  if (await followCwd($)) return
795  await revealPaths($, paths)
796  await patch($, cur => {
797    const open = new Set(cur.expanded)
798    for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
799    return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
800  })
801}
802
803async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
804  const out: string[] = []
805  let level = [root]
806  for (let d = 0; d < depth && level.length && out.length < limit; d++) {
807    const next: string[] = []
808    for (const dir of level) {
809      for (const n of (await list($, dir)) ?? []) {
810        if (n.kind === 'dir') {
811          if (!PRUNE.includes(n.name)) next.push(n.id)
812        } else out.push(n.id)
813      }
814      if (out.length >= limit) break
815    }
816    level = next
817  }
818  return out
819}
820
821async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
822  try {
823    if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
824    const run = t.top
825      ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
826      : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
827    if (t.top && run.exitCode !== 0) return null
828    const paths = run.stdout.split('\0').filter(Boolean)
829    if (run.isStdoutTruncated) paths.pop()
830    return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
831  } catch {
832    return null
833  }
834}
835
836function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
837  if (searchIndex?.root !== t.root) {
838    const index: { root: string; paths: Promise<string[]> } = {
839      root: t.root,
840      paths: listAll($, t).then(paths => {
841        if (paths === null && searchIndex === index) searchIndex = null
842        return paths ?? []
843      }),
844    }
845    searchIndex = index
846  }
847  return searchIndex.paths
848}
849
850async function search($: EngineInterface, query: string): Promise<string[]> {
851  if (!(await get($)).query.trim()) searchIndex = null
852  await patch($, () => ({ query }))
853  const q = query.trim().toLowerCase()
854  if (!q) return []
855  const t = await get($)
856  const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
857  if ((await get($)).query !== query) return []
858  await revealPaths($, hits)
859  const shown = await get($)
860  const ids = new Set(shown.nodes.map(n => n.id))
861  const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
862  if (stale.length) await loadDirs($, stale)
863  return hits
864}
865
866async function jump($: EngineInterface, query: string): Promise<void> {
867  const hits = (await search($, query)).slice(0, 10)
868  await patch($, cur => {
869    const open = new Set(cur.expanded)
870    for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
871    return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
872  })
873}
874
875async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
876  try {
877    const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
878    return out === 'stale' || out === 'missing' ? out : 'ok'
879  } catch {
880    return 'missing'
881  }
882}
883
884async function finePointerOk($: EngineInterface): Promise<boolean> {
885  if (!(await $.env.get('HERDR_ENV'))) return true
886  try {
887    const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
888    const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
889    return major * 1e6 + minor * 1e3 + fix >= 9_001
890  } catch {
891    return false
892  }
893}
894
895async function openFile($: EngineInterface, path: string): Promise<void> {
896  const { argv, init } = openCommand(await osName($), path)
897  try {
898    const run = await $.process.run(argv, init)
899    if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
900  } catch {
901    $.ui.toast(`could not open ${path} with ${argv[0]}`)
902  }
903}
904
905async function toggle($: EngineInterface, n: FileNode): Promise<void> {
906  if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
907  await patch($, t => {
908    const open = new Set(t.expanded)
909    if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
910    return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
911  })
912}
913
914async function press($: EngineInterface, n: FileNode): Promise<void> {
915  const now = await $.clock.now()
916  const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
917  lastPress = { key: isDouble ? '' : n.id, at: now }
918  if (!isDouble) {
919    await toggle($, n)
920    return
921  }
922  await openNode($, n)
923}
924
925async function openNode($: EngineInterface, n: FileNode): Promise<void> {
926  if (n.kind === 'dir') {
927    follow = false
928    await reset($, n.id)
929  } else await openFile($, n.id)
930}
931
932async function loadTheme($: EngineInterface): Promise<void> {
933  const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
934  try {
935    const stat = await $.fs.stat(path)
936    if (stat.kind !== 'file') throw new Error('no theme file')
937    if (stat.mtimeMs === themeMtime) return
938    await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
939    themeMtime = stat.mtimeMs
940  } catch {
941    if (themeMtime === 0) return
942    await $.state.set(THEME, DEFAULT_THEME)
943    themeMtime = 0
944  }
945}
946
947function shortPath(path: string): string {
948  return home && inside(home, path) ? `~${path.slice(home.length)}` : path
949}
950
951// ---------------------------------------------------------------------------
952// Pane opening, shared by the tree and the agents.
953
954export const PANE = 'savvy-status'
955export const TITLE = 'Savvy Status'
956
957// `docked` is false while the terminal can only seat the pane inline above the prompt;
958// `personClosed` is true once the person shut it by hand, until their next prompt.
959export const paneState = { docked: true, personClosed: false }
960
961// One pane for everything: opens on session start, on any file Claude touches and on
962// any subagent. Asked (a command or press) always opens, with the keyboard if `focus`.
963export async function openPane($: EngineInterface, why: 'agent' | 'files' | 'asked', focus = false): Promise<void> {
964  if (why === 'asked') {
965    paneState.docked = true
966    paneState.personClosed = false
967  }
968  if (!paneState.docked || (why === 'files' && paneState.personClosed)) return
969  if (why === 'agent') paneState.personClosed = false
970  if (!focus && (await $.ui.panes()).some(p => p.id === PANE)) return
971  await $.ui.open({ id: PANE, title: TITLE, ...(focus ? { focus: true } : {}) })
972}
973
974// ---------------------------------------------------------------------------
975// Sub-agents: ported from savvy-progress (johnnyvizz/claude-kit), drawn under the tree.
976
977type On = Parameters<Register>[0]
978type Options = Parameters<Register>[1]
979
980const flow = atom({ plugin: 'savvy-status', key: 'flow' } as const, null)
981const agents = atom({ plugin: 'savvy-status', key: 'agents' } as const, [])
982const panel = atom({ plugin: 'savvy-status', key: 'panel' } as const, {
983  isCompact: false,
984  isDoneCollapsed: false,
985  autoOpenedFor: '',
986  crewTab: '',
987})
988const now = atom({ plugin: 'savvy-status', key: 'now' } as const, 0)
989const nowAtom = now
990
991const TOOL = 'mcp__savvy-status__progress'
992const STEP_TOOL = 'mcp__savvy-status__step'
993const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
994const ACCENT = '#8f8cf4'
995const DONE = '#5fbf8f'
996
997type ProgressInput = {
998  title?: string
999  total?: number
1000  done?: number
1001  phase?: Phase
1002  finished?: boolean
1003  tasks?: { title?: string; tier?: string; after?: number[] }[]
1004}
1005
1006// ---------------------------------------------------------------------------
1007// Language: the `language` option, else Claude Code's `language` setting, else the
1008// process locale; English when nothing says Russian.
1009
1010type Lang = 'en' | 'ru'
1011
1012const STRINGS = {
1013  en: {
1014    section: 'Sub-agents',
1015    cost: 'Cost',
1016    tokens: 'Tokens',
1017    time: 'Time',
1018    collapse: 'Collapse',
1019    expand: 'Expand',
1020    clear: 'Clear finished',
1021    running: 'Running',
1022    finished: 'Finished',
1023    planned: 'Planned',
1024    round: 'round',
1025    failed: 'error',
1026    after: 'after',
1027    tokensWord: 'tokens',
1028    agentsCount: 'agents',
1029    isRunning: 'running',
1030    isFinished: 'finished',
1031    isPlanned: 'planned',
1032    done: 'Done',
1033    plan: 'Plan',
1034    design: 'Design',
1035    tasks: 'Tasks',
1036    review: 'Review',
1037    busy: 'running',
1038  },
1039  ru: {
1040    section: 'Субагенты',
1041    cost: 'Стоимость',
1042    tokens: 'Токены',
1043    time: 'Время',
1044    collapse: 'Свернуть',
1045    expand: 'Развернуть',
1046    clear: 'Убрать завершённых',
1047    running: 'Работают',
1048    finished: 'Завершены',
1049    planned: 'Запланированы',
1050    round: 'раунд',
1051    failed: 'ошибка',
1052    after: 'после',
1053    tokensWord: 'токенов',
1054    agentsCount: 'агентов',
1055    isRunning: 'работает',
1056    isFinished: 'завершён',
1057    isPlanned: 'запланирована',
1058    done: 'Готово',
1059    plan: 'План',
1060    design: 'Дизайн',
1061    tasks: 'Задачи',
1062    review: 'Ревью',
1063    busy: 'в работе',
1064  },
1065} as const
1066
1067// Module scope is fine here: session.start sets it again on every (re)load.
1068let lang: Lang = 'en'
1069const tr = () => STRINGS[lang]
1070
1071const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
1072
1073async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
1074  if (option === 'en' || option === 'ru') return option
1075  try {
1076    const settings = (await $.settings.read()) as Record<string, unknown>
1077    if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
1078  } catch {
1079    // No settings: fall through to the locale.
1080  }
1081  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
1082  return isRussian(locale) ? 'ru' : 'en'
1083}
1084
1085const blank = (): Flow => ({
1086  title: 'savvy-flow',
1087  total: 0,
1088  done: 0,
1089  running: 0,
1090  phase: 'plan',
1091  isFinished: false,
1092  tasks: [],
1093})
1094
1095const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
1096  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
1097
1098const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
1099  tasks
1100    ?.filter(t => t.title?.trim())
1101    .map(t => ({
1102      title: (t.title ?? '').trim(),
1103      tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
1104      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
1105    }))
1106
1107const merge = (prev: Flow | null, input: ProgressInput): Flow => {
1108  // A new title means a new flow: never carry counters over from an earlier one.
1109  const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
1110  const tasks = cleanTasks(input.tasks) ?? base.tasks
1111  const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
1112  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
1113  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
1114  return {
1115    ...base,
1116    title: input.title?.trim() || base.title,
1117    total,
1118    done,
1119    phase: input.finished ? 'close' : phase,
1120    isFinished: input.finished === true,
1121    tasks,
1122  }
1123}
1124
1125const label = (f: Flow): string => {
1126  const s = tr()
1127  if (f.isFinished) return s.done
1128  if (f.phase === 'plan') return s.plan
1129  if (f.phase === 'design') return s.design
1130  const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
1131  return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
1132}
1133
1134const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
1135
1136// Deterministic noise so the dither does not shimmer between redraws.
1137const noise = (x: number, y: number): number => {
1138  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
1139  return s - Math.floor(s)
1140}
1141
1142// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
1143// so title, bar, percent and the crab live in one drawing; only the count and
1144// the dismiss are Buttons beside it.
1145const H = 22
1146const BAR_H = 16
1147const CRAB_W = 26
1148const CELL = 3
1149const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
1150
1151const xml = (s: string): string =>
1152  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
1153
1154// Rough advance of system UI text, in em; good enough to size the title's slot.
1155const charEm = (ch: string): number =>
1156  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
1157
1158const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
1159
1160// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
1161const fitText = (s: string, size: number, maxW: number): string => {
1162  if (textWidth(s, size) <= maxW) return s
1163  let out = ''
1164  for (const ch of s) {
1165    if (textWidth(out + ch + '…', size) > maxW) break
1166    out += ch
1167  }
1168  return out + '…'
1169}
1170
1171const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
1172  // The title takes what it needs, up to 40% of the row; the bar takes the rest.
1173  const title = fitText(f.title, 13, Math.max(60, W * 0.4))
1174  const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
1175  const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
1176  const color = f.isFinished ? DONE : ACCENT
1177  const y0 = (H - BAR_H) / 2
1178  const fillW = Math.round(BAR_W * ratio(f))
1179  const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
1180  const dots: string[] = []
1181
1182  // Dithered fill: sparse at the start, dense toward the head.
1183  const cols = Math.floor(fillW / CELL)
1184  const rows = Math.floor(BAR_H / CELL)
1185  for (let c = 0; c < cols; c++) {
1186    const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
1187    for (let r = 0; r < rows; r++) {
1188      if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
1189    }
1190  }
1191  // Handed to workers, not yet accepted: a faint second layer.
1192  const faint: string[] = []
1193  for (let c = cols; c < Math.floor(runW / CELL); c++) {
1194    for (let r = 0; r < rows; r++) {
1195      if (noise(c + 7, r + 3) < 0.2) faint.push(`<rect class="t${Math.floor(noise(r + 5, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
1196    }
1197  }
1198
1199  const ticks: string[] = []
1200  for (let i = 1; i < f.total; i++) {
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/strip.ts 63 lines
1/** Pure parsers for the strip at the top of the pane (branches, Merge sweep, open PRs); the strip itself is in register.tsx. */
2import type { StripPr, StripState } from '../types'
3
4/** `git for-each-ref` lines of `<name>\t<*| >`: the branches, the checked-out one marked. */
5export function parseBranches(stdout: string): StripState['branches'] {
6  return stdout
7    .split('\n')
8    .map(l => l.split('\t'))
9    .filter(([name]) => Boolean(name))
10    .map(([name, head]) => ({ name: name!, isCurrent: head === '*' }))
11}
12
13/** The branches a dry run would delete (`would: git branch -D <b>`). */
14export function parseDryRun(stdout: string): string[] {
15  return [...stdout.matchAll(/^would: git branch -D (\S+)\s*$/gm)].map(m => m[1]!)
16}
17
18/** The hook's real-run report: `{"systemMessage": "..."}`, or '' when it did nothing. */
19export function parseReport(stdout: string): string {
20  const line = stdout.trim().split('\n').pop() ?? ''
21  try {
22    const msg = (JSON.parse(line) as { systemMessage?: unknown }).systemMessage
23    return typeof msg === 'string' ? msg : ''
24  } catch {
25    return ''
26  }
27}
28
29/** Lines of `git status --porcelain=v1`: how many paths are uncommitted. */
30export function countChanges(stdout: string): number {
31  return stdout.split('\n').filter(l => l.trim()).length
32}
33
34const FAILED = new Set(['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
35
36/** One PR's `statusCheckRollup` (CheckRuns carry status/conclusion, StatusContexts carry state) folded to one word. */
37function rollup(entries: unknown): StripPr['checks'] {
38  if (!Array.isArray(entries) || entries.length === 0) return 'none'
39  const all = entries as { status?: string; conclusion?: string; state?: string }[]
40  if (all.some(c => FAILED.has(c.conclusion ?? '') || c.state === 'FAILURE' || c.state === 'ERROR')) return 'failing'
41  if (all.some(c => (c.status !== undefined && c.status !== 'COMPLETED') || c.state === 'PENDING' || c.state === 'EXPECTED')) return 'pending'
42  return 'passing'
43}
44
45/** `gh pr list --json number,title,headRefName,url,statusCheckRollup`: the PRs with their checks; [] on anything unreadable. */
46export function parsePrs(stdout: string): StripPr[] {
47  try {
48    const list = JSON.parse(stdout) as unknown
49    if (!Array.isArray(list)) return []
50    return list
51      .filter((p): p is Record<string, unknown> => Boolean(p) && typeof p === 'object' && typeof (p as { number?: unknown }).number === 'number')
52      .map(p => ({
53        number: p.number as number,
54        title: typeof p.title === 'string' ? p.title : '',
55        branch: typeof p.headRefName === 'string' ? p.headRefName : '',
56        url: typeof p.url === 'string' ? p.url : '',
57        checks: rollup(p.statusCheckRollup),
58      }))
59  } catch {
60    return []
61  }
62}
63
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 219 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
65
66export type StripState = {
67  top: string
68  branches: { name: string; isCurrent: boolean }[]
69  sweepable: string[]
70  isSweeping: boolean
71  isReloading: boolean
72  /** False where `/reload-plugins` is refused (a desktop-app or other remote session); the Reload button hides. */
73  canReload: boolean
74  /** Uncommitted changes in the repo (`git status --porcelain` lines); 0 when clean or not a repo. */
75  changes: number
76  /** The person's open PRs for this repo (`gh pr list --author @me`); [] when gh fails. */
77  prs: StripPr[]
78}
79
80/** One plan window as the usage band shows it: the 5-hour or the 7-day, as a whole percent used. */
81export type UsageLimit = {
82  label: '5h' | '7d'
83  percent: number
84  /** When the window resets, epoch ms; absent when the engine reported no time. */
85  resetsAt?: number
86}
87
88export type StripPr = {
89  number: number
90  title: string
91  branch: string
92  url: string
93  checks: 'passing' | 'failing' | 'pending' | 'none'
94}
95
96export type Phase = 'plan' | 'design' | 'delegate' | 'review' | 'close'
97
98export type PlannedTask = {
99  title: string
100  tier: string
101  after: number[]
102}
103
104export type Flow = {
105  title: string
106  total: number
107  done: number
108  running: number
109  phase: Phase
110  isFinished: boolean
111  tasks: PlannedTask[]
112}
113
114export type AgentStatus = 'running' | 'done' | 'failed'
115
116export type AgentRun = {
117  id: string
118  agentId?: string
119  type: string
120  description: string
121  model: string
122  effort?: string
123  status: AgentStatus
124  startedAt: number
125  endedAt?: number
126  contextTokens: number
127  contextMax: number
128  tokens: number
129  costUsd: number
130  steps: number
131  round: number
132  /** Self-reported by the worker through the `step` tool. */
133  stepDone?: number
134  stepTotal?: number
135  stepNote?: string
136}
137
138export type Panel = {
139  isCompact: boolean
140  isDoneCollapsed: boolean
141  autoOpenedFor: string
142  /** Which controls tab is open under Sub-agents; '' for none. */
143  crewTab?: '' | 'roles' | 'defaults' | 'rules'
144  /** Cards opened by a click on their crab: 'main', an agent's id, or 'settings'. */
145  openCards?: string[]
146}
147
148/** A model choice for a role: `default` leaves the agent's own choice alone. */
149export type ModelChoice = 'default' | 'inherit' | 'haiku' | 'sonnet' | 'opus' | 'fable'
150
151/** An effort choice for a role: `default` leaves the agent's own effort alone. */
152export type EffortChoice = 'default' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
153
154/** What one agent type is set to. */
155export type RoleSetting = {
156  model: ModelChoice
157  effort: EffortChoice
158  isAllowed: boolean
159  /** The crab's costume for this role; `auto` picks one from the type and task. */
160  costume?: string
161}
162
163/** Delegation for a session: `turn` is on until the next reply finishes. */
164export type SessionMode = 'on' | 'off' | 'turn'
165
166/** Settings kept across sessions (also written to the mod store). */
167export type CrewSettings = {
168  /** Delegation for a new session. */
169  defaultMode: 'on' | 'off'
170  /** Also block the Workflow tool while delegation is off. */
171  isGatingWorkflows: boolean
172  /** Per agent type. */
173  roles: Record<string, RoleSetting>
174  /** For any agent type with no row of its own. */
175  fallback: RoleSetting
176  /** Replace a model the Agent call itself names. */
177  isOverridingCallModel: boolean
178  /** Most subagents running at once; 0 is no limit. */
179  maxRunning: number
180  /** Offer and allow only the Savvy Status roles (savvy-status:*), hiding every other agent type. */
181  isSavvyOnly: boolean
182  /** Orchestration rules Claude reads while delegation is on. */
183  rules: string[]
184}
185
186/** One file an agent read or wrote, for its hover tree. */
187export type Touch = { path: string; tone: string; at: number }
188
189/** An agent type Claude Code has offered (built-in, user, project or plugin). */
190export type KnownAgent = { name: string; source: string; description: string }
191
192/** The session's delegation switch. */
193export type ModeState = { mode: SessionMode; hasTurnStarted: boolean }
194
195declare module 'claude-code' {
196  interface PluginState {
197    'savvy-status': {
198      tree: FileTree
199      theme: Theme
200      activity: Activity[]
201      flow: Flow | null
202      agents: AgentRun[]
203      panel: Panel
204      now: number
205      crewSettings: CrewSettings
206      crewMode: ModeState
207      crewAgents: KnownAgent[]
208      /** Files each loop touched, keyed by agentId; `main` is the main loop. */
209      touches: Record<string, Touch[]>
210      /** The main loop drawn as a row of its own above the sub-agents. */
211      mainRun: AgentRun
212      /** The strip above Main Agent: local branches, which a merge sweep would delete, and what is running. */
213      strip: StripState
214      /** The plan's 5h and 7d windows, for the usage band at the top of the pane; [] off a subscription. */
215      usage: UsageLimit[]
216    }
217  }
218}
219