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…

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.
| 🦀 A crab for every agent | Each 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 trees | Hover 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 crab | Hover 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 guard | Scout 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 band | Your plan's 5-hour and 7-day limits sit across the top of the pane. |
| 🌿 Branch strip | Branches, open PRs with their checks, uncommitted changes, and a one-click merge sweep. |
Six roles ship in agents/. With delegation on, Claude hands work to them and the briefing tells it which one fits.
| Crab | Role | Model · effort | Does | Edits files? |
|---|---|---|---|---|
| 🧭 | scout | Haiku · low | Searches code and the web, inventories, traces usages; returns facts with path:line | — |
| 📐 | architect | Opus · high | Turns a goal into a step-by-step plan with risks and checks | — |
| 🔨 | builder | Opus · medium | Implements changes that need judgment while they are made | ✅ |
| 🪛 | worker | Haiku · low | Applies a fully described, mechanical edit exactly as written | ✅ |
| 📋 | reviewer | Opus · high | Reviews a diff, result, plan or document; findings ranked most severe first | — |
| 🧪 | tester | Sonnet · medium | Runs 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.
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:
ctx/100k, turning amber at 80k and red at 95k.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.
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.
/savvy-status [path] | Show the pane, rooted at path (default: the current directory) |
/subagents on · off · status | Flip delegation for this session, or check it; with no argument, opens the settings in the pane |
progress tool | The main agent lays out a planned flow so the pane can match runs to tasks |
step tool | A subagent reports its steps, filling its progress bar |
Set these in Claude Code's plugin settings:
| Option | Choices | Default |
|---|---|---|
| Claude activity | reads and writes · writes · reads · none | reads and writes |
| Glyphs | auto · nerd · plain | auto |
| Follow Claude | on · off | on |
| Right column | date · size | date |
| Sub-agents language | auto · en · ru | auto |
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
Savvy Status merges two MIT-licensed mods:
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.
hooks/register.tsx 4062 lines1import { 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 lines1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4 'set -f',
5 'f=$1',
6 'if command -v gio >/dev/null; then',
7 ' if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8 ' case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9 ' video/* | audio/*)',
10 ' app=$(xdg-mime query default text/plain)',
11 ' IFS=:',
12 ' for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13 ' [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14 ' done',
15 ' echo "No text/plain desktop application found for $f" >&2',
16 ' exit 1',
17 ' ;;',
18 ' esac',
19 ' fi',
20 ' if (exec gio open "$f"); then exit 0; fi',
21 'fi',
22 'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28 const argv = os === 'linux'
29 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30 : os === 'darwin'
31 ? ['open', '--', target]
32 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33 return {
34 argv,
35 init: {
36 timeoutMs: 10_000,
37 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38 },
39 }
40}
41hooks/git.ts 262 lines1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4 kind: string
5 verb: string
6 running: string
7 done: string
8 tone: string
9 icon: { nerd: string; plain: string }
10 init?: boolean
11}
12
13const ICON = {
14 commit: { nerd: '\u{f417}', plain: '●' },
15 push: { nerd: '\u{f0552}', plain: '↑' },
16 pull: { nerd: '\u{f0553}', plain: '↓' },
17 branch: { nerd: '\u{f418}', plain: '⑂' },
18 merge: { nerd: '\u{f419}', plain: '⑃' },
19 pr: { nerd: '\u{f407}', plain: '⇄' },
20 github: { nerd: '\u{f408}', plain: '◎' },
21 stash: { nerd: '\u{f01bc}', plain: '≡' },
22 undo: { nerd: '\u{f054c}', plain: '↶' },
23 tag: { nerd: '\u{f412}', plain: '⌖' },
24 git: { nerd: '\u{e702}', plain: '±' },
25}
26
27export const TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
28 orange: { bright: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'], dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'], solid: '#f97316' },
29 green: { bright: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'], dim: ['#14532d', '#166534', '#2f7a47', '#4f9a66'], solid: '#4ade80' },
30 teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'], solid: '#2dd4bf' },
31 blue: { bright: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'], dim: ['#1e3a8a', '#1d4ed8', '#3b6fd1', '#6b93dc'], solid: '#60a5fa' },
32 purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'], solid: '#c084fc' },
33 cyan: { bright: ['#06b6d4', '#22d3ee', '#67e8f9', '#cffafe'], dim: ['#155e75', '#0e7490', '#2b8ea3', '#5aa9b8'], solid: '#22d3ee' },
34 red: { bright: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'], dim: ['#7f1d1d', '#991b1b', '#b54040', '#c96a6a'], solid: '#f87171' },
35}
36
37const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
38 commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
39 push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
40 pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
41 fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
42 checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
43 switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
44 branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
45 merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
46 rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
47 'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
48 stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
49 reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
50 restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
51 revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
52 add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
53 rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
54 mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
55 tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
56 init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
57 clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
58}
59
60const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
61 'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
62 'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
63 'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
64 'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
65 'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
66 'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
67 'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
68 'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
69}
70
71const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
72
73function segments(command: string, seps: string[] = []): string[][] {
74 const out: string[][] = []
75 let cur: string[] = []
76 let tok = ''
77 let quote = ''
78 let has = false
79 const endTok = () => {
80 if (has) cur.push(tok)
81 tok = ''
82 has = false
83 }
84 const endSeg = () => {
85 endTok()
86 if (cur.length) out.push(cur)
87 cur = []
88 }
89 for (let i = 0; i < command.length; i++) {
90 const ch = command[i] ?? ''
91 if (quote) {
92 if (ch === quote) quote = ''
93 else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
94 else tok += ch
95 } else if (ch === '"' || ch === "'") {
96 quote = ch
97 has = true
98 } else if (ch === '\\' && i + 1 < command.length) {
99 tok += command[++i]
100 has = true
101 } else if (ch === '#' && !has) {
102 while (i + 1 < command.length && command[i + 1] !== '\n') i++
103 } else if (ch === ' ' || ch === '\t') endTok()
104 else if (ch === '\n' || ch === ';') {
105 seps.push(';')
106 endSeg()
107 } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
108 tok += ch
109 has = true
110 } else if (ch === '&' || ch === '|') {
111 const twice = command[i + 1] === ch
112 if (twice) i++
113 seps.push(twice ? ch + ch : ch)
114 endSeg()
115 } else {
116 tok += ch
117 has = true
118 }
119 }
120 endSeg()
121 if (!quote) return out
122 seps.push(';')
123 return command
124 .split(/&&|\|\||;|\||\n/)
125 .map(s => s.trim().split(/\s+/).filter(Boolean))
126 .filter(s => s.length > 0)
127}
128
129function stripGlobals(tokens: string[]): string[] {
130 const out = [...tokens]
131 if (out[0] === 'env') out.shift()
132 while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
133 return out
134}
135
136function gitVerb(tokens: string[]): number {
137 let i = 1
138 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
139 if (tokens[i] === '-C' || tokens[i] === '-c') i++
140 i++
141 }
142 return i
143}
144
145function gitReadOnly(verb: string, rest: string[]): boolean {
146 if (!verb || READ_ONLY_GIT.has(verb)) return true
147 if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
148 if (verb === 'branch') return rest.every(t => t.startsWith('-'))
149 if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
150 return false
151}
152
153export function chainOf(command: string): { size: number; and: boolean } {
154 const seps: string[] = []
155 const size = segments(command, seps).length
156 return { size, and: seps.every(s => s === '&&') }
157}
158
159export function gitActions(command: string): GitAction[] {
160 const out: GitAction[] = []
161 for (const raw of segments(command)) {
162 const tokens = stripGlobals(raw)
163 const head = tokens[0]?.split('/').pop()
164 if (head === 'git') {
165 const i = gitVerb(tokens)
166 const verb = tokens[i] ?? ''
167 const rest = tokens.slice(i + 1)
168 if (gitReadOnly(verb, rest)) continue
169 const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
170 if (spec) out.push({ kind: `git ${verb}`, ...spec })
171 } else if (head === 'gh') {
172 const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
173 const spec = GH_VERBS[pair]
174 if (spec) out.push({ kind: `gh ${pair}`, ...spec })
175 }
176 }
177 return out
178}
179
180export const BRANCH_ICON = ICON.branch
181
182const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
183const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
184const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
185
186function tidy(path: string): string {
187 const s = path.replace(/\/+$/, '')
188 return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
189}
190
191export function resolve(cwd: string, p: string, home = ''): string {
192 let path = posix(p)
193 if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
194 const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
195 const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
196 for (const seg of path.slice(drive.length).split('/')) {
197 if (!seg || seg === '.') continue
198 if (seg === '..') {
199 if (parts.length > 1) parts.pop()
200 } else parts.push(seg)
201 }
202 return tidy(parts.join('/'))
203}
204
205const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
206
207export function readOnly(command: string): boolean {
208 const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
209 if (/[^>]>[^>&]|>>/.test(bare)) return false
210 return segments(command).every(raw => {
211 const tokens = stripGlobals(raw)
212 const head = tokens[0]?.split('/').pop() ?? ''
213 if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
214 if (head === 'git') {
215 const i = gitVerb(tokens)
216 return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
217 }
218 if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
219 if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
220 return READERS.has(head)
221 })
222}
223
224export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
225 let cwd = sessionCwd
226 const out = new Set<string>()
227 let listsPaths = false
228 for (const raw of segments(command)) {
229 const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
230 const head = tokens[0]?.split('/').pop() ?? ''
231 if (head === 'cd' && tokens[1]) {
232 cwd = resolve(cwd, tokens[1], home)
233 continue
234 }
235 if (!READERS.has(head)) continue
236 if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
237 if (OUTPUT_PATHS.has(head)) listsPaths = true
238 const args = tokens.slice(1)
239 let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
240 for (let i = 0; i < args.length; i++) {
241 const a = args[i] ?? ''
242 if (a.startsWith('-')) {
243 if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
244 continue
245 }
246 if (skipPattern) {
247 skipPattern = false
248 continue
249 }
250 if (/[*?<>|]/.test(a)) continue
251 out.add(resolve(cwd, a, home))
252 }
253 }
254 if (listsPaths) {
255 for (const line of stdout.split('\n').slice(0, 400)) {
256 const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
257 if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
258 }
259 }
260 return [...out].slice(0, 60)
261}
262hooks/rows.tsx 143 lines1import type { ClientModule } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[] }
5export type RowsProps = {
6 rows: RowSpec[]
7 active: string
8 activeBg: string
9 hoverBg: string
10 tones: Record<string, { bright: string[]; dim: string[] }>
11 spinner?: string[]
12 pointer?: boolean
13 bar?: { pos: number; size: number; thumb: string; track: string }
14}
15type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
16
17const TICK_MS = 90
18const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
19
20function shimmer(i: number, phase: number, len: number, palette: string[]): string {
21 const band = ((phase * 1.6) % (len + 8)) - 4
22 const d = Math.abs(i - band)
23 return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
24}
25
26const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
27 const { Box, Text } = surface.elements
28 let state = surface.state
29 if (state === undefined) {
30 state = { hover: -1, phase: 0, drag: false, ref: {} }
31 surface.setState(state)
32 }
33 const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
34 if (lit && !state.ref.stop) {
35 state.ref.stop = surface.every(TICK_MS, () => {
36 const cur = surface.state
37 if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
38 })
39 } else if (!lit && state.ref.stop) {
40 state.ref.stop()
41 state.ref.stop = undefined
42 }
43 if (props.pointer === false) {
44 state.ref.unpoint?.()
45 state.ref.unpoint = undefined
46 } else state.ref.unpoint = surface.onPointer(e => {
47 const cur = surface.state ?? state
48 const span = Math.max(1, props.rows.length - 1)
49 const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
50 if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
51 if (e.type === 'up' || e.type === 'leave') {
52 surface.setState({ ...cur, drag: false })
53 return
54 }
55 if (e.type === 'down' || e.type === 'move') {
56 if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
57 surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
58 return
59 }
60 }
61 if (e.type === 'leave' || e.y < 0 || e.y >= props.rows.length) {
62 if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
63 return
64 }
65 if (e.type === 'move' && !e.button && cur.hover !== e.y) surface.setState({ ...cur, hover: e.y })
66 const row = props.rows[e.y]
67 if (e.type === 'down' && e.button === 'right' && row?.id) {
68 surface.post({ copy: row.id, shift: Boolean(e.shift) })
69 return
70 }
71 if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
72 let x = 0
73 for (const seg of row.left) {
74 const w = [...seg.t].length
75 if (seg.tab && e.x >= x && e.x < x + w) {
76 surface.post({ tab: seg.tab })
77 return
78 }
79 x += w
80 }
81 if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
82 })
83 surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
84 const frames = props.spinner?.length ? props.spinner : FRAMES
85 const draw = (s: Seg) => {
86 if (s.spin) {
87 return (
88 <Text color={s.c} bold={s.b}>
89 {s.t + (frames[state.phase % frames.length] ?? '')}
90 </Text>
91 )
92 }
93 const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
94 if (!palette) {
95 return (
96 <Text color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
97 {s.t}
98 </Text>
99 )
100 }
101 if (s.one) {
102 return (
103 <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
104 {s.t}
105 </Text>
106 )
107 }
108 const chars = [...s.t]
109 return (
110 <Text bold={s.b}>
111 {chars.map((ch, i) => (
112 <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
113 ))}
114 </Text>
115 )
116 }
117 return (
118 <Box flexDirection="column">
119 {props.rows.map((r, i) => (
120 <Box
121 flexDirection="row"
122 height={1}
123 overflow="hidden"
124 backgroundColor={r.id && r.id === props.active ? props.activeBg : r.id && i === state.hover ? props.hoverBg : undefined}
125 >
126 <Box flexShrink={1} overflow="hidden">
127 {r.left.map(draw)}
128 </Box>
129 <Box flexGrow={1} />
130 {r.right.map(draw)}
131 {props.bar && (
132 <Text color={i >= props.bar.pos && i < props.bar.pos + props.bar.size ? props.bar.thumb : props.bar.track}>
133 {i >= props.bar.pos && i < props.bar.pos + props.bar.size ? '┃' : '│'}
134 </Text>
135 )}
136 </Box>
137 ))}
138 </Box>
139 )
140}
141
142export default Rows
143hooks/strip.ts 63 lines1/** 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}
63hooks/icons.ts 132 lines1import type { FileNode } from '../types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5export const CHEVRON_OPEN = cp(0xf47c)
6export const CHEVRON_CLOSED = cp(0xf460)
7
8const NAMES: Record<string, number> = {
9 'agents.md': 0xf0354,
10 'changelog.md': 0xf0354,
11 'contributing.md': 0xf0354,
12 'todo.md': 0xf0354,
13 'readme.md': 0xf0354,
14 license: 0xe60a,
15 dockerfile: 0xf0868,
16 makefile: 0xf1064,
17 'cmakelists.txt': 0xf1064,
18 justfile: 0xf05b7,
19 gemfile: 0xf0d2d,
20 rakefile: 0xf0d2d,
21 'cargo.toml': 0xe6b2,
22 'cargo.lock': 0xe6b2,
23 'pyproject.toml': 0xe6b2,
24 'requirements.txt': 0xf160e,
25 'go.mod': 0xf0afa,
26 'go.sum': 0xf07d3,
27 'package.json': 0xf0626,
28 'package-lock.json': 0xf0626,
29 'tsconfig.json': 0xf0626,
30 '.prettierrc': 0xf0626,
31 '.eslintrc': 0xf0626,
32 'pnpm-lock.yaml': 0xe6a8,
33 'yarn.lock': 0xe6a8,
34 '.gitignore': 0xf02a2,
35 '.gitmodules': 0xf0493,
36 '.editorconfig': 0xe652,
37}
38
39const EXTS: Record<string, number> = {
40 lua: 0xe620,
41 py: 0xe606,
42 pyi: 0xe606,
43 js: 0xe60c,
44 mjs: 0xe60c,
45 cjs: 0xe60c,
46 ts: 0xe628,
47 jsx: 0xe625,
48 tsx: 0xe7ba,
49 json: 0xe60b,
50 jsonc: 0xe60b,
51 yaml: 0xe6a8,
52 yml: 0xe6a8,
53 sh: 0xe795,
54 bash: 0xe795,
55 zsh: 0xe795,
56 fish: 0xe795,
57 md: 0xf0354,
58 markdown: 0xf0354,
59 css: 0xe6b8,
60 scss: 0xf031c,
61 html: 0xe736,
62 htm: 0xe736,
63 go: 0xe627,
64 rs: 0xe68b,
65 rb: 0xe791,
66 php: 0xe608,
67 java: 0xe738,
68 cs: 0xf031b,
69 sql: 0xe706,
70 graphql: 0xf20e,
71 gql: 0xf20e,
72 xml: 0xf05c0,
73 toml: 0xe6b2,
74 ini: 0xf0bc2,
75 conf: 0xf0493,
76 pdf: 0xeaeb,
77 svg: 0xf0721,
78 jpg: 0xf0225,
79 jpeg: 0xf0225,
80 png: 0xe60d,
81 gif: 0xf0d78,
82 webp: 0xf021f,
83 mp4: 0xf022b,
84 mkv: 0xf022b,
85 mov: 0xf022b,
86 mp3: 0xf0223,
87 wav: 0xf0223,
88 flac: 0xf0223,
89 zip: 0xf05c4,
90 gz: 0xf05c4,
91 xz: 0xf05c4,
92 tar: 0xf05c4,
93 qml: 0xf375,
94 cpp: 0xe61d,
95 cc: 0xe61d,
96 cxx: 0xe61d,
97 c: 0xe61e,
98 h: 0xf0af5,
99 hpp: 0xf0af5,
100 vue: 0xe6a0,
101 svelte: 0xe697,
102 csv: 0xe64a,
103 txt: 0xf09aa,
104}
105
106export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
107 if (n.kind === 'link') return cp(0xf481)
108 if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
109 const lower = n.name.toLowerCase()
110 const named = NAMES[lower]
111 if (named) return cp(named)
112 const dot = lower.lastIndexOf('.')
113 const ext = dot >= 0 ? lower.slice(dot + 1) : ''
114 return cp(EXTS[ext] ?? 0xf0214)
115}
116
117export const GIT_COLOR: Record<string, string> = {
118 A: '#98c379',
119 '?': '#98c379',
120 R: '#61afef',
121 C: '#61afef',
122 M: '#e5c07b',
123 T: '#e5c07b',
124}
125
126const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
127
128export function stronger(a: string | undefined, b: string): string {
129 if (!a) return b
130 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
131}
132hooks/tree.ts 331 lines1import type { Branch, FileNode, FileTree, Theme } from '../types'
2import { stronger } from './icons'
3
4export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; size: number; isLink: boolean }
5export type Row = { node: FileNode; depth: number; open: boolean }
6
7const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8
9export const DEFAULT_THEME: Theme = {
10 fg: '',
11 accent: '#5b9bd5',
12 muted: '#808a96',
13 urgent: '#d0605e',
14 selection: '#6b7280',
15 bg: '',
16}
17
18export function emptyTree(root: string): FileTree {
19 return {
20 root,
21 nodes: [],
22 expanded: [],
23 cursor: '',
24 selected: '',
25 query: '',
26 showHidden: true,
27 showSize: false,
28 dirSizes: {},
29 git: {},
30 diff: {},
31 ignored: [],
32 untrackedDirs: [],
33 top: '',
34 prefix: '',
35 branch: null,
36 counts: {},
37 flash: [],
38 flashDim: [],
39 flashOn: false,
40 flashTones: {},
41 scroll: null,
42 }
43}
44
45export function parseTheme(toml: string): Theme {
46 const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
47 return {
48 fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
49 accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
50 muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
51 urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
52 selection: get('selection') ?? DEFAULT_THEME.selection,
53 bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
54 }
55}
56
57export function join(dir: string, name: string): string {
58 return dir.endsWith('/') ? dir + name : `${dir}/${name}`
59}
60
61let drives = false
62
63export function dirname(path: string): string {
64 if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
65 const i = path.lastIndexOf('/')
66 return i <= 0 ? '/' : path.slice(0, i)
67}
68
69export function useDrives(on: boolean): void {
70 drives = on
71}
72
73export function drivesOn(): boolean {
74 return drives
75}
76
77export function posix(path: string): string {
78 if (!drives) return path
79 return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
80}
81
82export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
83 if (dirs.length === 0) return nodes
84 const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
85 const gone = new Set(dirs)
86 return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
87}
88
89export function isAbsolute(path: string): boolean {
90 return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
91}
92
93export function inside(root: string, path: string): boolean {
94 return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
95}
96
97export function toNodes(dir: string, list: Entry[]): FileNode[] {
98 return list
99 .map(e => ({
100 id: join(dir, e.name),
101 parent: dir,
102 name: e.name,
103 kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
104 hidden: e.name.startsWith('.'),
105 mtime: e.mtimeMs,
106 size: e.size,
107 loaded: false,
108 }))
109 .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
110}
111
112export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
113 if (listed.size === 0) return nodes
114 const keep = new Map<string, FileNode>()
115 for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
116 const freshIds = new Set<string>()
117 for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
118 const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
119 const under = (id: string) => {
120 if (gone.size === 0) return false
121 for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
122 return false
123 }
124 const fresh: FileNode[] = []
125 for (const kids of listed.values()) {
126 for (const k of kids) {
127 if (under(k.id)) continue
128 const old = keep.get(k.id)
129 fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
130 }
131 }
132 const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
133 return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
134}
135
136export type GitStatus = {
137 git: Record<string, string>
138 ignored: string[]
139 untrackedDirs: string[]
140 branch: Branch | null
141 files: Record<string, Change>
142}
143
144export type Change = 'new' | 'mod' | 'del'
145
146export function parseBranch(record: string): Branch {
147 const body = record.replace(/^## /, '')
148 if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
149 const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
150 const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
151 const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
152 const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
153 return { head, upstream, ahead, behind }
154}
155
156export function mapper(root: string, prefix: string): (rel: string) => string | null {
157 return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
158}
159
160export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
161 const map = mapper(root, prefix)
162 const git: Record<string, string> = {}
163 const ignored: string[] = []
164 const untrackedDirs: string[] = []
165 const files: Record<string, Change> = {}
166 let branch: Branch | null = null
167 const parts = stdout.split('\0')
168 for (let i = 0; i < parts.length; i++) {
169 const rec = parts[i] ?? ''
170 if (rec.startsWith('## ')) {
171 branch = parseBranch(rec)
172 continue
173 }
174 if (rec.length < 4) continue
175 const xy = rec.slice(0, 2)
176 const raw = rec.slice(3)
177 if (xy[0] === 'R' || xy[0] === 'C') i++
178 const path = map(raw)
179 if (!path) continue
180 if (xy === '!!') {
181 ignored.push(path)
182 continue
183 }
184 if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
185 else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
186 const letter =
187 xy === '??'
188 ? '?'
189 : /U/.test(xy) || xy === 'AA' || xy === 'DD'
190 ? 'U'
191 : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
192 if (!letter) continue
193 git[path] = stronger(git[path], letter)
194 let dir = dirname(path)
195 while (dir.length >= root.length && dir !== '/') {
196 git[dir] = stronger(git[dir], letter)
197 if (dir === root) break
198 dir = dirname(dir)
199 }
200 }
201 return { git, ignored, untrackedDirs, branch, files }
202}
203
204export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
205 const map = mapper(root, prefix)
206 const parts = stdout.split('\0')
207 for (let i = 0; i < parts.length; i++) {
208 const rec = parts[i] ?? ''
209 if (!rec) continue
210 const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
211 if (!m) continue
212 let path = m[3] ?? ''
213 if (path === '') {
214 i += 2
215 path = parts[i] ?? ''
216 }
217 if (!path) continue
218 const add = m[1] === '-' ? 0 : Number(m[1])
219 const del = m[2] === '-' ? 0 : Number(m[2])
220 const abs = map(path)
221 if (!abs) continue
222 const prev = into[abs] ?? [0, 0]
223 into[abs] = [prev[0] + add, prev[1] + del]
224 }
225}
226
227export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
228 const out: Record<string, [number, number]> = { ...diff }
229 for (const [path, [add, del]] of Object.entries(diff)) {
230 let dir = dirname(path)
231 while (dir.length >= root.length && dir !== '/') {
232 const prev = out[dir] ?? [0, 0]
233 out[dir] = [prev[0] + add, prev[1] + del]
234 if (dir === root) break
235 dir = dirname(dir)
236 }
237 }
238 return out
239}
240
241export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
242 const out: Record<string, [number, number, number]> = {}
243 const slot = { new: 0, mod: 1, del: 2 } as const
244 for (const [path, change] of Object.entries(files)) {
245 let dir = dirname(path)
246 while (dir.length >= root.length && dir !== '/') {
247 const cur = out[dir] ?? [0, 0, 0]
248 cur[slot[change]] += 1
249 out[dir] = cur
250 if (dir === root) break
251 dir = dirname(dir)
252 }
253 }
254 return out
255}
256
257export function underAny(id: string, set: Set<string>, root: string): boolean {
258 if (set.size === 0) return false
259 let cur = id
260 while (cur.length >= root.length) {
261 if (set.has(cur)) return true
262 const up = dirname(cur)
263 if (up === cur) break
264 cur = up
265 }
266 return false
267}
268
269export function ancestorsOf(id: string, root: string): string[] {
270 const out: string[] = []
271 if (!root) return out
272 for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
273 return out
274}
275
276export function relative(root: string, path: string): string {
277 return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
278}
279
280export function visibleRows(t: FileTree): Row[] {
281 const kids = new Map<string, FileNode[]>()
282 for (const n of t.nodes) {
283 if (!t.showHidden && n.hidden) continue
284 const list = kids.get(n.parent)
285 if (list) list.push(n)
286 else kids.set(n.parent, [n])
287 }
288 const q = t.query.trim().toLowerCase()
289 let keep: Set<string> | null = null
290 if (q) {
291 keep = new Set()
292 for (const n of t.nodes) {
293 if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
294 keep.add(n.id)
295 for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
296 }
297 }
298 const open = new Set(t.expanded)
299 const rows: Row[] = []
300 const walk = (dir: string, depth: number) => {
301 for (const n of kids.get(dir) ?? []) {
302 if (keep && !keep.has(n.id)) continue
303 const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
304 rows.push({ node: n, depth, open: isOpen })
305 if (isOpen) walk(n.id, depth + 1)
306 }
307 }
308 walk(t.root, 0)
309 return rows
310}
311
312export function stamp(ms: number): string {
313 if (!ms) return ''
314 const d = new Date(ms)
315 const p = (v: number) => String(v).padStart(2, '0')
316 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
317}
318
319export function formatSize(bytes: number): string {
320 if (bytes < 0) return '?'
321 if (bytes < 1024) return `${bytes} B`
322 const units = ['K', 'M', 'G', 'T']
323 let v = bytes / 1024
324 let i = 0
325 while (v >= 1024 && i < units.length - 1) {
326 v /= 1024
327 i++
328 }
329 return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
330}
331types/index.d.ts 219 lines1export type FileNode = {
2 id: string
3 parent: string
4 name: string
5 kind: 'dir' | 'file' | 'link'
6 hidden: boolean
7 mtime: number
8 size: number
9 loaded: boolean
10}
11
12export type Branch = {
13 head: string
14 upstream: string
15 ahead: number
16 behind: number
17}
18
19export type Activity = {
20 id: number
21 kind: string
22 label: string
23 state: 'running' | 'done' | 'failed'
24 detail: string
25 at: number
26 tone: string
27 nerd: string
28 plain: string
29}
30
31export type FileTree = {
32 root: string
33 nodes: FileNode[]
34 expanded: string[]
35 cursor: string
36 selected: string
37 query: string
38 showHidden: boolean
39 showSize: boolean
40 dirSizes: Record<string, number>
41 git: Record<string, string>
42 diff: Record<string, [number, number]>
43 ignored: string[]
44 untrackedDirs: string[]
45 top: string
46 prefix: string
47 branch: Branch | null
48 counts: Record<string, [number, number, number]>
49 flash: string[]
50 flashDim: string[]
51 flashOn: boolean
52 flashTones: Record<string, string>
53 scroll: number | null
54}
55
56export type Theme = {
57 fg: string
58 accent: string
59 muted: string
60 urgent: string
61 selection: string
62 bg: string
63}
64
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