Fork of data-goblin/claude-code-filetree (MIT) with a built-in file viewer (diff, file, markdown/mermaid preview) and per-repo branches with pull/push

<h1 align="center">Claude Code Filetree</h1>
An IDE-style file tree for Claude Code that shows what Claude is doing and where in files
<img src="https://img.shields.io/badge/Claude_Code-%E2%89%A5_2.1.287-D97757?logo=claude&logoColor=fff" alt="Claude Code 2.1.287 or newer"> <img src="https://img.shields.io/badge/version-0.2.24-blue" alt="Version"> <img src="https://img.shields.io/badge/type-mod-6f42c1" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<img src="media/filetree-shimmer.gif" alt="filetree shimmering the file Claude is editing" width="900">
[!NOTE] filetree is a Claude Code mod and needs Claude Code 2.1.287+. It shows in the right sidebar, which needs the fullscreen layout (
/tui fullscreen) and a terminal at least 110 columns wide. Tested on Linux, macOS and Windows; mods don't load in WSL sessions of the Desktop app.
The repo is its own plugin marketplace. Run this in the terminal:
claude plugin marketplace add data-goblin/claude-code-filetree
claude plugin install filetree@claude-code-filetree
Or inside a Claude Code session:
/plugin marketplace add data-goblin/claude-code-filetree
/plugin install filetree@claude-code-filetree
Installed it as filetree@filetree before the repository was renamed? Nothing to do: that install keeps loading and keeps receiving updates.
/filetree <path> pins another folder+N -N) on modified files and ?:N M:N D:N file counts on foldersfiles panel; the tree itself stays still and keeps only git colours, counts and sizes up to date<img src="media/filetree-read.gif" alt="Files shimmer purple while Claude reads and searches them" width="800">
git and gh (commit, push, pull, checkout, merge, PR and more) shown as a status at the bottom of the pane<img src="media/filetree-git.gif" alt="A committed file shimmers green and the footer shows the commit" width="800">
prompt.submit hook, and @path mentions in a prompt reveal that file in the tree<img src="media/filetree-ask.gif" alt="Selecting config.yaml in the tree and asking Claude what it changed there" width="800">
Σ header button swaps the date column for sizes; folders show their disk usage (du, or a summed listing on Windows), worked out in the background for the rows on screen and refreshed after Claude writesYou can resize the pane with the mouse, or by setting custom pane:grow or pane:shrink keybindings in keybindings.json
<img src="media/filetree-resize.gif" alt="Dragging the filetree pane edge to resize it" width="900">
All settings are in /config under filetree.
files panel records: reads and writes (default), writes, reads or none. Git status, line counts and the git status at the bottom always show.date (default) or size; what the right column shows when a session starts. The Σ button in the header toggles it.auto (default) uses Nerd Font icons when a Nerd Font is installed and your terminal started after it was installed, plain Unicode in the desktop app, and Nerd Font over SSH. nerd or plain forces one.A Flightdeck tab (its own pane, opened with /flightdeck) shows a live agent dashboard: context and cost, permission gate, subagents, turn receipt, background shells and monitors (the shell panel; only those started after the mod loaded are listed) and session log. Ported from claude-flightdeck. The model, context, quota and cost panel (main) is off by default because the statusline shows them; add main to the panels option to bring it back. Switch the tab off with Flightdeck tab: off in /config: no tab, no command, no hooks, nothing tracked. Set Flightdeck open at start: on to open the tab when a session starts (default off). Reload plugins after changing it.
Viewer layout in /config: auto (default) shows only the hotkeys ([d], [f]...) on the viewer's header buttons when the pane is narrower than 60 columns, wide always shows full labels, narrow always shows hotkeys only.
Clicking rows needs herdr 0.9.1 or later. herdr 0.9.0 and older accept pixel mouse reporting but still send cell positions, which would put every click in the session in the wrong place, so on those versions the rows ignore the mouse and the rest of the pane and session keep working. Run herdr update to get row clicks.
Before a commit, run scripts/check.sh from the plugin folder: it runs bun test hooks, claude plugin validate ., scripts/typecheck.sh and scripts/load-check.sh (loads the module the way the engine does and rejects value imports of types, which tsc and the validator let through). The type check compares against scripts/typecheck.baseline and fails only on new errors; run scripts/typecheck.sh --update after fixing some to shrink the baseline. It needs the engine's claude-code.d.ts (found under .claude-plugin/types/ once the engine has loaded the mod, or in the plugin-authoring skill's folder under /tmp, or at $CLAUDE_CODE_TYPES) and is skipped, not failed, when none is found. The 8 known errors come from element and event types that differ per surface (props, viewport, Raster, Image).
Turn on the pre-commit hook once per clone; it runs claude plugin validate and the plugin tests before each commit that touches the plugin:
git config core.hooksPath .githooks
The same checks run on macOS, Windows and Linux in CI on every push that touches the plugin.
This project is inspired by my Omarchy app FileBlade
hooks/register.tsx 2309 lines1import { atom, read, type BuiltinToolResults, type EngineInterface, type Register, type RenderNode, type Timer, update } from 'claude-code'
2import { deltaDiff, ensureDelta, parseAnsi, sliceSpans, stripAnsi } from './delta'
3import { openCommand } from './open'
4import { holdBlocked } from './blocked'
5import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT, VIDEO_STEP, getPlayback, htmlFrame, imageFile, mediaDuration, pictureCells, setVideoPos, startAudio, stopAudio, supportsImages, videoFrame } from './media'
6import { configOf, consultEnded, consultStarted, costNow, getArchitect, getCards, getGate, getMain, getTurn, isArchitectType, migrate, noteAdvice, noteMode, refreshStatus, resetAll, say, type ServerBlock, updateFdAgents, updateFdArchitect, updateFdFiles, updateFdGate, updateFdLoops, updateFdMain, updateFdReceipt, updateFdRoster, updateFdTasks, updateFdTurn, updateFdUsage, whoIs } from './fd-state'
7import { renderFlightdeck } from './fd-view'
8import { isCompact, parseOptions, type ViewerLayout } from './config'
9import { formatTable, splitBlocks, type Segment } from './md'
10import { clean, clock, failAll, faint, openDirs, type Outcome, type Pending, proven, rootOfDisk, shortenHome } from './pure'
11
12import type { Activity, AgentCard, BgTask, Check, FileEvent, FileNode, FileTree, Loop, RepoRow, Theme, ViewMode } from '../types'
13import { DEFAULT_ARCHITECT, DEFAULT_MAIN, DEFAULT_ROSTER, DEFAULT_TURN, DEFAULT_USAGE, EDIT_TOOLS, afterCall, applyStep, bucketOf, cardTitle, describeInput, fmtDuration, limitOf, listOf, normalize, normalizeCard, normalizeGate, noteTool, promptLine, handbackOf, adviceLine, receiptOf, recordCheck, settleCheck, shorten, stepSpeed, stepLoop, startTask, endTask, recordFiles, taskEnds } from './fd-core'
14import type { Config } from './fd-core'
15
16import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve, TONES } from './git'
17import type { RowSpec, RowsProps, Seg } from './rows'
18import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR, iconColor as typeColor } from './icons'
19import { ancestorsOf, type Change, DEFAULT_THEME, dirname, emptyTree, formatSize, inside, isAbsolute, join, mapper, parseGit, parseNumstat, parseTheme, posix, relative, dropBelow, useDrives, rollCounts, replaceChildren, rollUp, stamp, toNodes, underAny, visibleRows } from './tree'
20
21const TREE = { plugin: 'filetree', key: 'tree' } as const
22const THEME = { plugin: 'filetree', key: 'theme' } as const
23const ACTIVITY = { plugin: 'filetree', key: 'activity' } as const
24const PANE = 'filetree'
25const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
26const RUNNING_MAX_MS = 600_000
27const NO_REPO_DEPTH = 4
28const DOUBLE_MS = 450
29const FIND_LIMIT = 200
30const READ_REVEAL_LIMIT = 12
31const SEARCH_REVEAL_LIMIT = 60
32const ACTIVITY_TTL_MS = 45_000
33const ADD_COLOR = '#98c379'
34const DEL_COLOR = '#e06c75'
35const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
36const THEME_POLL_MS = 2000
37const FONT_SCRIPT =
38 'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
39 'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
40 '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
41 '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 ' +
42 'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
43 '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; ' +
44 'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
45const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
46const HOME_PRUNE = ['Library', 'AppData', '.Trash']
47const SIZE_TIMEOUT_MS = 15_000
48const SIZE_JOBS = 2
49const SIZE_WALK_LIMIT = 50_000
50
51let themePoll: Timer | null = null
52let themeMtime: number | null = null
53let lastPress = { key: '', at: 0 }
54let noNerd = false
55let glyphSetting = 'auto'
56let viewerLayout: ViewerLayout = 'auto'
57let approvalOn = true
58let follow = true
59// One folder open at a time (setting: accordion)
60let accordion = true
61// Root stays where Claude was opened instead of following `cd` (setting: root)
62let pinRoot = true
63// Branch line above the tree (setting: branch)
64let showBranch = false
65let scanning: Promise<void> | null = null
66let scanJobs: Job[] = []
67let gitRun: Promise<void> | null = null
68let gitNext: Promise<void> | null = null
69let queuedReads: string[] = []
70let showReads = true
71let showWrites = true
72let searchIndex: { root: string; paths: Promise<string[]> } | null = null
73let activityId = 0
74let pointer = true
75let view = { from: 0, max: 0 }
76let lastSync = 0
77let noDock = false
78let home = ''
79let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
80let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
81let markId = 0
82let listSeq = 0
83let lastRoot = ''
84let sizeDefault = false
85let sizeEpoch = 0
86let sizeActive = 0
87let sizeQueue: string[] = []
88const sizing = new Set<string>()
89const listLatest = new Map<string, number>()
90const unreadable = new Set<string>()
91const background = new Map<string, Background>()
92
93function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
94 platform ??= (async () => {
95 if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
96 try {
97 return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
98 } catch {
99 return 'linux'
100 }
101 })()
102 return platform
103}
104
105async function cwdOf($: EngineInterface): Promise<string> {
106 return posix(await $.session.cwd())
107}
108
109// Where Claude was launched: the pinned tree root, so a `cd` in a Bash call never moves the repos block
110async function launchRoot($: EngineInterface): Promise<string> {
111 const root = pinRoot ? (await get($)).root : ''
112
113 return root || (await cwdOf($))
114}
115
116async function get($: EngineInterface): Promise<FileTree> {
117 return normalize(emptyTree(''), (await $.state.get(TREE)).value)
118}
119
120async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
121 await update($, TREE, cur => fn(normalize(emptyTree(''), cur)))
122}
123
124function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
125 return put($, t => ({ ...t, ...fn(t) }))
126}
127
128async function activities($: EngineInterface): Promise<Activity[]> {
129 return (await $.state.get(ACTIVITY)).value ?? []
130}
131
132async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
133 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
134}
135
136async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
137 try {
138 const entries = await $.fs.list(dir)
139 const resolved = await Promise.all(
140 entries.map(async e => {
141 if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
142 try {
143 const target = await $.fs.stat(join(dir, e.name))
144 return target.kind === 'dir'
145 ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
146 : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
147 } catch {
148 return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
149 }
150 }),
151 )
152 unreadable.delete(dir)
153 return toNodes(dir, resolved)
154 } catch (err) {
155 if (!(await exists($, dir))) return []
156 if (!unreadable.has(dir)) {
157 unreadable.add(dir)
158 $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
159 }
160 return null
161 }
162}
163
164async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
165 const root = (await get($)).root
166 const seq = ++listSeq
167 for (const dir of dirs) listLatest.set(dir, seq)
168 const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
169 const listed = new Map<string, FileNode[]>()
170 for (const [dir, kids] of results) {
171 if (listLatest.get(dir) !== seq) continue
172 listLatest.delete(dir)
173 if (kids) listed.set(dir, kids)
174 }
175 await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
176 return listed
177}
178
179async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
180 return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
181}
182
183async function detectRepo($: EngineInterface): Promise<void> {
184 const t = await get($)
185 if (!t.root) return
186 let top = ''
187 let prefix = ''
188 try {
189 const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
190 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
191 prefix = p.trim()
192 top = tl.trim()
193 } catch {
194 top = ''
195 }
196 await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
197}
198
199async function readGit($: EngineInterface): Promise<void> {
200 const t = await get($)
201 if (!t.root || !t.top) return
202 const root = t.root
203 try {
204 const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
205 if (status.exitCode !== 0) return
206 const parsed = parseGit(status.stdout, root, t.prefix)
207 const diff: Record<string, [number, number]> = {}
208 const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
209 if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
210 const files = { ...parsed.files }
211 if (parsed.untrackedDirs.length) {
212 const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
213 if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
214 }
215 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
216 dirty = { root, files }
217 await patch($, cur =>
218 cur.root === root
219 ? {
220 git: parsed.git,
221 ignored: parsed.ignored,
222 untrackedDirs: parsed.untrackedDirs,
223 branch: parsed.branch,
224 counts: rollCounts(files, root),
225 diff: rollUp(diff, root),
226 }
227 : {},
228 )
229 } catch {
230 return
231 }
232}
233
234function refreshGit($: EngineInterface): Promise<void> {
235 if (!gitRun) {
236 gitRun = readGit($).finally(() => {
237 gitRun = null
238 })
239 return gitRun
240 }
241 const rerun = () => {
242 gitNext = null
243 return refreshGit($)
244 }
245 gitNext ??= gitRun.then(rerun, rerun)
246 return gitNext
247}
248
249async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
250 const prev = await get($)
251 lastRoot = root
252 searchIndex = null
253 sizeEpoch += 1
254 sizeQueue = []
255 await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
256 const title = `Files: ${root.split('/').pop() || root}`
257 if (focus) await $.ui.open({ id: PANE, title, focus: true })
258 else if (!noDock) await $.ui.open({ id: PANE, title })
259 await loadDirs($, [root])
260 await detectRepo($)
261 await refreshGit($)
262}
263
264async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
265 const tried = new Set<string>()
266 for (let pass = 0; pass < 32; pass++) {
267 const t = await get($)
268 const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
269 if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
270 const need = new Set<string>()
271 for (const p of paths) {
272 for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
273 if (!loaded.has(dir)) {
274 if (!tried.has(dir)) need.add(dir)
275 break
276 }
277 }
278 }
279 if (need.size === 0) return
280 for (const dir of need) tried.add(dir)
281 await loadDirs($, [...need])
282 }
283}
284
285function pruneArgs(root: string, ignored: string[] = []): string[] {
286 const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
287 return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
288}
289
290async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
291 const stats = await Promise.all(
292 paths.map(async p => {
293 try {
294 return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
295 } catch {
296 return ''
297 }
298 }),
299 )
300 return stats.filter(Boolean)
301}
302
303function broad(root: string): boolean {
304 return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
305}
306
307async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
308 if (rootOfDisk(root)) return []
309 const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
310 try {
311 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 })
312 return run.stdout.split('\0').filter(p => p && p !== root)
313 } catch {
314 return []
315 }
316}
317
318async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
319 if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
320 if (dirty.root !== root) return { hits: [], gone: [] }
321 const entries = Object.entries(dirty.files)
322 const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
323 const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
324 return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
325}
326
327type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
328
329async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
330 const ms = await $.clock.now()
331 const os = await osName($)
332 if (os !== 'darwin' || !writes) return { ms, mark: '', os }
333 const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
334 try {
335 await $.process.run(['touch', mark], { timeoutMs: 3_000 })
336 return { ms, mark, os }
337 } catch {
338 return { ms, mark: '', os }
339 }
340}
341
342async function walkSize($: EngineInterface, dir: string): Promise<number> {
343 let total = 0
344 let seen = 0
345 let level = [dir]
346 while (level.length) {
347 const next: string[] = []
348 for (const d of level) {
349 let entries
350 try {
351 entries = await $.fs.list(d)
352 } catch {
353 continue
354 }
355 for (const e of entries) {
356 if (++seen > SIZE_WALK_LIMIT) return -1
357 if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
358 else if (e.kind === 'file') total += e.size
359 }
360 }
361 level = next
362 }
363 return total
364}
365
366async function dirSize($: EngineInterface, dir: string): Promise<number> {
367 if (rootOfDisk(dir)) return -1
368 if ((await osName($)) === 'win32') return walkSize($, dir)
369 try {
370 const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
371 const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
372 return Number.isFinite(kb) ? kb * 1024 : -1
373 } catch {
374 return -1
375 }
376}
377
378function pumpSizes($: EngineInterface): void {
379 while (sizeActive < SIZE_JOBS && sizeQueue.length) {
380 const dir = sizeQueue.shift() ?? ''
381 const epoch = sizeEpoch
382 sizing.add(dir)
383 sizeActive += 1
384 void dirSize($, dir)
385 .then(async bytes => {
386 if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
387 const cur = await get($)
388 if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
389 })
390 .catch(() => undefined)
391 .finally(() => {
392 sizing.delete(dir)
393 sizeActive -= 1
394 pumpSizes($)
395 })
396 }
397}
398
399function wantSizes($: EngineInterface, dirs: string[]): void {
400 for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
401 pumpSizes($)
402}
403
404async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
405 sizeEpoch += 1
406 sizeQueue = []
407 await patch($, cur => {
408 if (!paths) return { dirSizes: {} }
409 const keep: Record<string, number> = {}
410 for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
411 return { dirSizes: keep }
412 })
413}
414
415async function followCwd($: EngineInterface): Promise<boolean> {
416 if (!follow) return false
417 const t = await get($)
418 if (pinRoot && t.root) return false
419 const cwd = await cwdOf($)
420 if (t.root === cwd) return false
421 await reset($, cwd)
422 return true
423}
424
425type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
426type Background = { p: Pending | null; job: Job; reads: string[] }
427type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
428
429const PROOF: Record<string, (op: GitOperation) => boolean> = {
430 push: op => Boolean(op.push),
431 merge: op => op.branch?.action === 'merged',
432 rebase: op => op.branch?.action === 'rebased',
433 'pr create': op => op.pr?.action === 'created',
434 'pr merge': op => op.pr?.action === 'merged',
435 'pr comment': op => op.pr?.action === 'commented',
436}
437
438async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
439 const now = await $.clock.now()
440 const ids = actions.map(() => ++activityId)
441 await setActivities($, cur => [
442 ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
443 ...actions.map((a, i) => ({
444 id: ids[i] ?? 0,
445 kind: a.kind,
446 label: a.running,
447 state: 'running' as const,
448 detail: '',
449 at: now,
450 tone: a.tone,
451 nerd: a.icon.nerd,
452 plain: a.icon.plain,
453 })),
454 ])
455 return ids
456}
457
458async function headOf($: EngineInterface, cwd: string): Promise<string> {
459 try {
460 const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
461 return run.exitCode === 0 ? run.stdout.trim() : ''
462 } catch {
463 return ''
464 }
465}
466
467async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
468 const chain = chainOf(p.command)
469 const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
470 return p.actions.map(a => {
471 if (a.verb === 'commit') {
472 const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
473 return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
474 }
475 if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
476 if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
477 return { state: 'done', label: `ran ${a.kind}`, detail: '' }
478 })
479}
480
481async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
482 const now = await $.clock.now()
483 await setActivities($, cur =>
484 cur.map(a => {
485 const i = p.ids.indexOf(a.id)
486 const result = i < 0 ? undefined : results[i]
487 return result ? { ...a, ...result, at: now } : a
488 }),
489 )
490 $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
491}
492
493async function endTasks($: EngineInterface, text: string): Promise<void> {
494 if (!fdCfg || !text.includes('<task-notification>')) return
495 const at = await $.clock.now()
496 for (const [id, status] of taskEnds(text)) await updateFdTasks($, list => endTask(listOf<BgTask>(list), id, status, at))
497}
498
499async function settleBackground($: EngineInterface, text: string): Promise<void> {
500 if (background.size === 0 || !text.includes('<task-notification>')) return
501 for (const [id, status] of taskEnds(text)) {
502 const task = background.get(id)
503 if (!task) continue
504 background.delete(id)
505 const ok = status === 'completed'
506 const results = task.p ? await outcomes($, task.p, ok, undefined) : []
507 if (task.p) await finishGit($, task.p, results)
508 if (showReads) queuedReads.push(...task.reads)
509 scheduleScan($, { ...task.job, actions: proven(task.p, results) })
510 }
511}
512
513async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
514 const map = mapper(root, prefix)
515 try {
516 const run = committed
517 ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
518 : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
519 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
520 } catch {
521 return []
522 }
523}
524
525async function gitDir($: EngineInterface, cwd: string): Promise<string> {
526 for (let dir = cwd, i = 0; i < 64; i++) {
527 try {
528 const dot = join(dir, '.git')
529 const st = await $.fs.stat(dot)
530 if (st.kind === 'dir') return dot
531 const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
532 if (ref) return resolve(dir, ref, home)
533 } catch {
534 const up = dirname(dir)
535 if (up === dir) return ''
536 dir = up
537 continue
538 }
539 return ''
540 }
541 return ''
542}
543
544async function sync($: EngineInterface, force = false): Promise<void> {
545 const now = await $.clock.now()
546 if (!force && now - lastSync < 2_000) return
547 lastSync = now
548 searchIndex = null
549 const t = await get($)
550 if (!t.root) return
551 await loadDirs($, [t.root, ...openDirs(t)])
552 if (t.top) await refreshGit($)
553}
554
555async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
556 const t = await get($)
557 const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
558 const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
559 $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
560}
561
562async function exists($: EngineInterface, path: string): Promise<boolean> {
563 try {
564 await $.fs.stat(path)
565 return true
566 } catch {
567 return false
568 }
569}
570
571async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
572 const reads = queuedReads
573 queuedReads = []
574 if (await followCwd($)) return
575 const t = await get($)
576 if (!t.root) return
577 const actions = jobs.flatMap(j => j.actions)
578 const writers = jobs.filter(j => !j.readOnly)
579 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 })
580 const writes = !jobs.every(j => j.readOnly)
581 const before = dirty.root === t.root ? dirty.files : {}
582 if (jobs.some(j => j.initRepo)) await detectRepo($)
583 else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
584 const probed = await get($)
585 if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
586 if (writes) {
587 searchIndex = null
588 await staleSizes($)
589 }
590 const fresh = await get($)
591 const ignored = new Set(fresh.ignored)
592 if (writes) {
593 const found = fresh.top
594 ? await changedInRepo($, t.root, since, before, fresh.ignored)
595 : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
596 const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
597 if (showWrites) await recordTouched($, 'write', hits)
598 const loaded = await get($)
599 const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
600 const listed = await loadDirs($, dirs)
601 if (!fresh.top && since.os === 'win32')
602 for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
603 await patch($, cur => {
604 const ids = new Set(cur.nodes.map(n => n.id))
605 return { expanded: cur.expanded.filter(id => ids.has(id)) }
606 })
607 }
608 if (showReads) {
609 const found: string[] = []
610 for (const r of reads) {
611 if (found.length >= READ_REVEAL_LIMIT) break
612 if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
613 }
614 await recordTouched($, 'read', found)
615 }
616 if (showWrites && actions.length) {
617 const final = await get($)
618 const committed = actions.some(a => a.verb === 'commit')
619 if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
620 const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
621 await recordTouched($, 'git', paths, committed ? 'commit' : 'add')
622 }
623 }
624}
625
626function scheduleScan($: EngineInterface, job: Job): void {
627 scanJobs.push(job)
628 if (scanning) return
629 scanning = (async () => {
630 while (scanJobs.length) {
631 const jobs = scanJobs
632 scanJobs = []
633 try {
634 await afterBash($, jobs)
635 } finally {
636 const marks = jobs.map(j => j.since.mark).filter(Boolean)
637 if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
638 }
639 }
640 })().finally(() => {
641 scanning = null
642 })
643}
644
645/** Tells the Flightdeck `files` panel what Claude touched; a no-op when the tab is off. */
646async function recordTouched($: EngineInterface, kind: FileEvent['kind'], paths: string[], verb = ''): Promise<void> {
647 if (!fdCfg || paths.length === 0) return
648 const at = await $.clock.now()
649 await updateFdFiles($, list => recordFiles(listOf<FileEvent>(list), paths.map(path => ({ path, kind, verb, at }))))
650}
651
652async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
653 if (await followCwd($)) return
654 const t = await get($)
655 if (!t.root) return
656 const within = paths.map(posix).filter(p => inside(t.root, p))
657 if (within.length === 0) return
658 if (show) await recordTouched($, tone === 'purple' ? 'read' : 'write', within)
659 if (tone !== 'purple') {
660 searchIndex = null
661 await staleSizes($, within)
662 await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
663 await refreshGit($)
664 }
665}
666
667async function reveal($: EngineInterface, paths: string[]): Promise<void> {
668 if (await followCwd($)) return
669 await revealPaths($, paths)
670 await patch($, cur => {
671 const open = new Set(cur.expanded)
672 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
673 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
674 })
675}
676
677/** A click on a Flightdeck `files` row: show the file in the tree and open it in the viewer (a diff for what Claude changed, the file for what it read). */
678async function showFile($: EngineInterface, f: FileEvent): Promise<void> {
679 await reveal($, [f.path])
680 await patch($, () => ({ selected: f.path }))
681 await update($, mode, () => (f.kind === 'read' ? 'file' : 'diff'))
682 await show($)
683}
684
685async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
686 const out: string[] = []
687 let level = [root]
688 for (let d = 0; d < depth && level.length && out.length < limit; d++) {
689 const next: string[] = []
690 for (const dir of level) {
691 for (const n of (await list($, dir)) ?? []) {
692 if (n.kind === 'dir') {
693 if (!PRUNE.includes(n.name)) next.push(n.id)
694 } else out.push(n.id)
695 }
696 if (out.length >= limit) break
697 }
698 level = next
699 }
700 return out
701}
702
703async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
704 try {
705 if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
706 const run = t.top
707 ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
708 : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
709 if (t.top && run.exitCode !== 0) return null
710 const paths = run.stdout.split('\0').filter(Boolean)
711 if (run.isStdoutTruncated) paths.pop()
712 // `ls-files --exclude-standard` leaves out gitignored files, which the tree still shows: add them, minus the pruned dirs
713 if (t.top) {
714 const ignored = await git($, t.root, ['ls-files', '-oi', '--exclude-standard', '-z'], 10_000)
715 if (ignored.exitCode === 0) paths.push(...ignored.stdout.split('\0').filter(p => p && !p.split('/').some(seg => PRUNE.includes(seg))))
716 }
717 return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
718 } catch {
719 return null
720 }
721}
722
723function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
724 if (searchIndex?.root !== t.root) {
725 const index: { root: string; paths: Promise<string[]> } = {
726 root: t.root,
727 paths: listAll($, t).then(paths => {
728 if (paths === null && searchIndex === index) searchIndex = null
729 return paths ?? []
730 }),
731 }
732 searchIndex = index
733 }
734 return searchIndex.paths
735}
736
737async function search($: EngineInterface, query: string): Promise<string[]> {
738 if (!(await get($)).query.trim()) searchIndex = null
739 await patch($, () => ({ query }))
740 const q = query.trim().toLowerCase()
741 if (!q) return []
742 const t = await get($)
743 const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
744 if ((await get($)).query !== query) return []
745 await revealPaths($, hits)
746 const shown = await get($)
747 const ids = new Set(shown.nodes.map(n => n.id))
748 const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
749 if (stale.length) await loadDirs($, stale)
750 return hits
751}
752
753async function jump($: EngineInterface, query: string): Promise<void> {
754 const hits = (await search($, query)).slice(0, 10)
755 await patch($, cur => {
756 const open = new Set(cur.expanded)
757 for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
758 return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
759 })
760}
761
762async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
763 try {
764 const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
765 return out === 'stale' || out === 'missing' ? out : 'ok'
766 } catch {
767 return 'missing'
768 }
769}
770
771async function finePointerOk($: EngineInterface): Promise<boolean> {
772 if (!(await $.env.get('HERDR_ENV'))) return true
773 try {
774 const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
775 const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
776 return major * 1e6 + minor * 1e3 + fix >= 9_001
777 } catch {
778 return false
779 }
780}
781
782async function openFile($: EngineInterface, path: string): Promise<void> {
783 const { argv, init } = openCommand(await osName($), path)
784 try {
785 const run = await $.process.run(argv, init)
786 if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
787 } catch {
788 $.ui.toast(`could not open ${path} with ${argv[0]}`)
789 }
790}
791
792async function toggle($: EngineInterface, n: FileNode): Promise<void> {
793 if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
794 await patch($, t => {
795 const open = new Set(t.expanded)
796 if (n.kind === 'dir') {
797 if (open.has(n.id)) open.delete(n.id)
798 // Accordion: opening a folder closes every other one, keeping only the path down to it
799 else if (accordion) {
800 open.clear()
801 for (const a of ancestorsOf(n.id, t.root)) open.add(a)
802 open.add(n.id)
803 } else open.add(n.id)
804 }
805 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
806 })
807}
808
809async function press($: EngineInterface, n: FileNode): Promise<void> {
810 const now = await $.clock.now()
811 const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
812 lastPress = { key: isDouble ? '' : n.id, at: now }
813 if (!isDouble) {
814 await toggle($, n)
815 // The click above can take a while (viewer opens); time the second click from when it finished
816 lastPress.at = await $.clock.now()
817 return
818 }
819 // Double click: a file opens in the viewer; a folder was already toggled by the first click
820 if (n.kind !== 'dir') await show($)
821}
822
823async function openNode($: EngineInterface, n: FileNode): Promise<void> {
824 if (n.kind === 'dir') {
825 follow = false
826 await reset($, n.id)
827 } else await openFile($, n.id)
828}
829
830function shortPath(path: string): string {
831 return shortenHome(path, home)
832}
833
834type Btn = { label: string; hotkey?: string; onPress: () => void }
835type Els = ReturnType<EngineInterface['ui']['resolve']>
836
837// One row of buttons with a space between each; a `false` entry is a hidden button and leaves no gap.
838function buttons(els: Els, items: (Btn | false)[], compact = false) {
839 const { Button, Text } = els
840 return items.flatMap((b, i) =>
841 b ? [i > 0 ? <Text> </Text> : null, <Button label={compact && b.hotkey ? `[${b.hotkey}]` : b.label} hotkey={b.hotkey} onPress={b.onPress} />] : [],
842 )
843}
844
845async function loadTheme($: EngineInterface): Promise<void> {
846 const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
847 try {
848 const stat = await $.fs.stat(path)
849 if (stat.kind !== 'file') throw new Error('no theme file')
850 if (stat.mtimeMs === themeMtime) return
851 await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
852 themeMtime = stat.mtimeMs
853 } catch {
854 if (themeMtime === 0) return
855 await $.state.set(THEME, DEFAULT_THEME)
856 themeMtime = 0
857 }
858}
859
860// ---- file viewer, repo branches, pull/push (merged from the file-viewer mod) ----
861const VIEWER_PANE = 'file-viewer'
862const TITLE = 'File viewer'
863const MAX_CHARS = 90000
864const RENDER_TIMEOUT_MS = 10000
865// Set by register() when the Flightdeck tab is on; null hides the tab (its hooks live in fd.tsx and skip registering)
866const FD_PANE = 'flightdeck'
867let fdOptions: Readonly<Record<string, unknown>> | null = null
868let fdCfg: Config | null = null
869// tool.check carries no loop id; the tool.call around it does, keyed by the call's id.
870const callLoop = new Map<string, string | null>()
871const mode = atom({ plugin: 'filetree', key: 'mode' } as const, 'diff' as ViewMode)
872const auto = atom({ plugin: 'filetree', key: 'auto' } as const, false)
873// A path given to `/view <path>`; shown instead of the filetree selection until the next selection there
874const SCROLL_STEP = 20
875const GUTTER = 7
876// Columns scrolled off to the left in the File and Diff views
877// First row shown (the mod scrolls vertically itself so the header stays put)
878const row = atom({ plugin: 'filetree', key: 'row' } as const, 0)
879const col = atom({ plugin: 'filetree', key: 'col' } as const, 0)
880// True while the viewer pane is open; the band reads it because a render hook cannot call $.ui.panes()
881// One tab at a time: opening the viewer closes the file tree; closing the viewer brings the tree back
882const single = atom({ plugin: 'filetree', key: 'single' } as const, false)
883const isOpen = atom({ plugin: 'filetree', key: 'isOpen' } as const, false)
884const target = atom({ plugin: 'filetree', key: 'target' } as const, '')
885
886// Diagram sources already drawn this session, by source text
887const diagrams = new Map<string, string>()
888
889// Last row the header-fixed window can start at, as of the last pane draw; the wheel and arrows clamp to it
890let lastMaxRow = 0
891const WHEEL_ROWS = 3
892// Fallback when the pane's height is unknown: screen rows the pane body never gets (prompt, frame)
893const CHROME_ROWS = 12
894// Rows the pane draws above the content: the controls and the hint line
895const HEADER_ROWS = 2
896// bodyRows reads ~10 rows short of the room the pane really has (measured: 11 leaves the last line cut off, 0 leaves rows empty)
897const EXTRA_ROWS = 10
898// Blank rows under the window so the tree is always taller than the pane and the engine keeps raising ui.scroll
899const SPACER_ROWS = 12
900
901// Repos directly under the working directory, with their branch (shown in the viewer's Repos view)
902const REFRESH_MS = 30000
903// Branches repos normally sit on; anything else (a feature branch) is highlighted
904let deltaBin: string | null = null
905const BASE_BRANCHES = new Set(['main', 'master', 'develop', 'dev', 'staging', 'production'])
906const repos = atom({ plugin: 'filetree', key: 'repos' } as const, [] as RepoRow[])
907// Repos view: the repo whose changed files are listed ('' = the repo list), and whether clean repos are listed too
908const repoPick = atom({ plugin: 'filetree', key: 'repoPick' } as const, '')
909const allRepos = atom({ plugin: 'filetree', key: 'allRepos' } as const, false)
910
911type ChangedFile = { path: string; letter: string; add: number; del: number }
912
913// `git status --porcelain=v2 --branch`: "# branch.head <name>", "# branch.ab +<ahead> -<behind>", then one line per changed path
914const inspectRepo = async ($: EngineInterface, name: string, path = name): Promise<RepoRow> => {
915 const run = await $.process.run(['git', '-C', path, 'status', '--porcelain=v2', '--branch'], { timeoutMs: 10000 })
916 const repo: RepoRow = { name, path, branch: '?', dirty: 0, ahead: 0, behind: 0 }
917 if (run.exitCode !== 0) return repo
918 for (const line of run.stdout.split('\n')) {
919 if (line.startsWith('# branch.head ')) repo.branch = line.slice('# branch.head '.length)
920 else if (line.startsWith('# branch.ab ')) {
921 const [ahead, behind] = line.slice('# branch.ab '.length).split(' ')
922 repo.ahead = Math.abs(Number(ahead))
923 repo.behind = Math.abs(Number(behind))
924 } else if (line !== '' && !line.startsWith('#')) repo.dirty += 1
925 }
926
927 return repo
928}
929
930const refreshRepos = async ($: EngineInterface): Promise<void> => {
931 const root = await launchRoot($)
932 const names: string[] = []
933 for (const entry of await $.fs.list(root)) {
934 if (entry.kind === 'dir' && !entry.name.startsWith('.') && (await $.fs.exists(`${root}/${entry.name}/.git`))) names.push(entry.name)
935 }
936 const rows = await Promise.all(names.sort().map(name => inspectRepo($, name, `${root}/${name}`)))
937 // The repo that holds the folder (the folder itself, or a parent when working in a subfolder) is the main one and stays pinned first
938 const top = (await git($, root, ['rev-parse', '--show-toplevel'], 5_000).catch(() => undefined))?.stdout.trim()
939 if (top) rows.unshift(await inspectRepo($, top.split('/').pop() || top, top))
940 await update($, repos, () => rows)
941}
942
943// Pull (fast-forward only) and push (never forced) one repo, then re-read it; the result goes to a toast
944const gitAction = async ($: EngineInterface, repo: RepoRow, verb: 'pull' | 'push'): Promise<void> => {
945 const { name, path } = repo
946 $.ui.toast(`${verb === 'pull' ? 'Pulling' : 'Pushing'} ${name}...`)
947 const argv = verb === 'pull' ? ['git', '-C', path, 'pull', '--ff-only'] : ['git', '-C', path, 'push']
948 try {
949 const run = await $.process.run(argv, { timeoutMs: 120000 })
950 const out = (run.exitCode === 0 ? run.stdout || run.stderr : run.stderr || run.stdout).trim().split('\n').filter(l => l !== '')
951 $.ui.toast(`${name}: ${run.exitCode === 0 ? `${verb} ok` : `${verb} failed`}${out.length > 0 ? ` - ${out[out.length - 1]}` : ''}`)
952 } catch (err) {
953 $.ui.toast(`${name}: ${verb} failed - ${String(err)}`)
954 }
955 await refreshRepos($)
956}
957
958// Files changed in one repo (staged, unstaged and untracked), with their +N -N against HEAD
959const changedFiles = async ($: EngineInterface, repo: RepoRow): Promise<ChangedFile[]> => {
960 const abs = repo.path
961 const [status, numstat] = await Promise.all([
962 $.process.run(['git', '--no-optional-locks', '-C', abs, 'status', '--porcelain=v1', '-z', '--untracked-files=all'], { timeoutMs: 10000 }),
963 $.process.run(['git', '--no-optional-locks', '-C', abs, 'diff', 'HEAD', '--numstat', '-z'], { timeoutMs: 10000 }),
964 ])
965 if (status.exitCode !== 0) return []
966 const counts: Record<string, [number, number]> = {}
967 if (numstat.exitCode === 0) parseNumstat(numstat.stdout, abs, '', counts)
968 const { files } = parseGit(status.stdout, abs, '')
969 const letters: Record<Change, string> = { new: 'A', mod: 'M', del: 'D' }
970
971 return Object.entries(files)
972 .map(([path, change]) => ({ path, letter: letters[change], add: counts[path]?.[0] ?? 0, del: counts[path]?.[1] ?? 0 }))
973 .sort((a, b) => a.path.localeCompare(b.path))
974}
975
976// Repo list and, once one is picked, its changed files; `inViewer` adds the Diff/File buttons and the file-tree hint
977const reposView = async ($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], path: string, inViewer: boolean): Promise<RenderNode> => {
978 const els = $.ui.resolve(e)
979 const { Box, Text, Button } = els
980 const everyRepo = await read($, repos)
981 const showAll = await read($, allRepos)
982 const rows = showAll ? everyRepo : everyRepo.filter(r => r.dirty > 0)
983 const nameWidth = rows.reduce((n, r) => Math.max(n, r.name.length), 0)
984 const pickedPath = await read($, repoPick)
985 const picked = everyRepo.find(r => r.path === pickedPath)
986 const header = (
987 <Box>
988 {buttons(els, [
989 !!(inViewer && path) && { label: 'Diff', hotkey: 'd', onPress: () => void update($, mode, () => 'diff') },
990 !!(inViewer && path) && { label: 'File', hotkey: 'f', onPress: () => void update($, mode, () => 'file') },
991 inViewer && { label: 'Repos', hotkey: 'r', onPress: () => void update($, mode, () => 'repos') },
992 { label: 'Refresh', onPress: () => void refreshRepos($) },
993 !picked && { label: showAll ? 'Changed only' : 'All', hotkey: 'a', onPress: () => void update($, allRepos, current => !current) },
994 ])}
995 {!picked && <Text dimColor> {showAll ? `${everyRepo.length} repos` : `${rows.length} of ${everyRepo.length} repos with changes`}</Text>}
996 </Box>
997 )
998
999 if (picked) {
1000 const base = picked.path
1001 const files = await changedFiles($, picked)
1002 const open = (file: ChangedFile) =>
1003 void (async () => {
1004 await update($, target, () => file.path)
1005 await update($, col, () => 0)
1006 await update($, row, () => 0)
1007 await update($, mode, () => 'diff')
1008 })()
1009
1010 return (
1011 <Box flexDirection="column">
1012 {header}
1013 <Box>
1014 <Button label="← Repos" hotkey="b" onPress={() => void update($, repoPick, () => '')} />
1015 <Text> {picked.name} </Text>
1016 <Text color={BASE_BRANCHES.has(picked.branch) ? undefined : 'yellow'} dimColor={BASE_BRANCHES.has(picked.branch)}>
1017 {picked.branch}
1018 </Text>
1019 <Text dimColor> · {files.length} changed files</Text>
1020 </Box>
1021 {files.length === 0 && <Text dimColor>No changes in this repo.</Text>}
1022 {files.map(f => (
1023 <Box>
1024 <Text color={GIT_COLOR[f.letter === 'A' ? '?' : f.letter]}>{f.letter} </Text>
1025 <Button key={`file-${f.path}`} plain label={f.path.slice(base.length + 1)} onPress={() => open(f)} />
1026 {f.add > 0 && <Text color="green"> +{f.add}</Text>}
1027 {f.del > 0 && <Text color="red"> -{f.del}</Text>}
1028 </Box>
1029 ))}
1030 </Box>
1031 )
1032 }
1033
1034 return (
1035 <Box flexDirection="column">
1036 {header}
1037 {rows.length === 0 && (
1038 <Text dimColor>
1039 {everyRepo.length > 0 ? 'No repos with changes. Press "a" to list all.' : 'No git repos directly under this folder.'}
1040 </Text>
1041 )}
1042 {inViewer && rows.length > 0 && !path && <Text dimColor>Select a file in the file tree to preview it.</Text>}
1043 {rows.map(r => (
1044 <Box>
1045 <Button key={`open-${r.path}`} plain label={r.name.padEnd(nameWidth)} onPress={() => void update($, repoPick, () => r.path)} />
1046 <Text> </Text>
1047 <Text color={BASE_BRANCHES.has(r.branch) ? undefined : 'yellow'} dimColor={BASE_BRANCHES.has(r.branch)}>
1048 {r.branch}
1049 </Text>
1050 {r.dirty > 0 && <Text color="red"> ●{r.dirty}</Text>}
1051 {r.ahead > 0 && <Text color="green"> ↑{r.ahead}</Text>}
1052 {r.behind > 0 && <Text color="cyan"> ↓{r.behind}</Text>}
1053 {r.behind > 0 && <Text> </Text>}
1054 {r.behind > 0 && <Button key={`pull-${r.name}`} plain label="pull" onPress={() => void gitAction($, r, 'pull')} />}
1055 {r.ahead > 0 && <Text> </Text>}
1056 {r.ahead > 0 && <Button key={`push-${r.name}`} plain label="push" onPress={() => void gitAction($, r, 'push')} />}
1057 </Box>
1058 ))}
1059 </Box>
1060 )
1061}
1062
1063const parentDir = (path: string): string => path.slice(0, path.lastIndexOf('/')) || '/'
1064
1065const show = async ($: EngineInterface): Promise<void> => {
1066 const placed = await $.ui.open({ id: VIEWER_PANE, title: TITLE, focus: true })
1067 await update($, isOpen, () => true)
1068 if (await read($, single)) await $.ui.close({ id: 'filetree' }).catch(() => undefined)
1069 if (!placed.isPlaced) $.ui.toast(`File viewer not shown: ${placed.reason}`)
1070}
1071
1072// Mermaid -> text via ~/.claude/file-viewer-tools/render.mjs (beautiful-mermaid)
1073const drawDiagram = async ($: EngineInterface, source: string): Promise<string> => {
1074 const cached = diagrams.get(source)
1075 if (cached !== undefined) return cached
1076 const home = (await $.env.get('HOME')) ?? ''
1077 const run = await $.process.run(['node', `${home}/.claude/file-viewer-tools/render.mjs`], {
1078 stdin: source,
1079 timeoutMs: RENDER_TIMEOUT_MS,
1080 })
1081 const text = run.exitCode === 0 ? run.stdout : `mermaid render failed: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}\n\n${source}`
1082 diagrams.set(source, text)
1083
1084 return text
1085}
1086
1087// ---- Flightdeck (ported from scasella/claude-flightdeck, MIT) ----
1088
1089
1090export const register: Register = (on, options) => {
1091 fdOptions = options?.flightdeck === 'off' ? null : (options ?? {})
1092 if (fdOptions) {
1093 const cfg = configOf(fdOptions)
1094 fdCfg = cfg
1095 const options = fdOptions
1096 on('ui.render', { component: 'Pane', requestId: FD_PANE }, async ($, e) => renderFlightdeck($, e, options, { treeRoot: async () => (await get($)).root, showFile: f => showFile($, f), shortPath }))
1097
1098 on('session.end', async ($, e, next) => {
1099 if (e.reason === 'clear') {
1100 await resetAll($)
1101 await refreshStatus($, cfg)
1102 }
1103 return next(e)
1104 })
1105
1106 on('classic.UserPromptSubmit', async ($, e, next) => {
1107 await noteMode($, e.permission_mode)
1108 return next(e)
1109 })
1110
1111 on('agent.offer', async ($, e, next) => {
1112 const offered = await next(e)
1113 if (cfg.architect.test(e.agent) || (cfg.matchDescriptions && cfg.architect.test(e.description))) {
1114 await updateFdRoster($, r => {
1115 const x = normalize(DEFAULT_ROSTER, r)
1116 return x.architectTypes.includes(e.agent) ? x : { architectTypes: [...listOf<string>(x.architectTypes), e.agent].slice(-20) }
1117 })
1118 }
1119 return offered
1120 })
1121
1122 on('turn.start', async ($, e, next) => {
1123 const [now, cost] = await Promise.all([$.clock.now(), costNow($)])
1124 await updateFdTurn($, () => ({ ...DEFAULT_TURN, startedAt: now, costAtStart: cost }))
1125 await updateFdMain($, m => ({ ...normalize(DEFAULT_MAIN, m), isRunning: true }))
1126 // A background architect's report reaches the main loop as the text opening this turn. The
1127 // SubagentHandback tool call (in tool.call) normally carries it first; this is the fallback.
1128 const back = e.text ? handbackOf(e.text) : null
1129 const a = back ? await getArchitect($) : null
1130 if (back && a && a.ids.includes(back.from)) {
1131 const advice = adviceLine(back.body)
1132 if (advice && advice !== a.lastAdvice) await noteAdvice($, cfg, advice)
1133 } else if (e.text) {
1134 const p = promptLine(e.text)
1135 await say($, p.who, p.text)
1136 }
1137 return next(e)
1138 })
1139
1140 on('turn.step', async function* ($, e, next) {
1141 // The main loop's model is known when its request starts; a long first request shouldn't read "—".
1142 if (!e.agentId) {
1143 await updateFdMain($, m => {
1144 const x = normalize(DEFAULT_MAIN, m)
1145 return { ...x, model: e.model, effort: String(e.effort ?? x.effort), steps: x.steps + 1 }
1146 })
1147 // Time the response as it passes: the first piece that is not the engine's envelope, then the
1148 // stop chunk. Two clock reads in the stream and nothing stored until it has ended.
1149 const sentAt = await $.clock.now()
1150 let firstAt: number | null = null
1151 let stopAt: number | null = null
1152 const s = next(e)
1153 for await (const c of s) {
1154 if (c.kind === 'stop') stopAt = await $.clock.now()
1155 else if (firstAt === null && c.kind !== 'engine') firstAt = await $.clock.now()
1156 yield c
1157 }
1158 const result = await s.result
1159 const speed = result.usage && stopAt !== null ? stepSpeed(sentAt, firstAt, stopAt, result.usage.output_tokens) : null
1160 if (speed) await updateFdMain($, m => ({ ...normalize(DEFAULT_MAIN, m), speed }))
1161 return result
1162 }
1163 const result = yield* next(e)
1164 const id = e.agentId
1165 const [cards, a] = await Promise.all([getCards($), getArchitect($)])
1166 if (cards.some(c => c.id === id)) {
1167 const step = { model: e.model, usage: result.usage, stopReason: result.stopReason }
1168 await updateFdAgents($, list => listOf<unknown>(list).map(normalizeCard).map(c => (c.id === id ? applyStep(c, step) : c)))
1169 if (result.stopReason === 'max_tokens') await say($, await whoIs($, id), 'hit max_tokens', 'error', id)
1170 } else if (!a.ids.includes(id)) {
1171 const now = await $.clock.now()
1172 await updateFdLoops($, l => stepLoop(listOf<Loop>(l), id, now))
1173 }
1174 return result
1175 })
1176
1177 on('session.measure', async ($, e, next) => {
1178 await updateFdUsage($, x => ({
1179 ...normalize(DEFAULT_USAGE, x),
1180 pct: e.context.percent ?? null,
1181 tokens: e.context.tokens ?? null,
1182 window: e.context.window,
1183 costUsd: e.cost?.usd ?? null,
1184 limits: e.rateLimits.map(limitOf),
1185 }))
1186 if (e.changed.includes('context')) await refreshStatus($, cfg)
1187 return next(e)
1188 })
1189
1190 on('session.compact', async ($, e, next) => {
1191 const done = await next(e)
1192 if (!e.agentId && e.trigger !== 'precompute') {
1193 const now = await $.clock.now()
1194 await updateFdUsage($, x => {
1195 const u = normalize(DEFAULT_USAGE, x)
1196 return { ...u, compactions: u.compactions + 1, lastCompactAt: now }
1197 })
1198 await say($, 'main', `context compacted (${e.trigger})`)
1199 }
1200 return donehooks/delta.ts 135 lines1import type { EngineInterface } from 'claude-code'
2
3// delta (https://github.com/dandavison/delta) draws the git diff: syntax colors, word-level highlights, line numbers.
4// The mod installs it itself (prebuilt release into ~/.local/bin) and falls back to the plain diff while it is missing.
5const VERSION = '0.18.2'
6
7export type Span = { t: string; c?: string; bg?: string; b?: boolean; i?: boolean; s?: boolean }
8
9const ANSI = /\x1b\[([0-9;]*)m|\x1b\[[0-9;?]*[A-Za-z]/g
10
11const BASIC = ['#000000', '#cd3131', '#0dbc79', '#e5e510', '#2472c8', '#bc3fbc', '#11a8cd', '#e5e5e5']
12const BRIGHT = ['#666666', '#f14c4c', '#23d18b', '#f5f543', '#3b8eea', '#d670d6', '#29b8db', '#ffffff']
13
14const hex = (n: number): string => n.toString(16).padStart(2, '0')
15
16// xterm 256-color index -> hex
17const palette = (n: number): string => {
18 if (n < 8) return BASIC[n] ?? '#000000'
19 if (n < 16) return BRIGHT[n - 8] ?? '#ffffff'
20 if (n >= 232) {
21 const g = 8 + (n - 232) * 10
22
23 return `#${hex(g)}${hex(g)}${hex(g)}`
24 }
25 const i = n - 16
26 const level = (v: number): number => (v === 0 ? 0 : 55 + v * 40)
27
28 return `#${hex(level(Math.floor(i / 36)))}${hex(level(Math.floor(i / 6) % 6))}${hex(level(i % 6))}`
29}
30
31export const stripAnsi = (text: string): string => text.replace(ANSI, '')
32
33// One line of SGR-colored text -> styled spans. Only color, bold, italic and strikethrough are kept; other escapes are dropped.
34export function parseAnsi(line: string): Span[] {
35 const spans: Span[] = []
36 let style: Omit<Span, 't'> = {}
37 let last = 0
38
39 const push = (text: string) => {
40 if (text !== '') spans.push({ t: text, ...style })
41 }
42
43 for (const m of line.matchAll(ANSI)) {
44 push(line.slice(last, m.index))
45 last = (m.index ?? 0) + m[0].length
46 if (m[1] === undefined) continue
47
48 const codes = m[1] === '' ? [0] : m[1].split(';').map(Number)
49 for (let k = 0; k < codes.length; k++) {
50 const code = codes[k] ?? 0
51 if (code === 0) style = {}
52 else if (code === 1) style = { ...style, b: true }
53 else if (code === 3) style = { ...style, i: true }
54 else if (code === 9) style = { ...style, s: true }
55 else if (code === 22) style = { ...style, b: undefined }
56 else if (code === 23) style = { ...style, i: undefined }
57 else if (code === 29) style = { ...style, s: undefined }
58 else if (code >= 30 && code <= 37) style = { ...style, c: BASIC[code - 30] }
59 else if (code >= 90 && code <= 97) style = { ...style, c: BRIGHT[code - 90] }
60 else if (code >= 40 && code <= 47) style = { ...style, bg: BASIC[code - 40] }
61 else if (code >= 100 && code <= 107) style = { ...style, bg: BRIGHT[code - 100] }
62 else if (code === 39) style = { ...style, c: undefined }
63 else if (code === 49) style = { ...style, bg: undefined }
64 else if (code === 38 || code === 48) {
65 const key = code === 38 ? 'c' : 'bg'
66 if (codes[k + 1] === 2) {
67 style = { ...style, [key]: `#${hex(codes[k + 2] ?? 0)}${hex(codes[k + 3] ?? 0)}${hex(codes[k + 4] ?? 0)}` }
68 k += 4
69 } else if (codes[k + 1] === 5) {
70 style = { ...style, [key]: palette(codes[k + 2] ?? 0) }
71 k += 2
72 }
73 }
74 }
75 }
76 push(line.slice(last))
77
78 return spans
79}
80
81// Drop the first `offset` visible characters (horizontal scroll)
82export function sliceSpans(spans: Span[], offset: number): Span[] {
83 if (offset <= 0) return spans
84 let skip = offset
85 const out: Span[] = []
86 for (const s of spans) {
87 if (skip >= s.t.length) skip -= s.t.length
88 else {
89 out.push(skip > 0 ? { ...s, t: s.t.slice(skip) } : s)
90 skip = 0
91 }
92 }
93
94 return out
95}
96
97const BIN = (home: string): string => `${home}/.local/bin`
98
99let ready: Promise<string | null> | null = null
100
101// Path of a working delta binary, installing it first when missing; null when it cannot be had. Runs the install once per session.
102export function ensureDelta($: EngineInterface, home: string): Promise<string | null> {
103 ready ??= (async () => {
104 const found = await $.process.run(['sh', '-c', `command -v delta || { [ -x "${BIN(home)}/delta" ] && echo "${BIN(home)}/delta"; }`], { timeoutMs: 5000 }).catch(() => null)
105 const path = found?.stdout.trim().split('\n')[0]
106 if (found?.exitCode === 0 && path) return path
107
108 const os = (await $.process.run(['uname', '-s'], { timeoutMs: 5000 })).stdout.trim()
109 const arch = (await $.process.run(['uname', '-m'], { timeoutMs: 5000 })).stdout.trim()
110 const cpu = arch === 'arm64' || arch === 'aarch64' ? 'aarch64' : 'x86_64'
111 const target = os === 'Darwin' ? `${cpu}-apple-darwin` : os === 'Linux' ? `${cpu}-unknown-linux-gnu` : ''
112 if (!target) return null
113
114 const dir = `delta-${VERSION}-${target}`
115 const url = `https://github.com/dandavison/delta/releases/download/${VERSION}/${dir}.tar.gz`
116 const script = `set -e; mkdir -p "${BIN(home)}"; tmp=$(mktemp -d); curl -fsSL "${url}" | tar -xz -C "$tmp"; install -m 755 "$tmp/${dir}/delta" "${BIN(home)}/delta"; rm -rf "$tmp"`
117 const run = await $.process.run(['sh', '-c', script], { timeoutMs: 120_000 }).catch(() => null)
118 if (run?.exitCode !== 0) return null
119 $.ui.toast('delta installed in ~/.local/bin')
120
121 return `${BIN(home)}/delta`
122 })()
123
124 return ready
125}
126
127// `git diff HEAD` for one file through delta; null when delta is missing or produced nothing
128export async function deltaDiff($: EngineInterface, bin: string | null, dir: string, path: string, width: number): Promise<string | null> {
129 if (!bin) return null
130 const script = `git -C "$1" diff HEAD --no-color -- "$2" | "$3" --paging=never --line-numbers --file-style=omit --width="$4"`
131 const run = await $.process.run(['sh', '-c', script, '_', dir, path, bin, String(width)], { timeoutMs: 15_000 }).catch(() => null)
132
133 return run?.exitCode === 0 && run.stdout.trim() !== '' ? run.stdout : null
134}
135hooks/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/blocked.ts 50 lines1// What the Flightdeck box does with a command the engine refused: hold it, run it only when the user says so, and hand
2// the result to the prompt. The engine's refusal is never overridden: the command runs as the user's own, like `!`.
3import type { EngineInterface } from 'claude-code'
4import { addBlocked, clipOutput, dismissBlocked, isStartable, patchBlocked, promptText } from './blocked-core'
5import { getBlocked, updateFdBlocked } from './fd-state'
6
7const RUN_TIMEOUT_MS = 120_000
8
9/** Holds a refused Bash command in the box and brings the Flightdeck up so it is seen. Runs nothing. */
10export async function holdBlocked($: EngineInterface, item: { id: string; command: string; reason: string }, pane: { id: string; title: string }): Promise<void> {
11 const at = await $.clock.now()
12 await updateFdBlocked($, list => addBlocked(list, { ...item, at, status: 'waiting', output: '', exitCode: null }))
13 // Bring the Flightdeck to the front: opening an id that is already up retitles it in place and, with `focus`, selects it.
14 const front = (await $.ui.panes().catch(() => [])).some(p => p.id === pane.id && p.isShown && p.isFocused)
15 if (!front) {
16 const placed = await $.ui.open({ id: pane.id, title: pane.title, columns: 66, rows: 12, focus: true }).catch(() => undefined)
17 if (!placed?.isPlaced) $.ui.log(`A command was blocked; open the Flightdeck tab to run it yourself.${placed && !placed.isPlaced ? ` (${placed.reason})` : ''}`)
18 }
19 $.ui.toast('A command was blocked: you can run it yourself in the Flightdeck tab')
20}
21
22/** Runs the held command exactly as shown, once, in the session's directory. */
23export async function runBlocked($: EngineInterface, id: string): Promise<void> {
24 const list = await getBlocked($)
25 const item = list.find(b => b.id === id)
26 if (!item || !isStartable(list, id)) return
27 await updateFdBlocked($, l => patchBlocked(l, id, { status: 'running' }))
28 try {
29 const cwd = await $.session.cwd()
30 const run = await $.process.run(['sh', '-c', item.command], { cwd, timeoutMs: RUN_TIMEOUT_MS })
31 const output = clipOutput([run.stdout, run.stderr].filter(Boolean).join('\n'))
32 await updateFdBlocked($, l => patchBlocked(l, id, { status: run.exitCode === 0 ? 'done' : 'failed', output, exitCode: run.exitCode }))
33 } catch (err) {
34 await updateFdBlocked($, l => patchBlocked(l, id, { status: 'failed', output: err instanceof Error ? err.message : String(err), exitCode: null }))
35 }
36}
37
38/** Puts what was run and what it printed in the prompt box; the user reads and sends it. */
39export async function sendBlockedToPrompt($: EngineInterface, id: string): Promise<void> {
40 const item = (await getBlocked($)).find(b => b.id === id)
41 if (!item) return
42 const filled = await $.prompt.fill({ text: promptText(item), mode: 'append' })
43 if (!filled.isFilled) $.ui.toast('The prompt box could not take the result; it is still in the Flightdeck.')
44}
45
46/** Forgets a held command. */
47export async function dismissBlockedCommand($: EngineInterface, id: string): Promise<void> {
48 await updateFdBlocked($, l => dismissBlocked(l, id))
49}
50hooks/media.ts 203 lines1// Media in the viewer: images (converted with sips), audio (afplay) and video (a poster frame from ffmpeg, seekable). Owns the playback atoms.
2import { atom, read, type EngineInterface, type Timer, update } from 'claude-code'
3import { cacheName } from './pure'
4
5export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic)$/i
6export const AUDIO_EXT = /\.(mp3|wav|m4a|aac|flac|ogg|aiff?)$/i
7export const VIDEO_EXT = /\.(mp4|mov|m4v|mkv|webm|avi)$/i
8const MEDIA_CACHE = '/tmp/file-viewer-cache'
9export const VIDEO_STEP = 5
10const playPid = atom({ plugin: 'filetree', key: 'playPid' } as const, 0)
11const playStart = atom({ plugin: 'filetree', key: 'playStart' } as const, 0)
12const playDuration = atom({ plugin: 'filetree', key: 'playDuration' } as const, 0)
13const videoPos = atom({ plugin: 'filetree', key: 'videoPos' } as const, 0)
14const tick = atom({ plugin: 'filetree', key: 'tick' } as const, 0)
15let ticker: Timer | undefined
16
17// Terminals that draw real images through the terminal's own protocol (iTerm2, WezTerm, Ghostty, Kitty); others get the text-cell picture
18export async function supportsImages($: EngineInterface): Promise<boolean> {
19 const program = (await $.env.get('TERM_PROGRAM')) ?? ''
20 if (/^(iTerm\.app|WezTerm|ghostty)$/i.test(program)) return true
21
22 return Boolean(await $.env.get('KITTY_WINDOW_ID')) || (await $.env.get('TERM')) === 'xterm-kitty'
23}
24
25
26export async function mediaDuration($: EngineInterface, path: string): Promise<number> {
27 const run = await $.process.run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path], { timeoutMs: 15000 })
28 const seconds = Number(run.stdout.trim())
29
30 return run.exitCode === 0 && Number.isFinite(seconds) ? seconds : 0
31}
32
33// A PNG for the terminal to draw (png stays as is), and its pixel size
34export async function imageFile($: EngineInterface, path: string): Promise<{ file: string; width: number; height: number } | undefined> {
35 let file = path
36 if (!/\.png$/i.test(path)) {
37 file = `${MEDIA_CACHE}/${cacheName(path)}.png`
38 await $.process.run(['mkdir', '-p', MEDIA_CACHE])
39 if (!(await $.fs.exists(file))) {
40 let made = await $.process.run(['sips', '-s', 'format', 'png', path, '--out', file], { timeoutMs: 30000 }).catch(() => ({ exitCode: 1 }))
41 // sips is macOS only; ffmpeg converts elsewhere
42 if (made.exitCode !== 0) made = await $.process.run(['ffmpeg', '-y', '-v', 'error', '-i', path, '-frames:v', '1', file], { timeoutMs: 30000 })
43 if (made.exitCode !== 0) return undefined
44 }
45 }
46 // ffprobe first (Linux and macOS with ffmpeg); sips is the macOS fallback and its absence must not abort the preview
47 const size = async (cmd: string[]): Promise<[number, number]> => {
48 try {
49 const run = await $.process.run(cmd, { timeoutMs: 15000 })
50 const text = run.stdout.trim()
51 if (cmd[0] === 'sips') return [Number(/pixelWidth:\s*(\d+)/.exec(text)?.[1]), Number(/pixelHeight:\s*(\d+)/.exec(text)?.[1])]
52 const [w, h] = text.split(',').map(Number)
53
54 return [w, h]
55 } catch {
56 return [0, 0]
57 }
58 }
59 let [width, height] = await size(['ffprobe', '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', file])
60 if (!(width > 0 && height > 0)) [width, height] = await size(['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', file])
61
62 return width > 0 && height > 0 ? { file, width, height } : undefined
63}
64
65export async function videoFrame($: EngineInterface, path: string, at: number): Promise<string | undefined> {
66 const file = `${MEDIA_CACHE}/${cacheName(path)}_${Math.round(at)}.png`
67 await $.process.run(['mkdir', '-p', MEDIA_CACHE])
68 if (!(await $.fs.exists(file))) {
69 const run = await $.process.run(['ffmpeg', '-y', '-v', 'error', '-ss', String(at), '-i', path, '-frames:v', '1', file], { timeoutMs: 30000 })
70 if (run.exitCode !== 0) return undefined
71 }
72
73 return file
74}
75
76// A PNG of a rendered HTML page, from the first headless browser found (the default one is often Brave or Chrome); re-made when the file is newer
77const HTML_SHOT = [
78 'out=$1; f=$2',
79 '[ "$out" -nt "$f" ] && exit 0',
80 'for b in brave-browser chromium chromium-browser google-chrome-stable google-chrome "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; do',
81 ' if command -v "$b" >/dev/null 2>&1 || [ -x "$b" ]; then',
82 ' exec "$b" --headless --disable-gpu --hide-scrollbars --screenshot="$out" --window-size=1200,900 "file://$f"',
83 ' fi',
84 'done',
85 'command -v firefox >/dev/null 2>&1 && exec firefox --headless --screenshot "$out" --window-size=1200,900 "file://$f"',
86 'exit 127',
87].join('\n')
88
89export async function htmlFrame($: EngineInterface, path: string): Promise<string | undefined> {
90 const file = `${MEDIA_CACHE}/${cacheName(path)}_html.png`
91 await $.process.run(['mkdir', '-p', MEDIA_CACHE])
92 const run = await $.process.run(['sh', '-c', HTML_SHOT, 'sh', file, path], { timeoutMs: 40000 }).catch(() => ({ exitCode: 1 }))
93
94 return run.exitCode === 0 && (await $.fs.exists(file)) ? file : undefined
95}
96
97export async function stopAudio($: EngineInterface): Promise<void> {
98 const pid = await read($, playPid)
99 ticker?.cancel()
100 ticker = undefined
101 if (pid > 0) await $.process.run(['kill', String(pid)]).catch(() => undefined)
102 await update($, playPid, () => 0)
103}
104
105export async function startAudio($: EngineInterface, path: string, duration: number): Promise<void> {
106 await stopAudio($)
107 // afplay reads the sound of mp4/mov/m4v itself; other containers get their audio pulled out into a cached m4a first
108 let playable = path
109 if (VIDEO_EXT.test(path) && !/\.(mp4|mov|m4v)$/i.test(path)) {
110 playable = `${MEDIA_CACHE}/${cacheName(path)}.m4a`
111 await $.process.run(['mkdir', '-p', MEDIA_CACHE])
112 if (!(await $.fs.exists(playable))) {
113 $.ui.toast('Extracting the audio...')
114 const made = await $.process.run(['ffmpeg', '-y', '-v', 'error', '-i', path, '-vn', '-c:a', 'aac', playable], { timeoutMs: 600000 })
115 if (made.exitCode !== 0) return $.ui.toast(`Could not extract the audio of ${path}`)
116 }
117 }
118 const run = await $.process.run(['sh', '-c', 'afplay "$1" >/dev/null 2>&1 & echo $!', 'sh', playable], { timeoutMs: 10000 })
119 const pid = Number(run.stdout.trim())
120 if (!(pid > 0)) return $.ui.toast(`Could not play ${path}`)
121 await update($, playPid, () => pid)
122 await update($, playStart, () => Date.now())
123 await update($, playDuration, () => duration)
124 // Redraw once a second for the elapsed time; stop the bookkeeping when the clip has run its length
125 ticker = $.clock.every(1000, async () => {
126 if (duration > 0 && Date.now() - (await read($, playStart)) > duration * 1000 + 500) await stopAudio($)
127 else await update($, tick, n => n + 1)
128 })
129}
130
131// A picture as cells: each cell is a braille glyph (2x4 dots) coloured with the bright dots' average on the average of the dark ones,
132// which is four times the detail of half-blocks in the same space
133const BRAILLE_BITS = [
134 [0x01, 0x08],
135 [0x02, 0x10],
136 [0x04, 0x20],
137 [0x40, 0x80],
138] as const
139
140export async function pictureCells($: EngineInterface, file: string, columns: number, rows: number): Promise<string | undefined> {
141 const wide = columns * 2
142 const tall = rows * 4
143 const raw = `${MEDIA_CACHE}/${cacheName(file)}_${wide}x${tall}.rgb`
144 if (!(await $.fs.exists(raw))) {
145 await $.process.run(['mkdir', '-p', MEDIA_CACHE])
146 const run = await $.process.run(
147 ['ffmpeg', '-y', '-v', 'error', '-i', file, '-vf', `scale=${wide}:${tall}:flags=lanczos`, '-frames:v', '1', '-f', 'rawvideo', '-pix_fmt', 'rgb24', raw],
148 { timeoutMs: 30000 },
149 )
150 if (run.exitCode !== 0) return undefined
151 }
152 const { base64 } = await $.fs.read(raw, { as: 'bytes' })
153 // The engine's runtime has the base64 helpers; the TypeScript lib does not declare them yet.
154 const rgb = (Uint8Array as unknown as { fromBase64(s: string): Uint8Array }).fromBase64(base64)
155 const cells = new Uint32Array(columns * rows * 3)
156 const pack = (r: number, g: number, b: number): number => (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b)
157 for (let cy = 0; cy < rows; cy++) {
158 for (let cx = 0; cx < columns; cx++) {
159 const dots: { x: number; y: number; r: number; g: number; b: number; lum: number }[] = []
160 for (let y = 0; y < 4; y++) {
161 for (let x = 0; x < 2; x++) {
162 const i = ((cy * 4 + y) * wide + cx * 2 + x) * 3
163 const r = rgb[i] ?? 0
164 const g = rgb[i + 1] ?? 0
165 const b = rgb[i + 2] ?? 0
166 dots.push({ x, y, r, g, b, lum: 0.299 * r + 0.587 * g + 0.114 * b })
167 }
168 }
169 const mean = dots.reduce((n, d) => n + d.lum, 0) / dots.length
170 const lit = dots.filter(d => d.lum >= mean)
171 const dark = dots.filter(d => d.lum < mean)
172 const avg = (group: typeof dots): number => {
173 const n = Math.max(1, group.length)
174
175 return pack(group.reduce((t, d) => t + d.r, 0) / n, group.reduce((t, d) => t + d.g, 0) / n, group.reduce((t, d) => t + d.b, 0) / n)
176 }
177 const k = (cy * columns + cx) * 3
178 if (dark.length === 0) {
179 // flat cell: a blank braille on the average colour
180 cells[k] = 0x2800
181 cells[k + 1] = avg(lit)
182 cells[k + 2] = avg(lit)
183 } else {
184 cells[k] = 0x2800 + lit.reduce((bits, d) => bits | (BRAILLE_BITS[d.y]?.[d.x] ?? 0), 0)
185 cells[k + 1] = avg(lit)
186 cells[k + 2] = avg(dark)
187 }
188 }
189 }
190
191 return (new Uint8Array(cells.buffer) as unknown as { toBase64(): string }).toBase64()
192}
193
194/** Playback state the viewer draws: the audio pid (0 when stopped), when it started, and the video seek position. */
195export async function getPlayback($: EngineInterface): Promise<{ pid: number; started: number; at: number }> {
196 const [pid, started, at] = await Promise.all([read($, playPid), read($, playStart), read($, videoPos)])
197 await read($, tick)
198 return { pid, started, at }
199}
200export async function setVideoPos($: EngineInterface, to: number): Promise<void> {
201 await update($, videoPos, () => to)
202}
203hooks/fd-state.ts 211 lines1// Flightdeck state: atoms, readers that tolerate older shapes, and the small writers the hooks share. No drawing here.
2import { atom, read, type EngineInterface, update } from 'claude-code'
3import { DEFAULT_BLOCKED, normalizeBlocked } from './blocked-core'
4import { type AgentCard, type BlockedCommand, type Architect, type BgTask, type FileEvent, type Gate, type LogLine, type Loop, type Main, type Receipt, type Roster, type Turn, type Usage, type View } from '../types'
5import { DEFAULT_ARCHITECT, DEFAULT_GATE, DEFAULT_MAIN, DEFAULT_ROSTER, DEFAULT_TURN, DEFAULT_USAGE, DEFAULT_VIEW, SCHEMA_VERSION, cardTitle, endConsult, gateSummary, isAdvising, listOf, momentOf, normalize, normalizeCard, normalizeGate, normalizeLog, parseConfig, shorten, startConsult } from './fd-core'
6import { type Config } from './fd-core'
7
8// ---------------------------------------------------------------- state
9
10export const fdMeta = atom({ plugin: 'filetree', key: 'fdMeta' } as const, { schemaVersion: SCHEMA_VERSION })
11export const fdMain = atom({ plugin: 'filetree', key: 'fdMain' } as const, DEFAULT_MAIN)
12export const fdUsage = atom({ plugin: 'filetree', key: 'fdUsage' } as const, DEFAULT_USAGE)
13export const fdArchitect = atom({ plugin: 'filetree', key: 'fdArchitect' } as const, DEFAULT_ARCHITECT)
14export const fdGate = atom({ plugin: 'filetree', key: 'fdGate' } as const, DEFAULT_GATE)
15export const fdAgents = atom({ plugin: 'filetree', key: 'fdAgents' } as const, [])
16export const fdLoops = atom({ plugin: 'filetree', key: 'fdLoops' } as const, [])
17export const fdBlocked = atom({ plugin: 'filetree', key: 'fdBlocked' } as const, DEFAULT_BLOCKED)
18export const fdTasks = atom({ plugin: 'filetree', key: 'fdTasks' } as const, [])
19export const fdFiles = atom({ plugin: 'filetree', key: 'fdFiles' } as const, [])
20export const fdLog = atom({ plugin: 'filetree', key: 'fdLog' } as const, [])
21export const fdTurn = atom({ plugin: 'filetree', key: 'fdTurn' } as const, DEFAULT_TURN)
22export const fdReceipt = atom({ plugin: 'filetree', key: 'fdReceipt' } as const, null)
23export const fdView = atom({ plugin: 'filetree', key: 'fdView' } as const, DEFAULT_VIEW)
24export const fdRoster = atom({ plugin: 'filetree', key: 'fdRoster' } as const, DEFAULT_ROSTER)
25
26export type ServerBlock = { type: string; id?: string; name?: string; tool_use_id?: string }
27
28// Every read goes through these, so a value saved under an older shape still reads.
29export async function getMain($: EngineInterface): Promise<Main> {
30 return normalize(DEFAULT_MAIN, await read($, fdMain))
31}
32export async function getUsage($: EngineInterface): Promise<Usage> {
33 return normalize(DEFAULT_USAGE, await read($, fdUsage))
34}
35export async function getArchitect($: EngineInterface): Promise<Architect> {
36 const a = normalize(DEFAULT_ARCHITECT, await read($, fdArchitect))
37 return { ...a, consults: listOf(a.consults), ids: listOf(a.ids), seen: listOf(a.seen) }
38}
39export async function getGate($: EngineInterface): Promise<Gate> {
40 return normalizeGate(await read($, fdGate))
41}
42export async function getCards($: EngineInterface): Promise<AgentCard[]> {
43 return listOf<unknown>(await read($, fdAgents)).map(normalizeCard)
44}
45export async function getLoops($: EngineInterface): Promise<Loop[]> {
46 return listOf<Loop>(await read($, fdLoops))
47}
48export async function getBlocked($: EngineInterface): Promise<BlockedCommand[]> {
49 return normalizeBlocked(await read($, fdBlocked))
50}
51export async function getTasks($: EngineInterface): Promise<BgTask[]> {
52 return listOf<BgTask>(await read($, fdTasks))
53}
54export async function getFiles($: EngineInterface): Promise<FileEvent[]> {
55 return listOf<FileEvent>(await read($, fdFiles))
56}
57export async function getLog($: EngineInterface): Promise<LogLine[]> {
58 return normalizeLog(await read($, fdLog))
59}
60export async function getTurn($: EngineInterface): Promise<Turn> {
61 return normalize(DEFAULT_TURN, await read($, fdTurn))
62}
63export async function getView($: EngineInterface): Promise<View> {
64 return normalize(DEFAULT_VIEW, await read($, fdView))
65}
66export async function getRoster($: EngineInterface): Promise<Roster> {
67 const r = normalize(DEFAULT_ROSTER, await read($, fdRoster))
68 return { architectTypes: listOf(r.architectTypes) }
69}
70
71/** A stored shape older than this build's: drop what cannot be read, keep the rest. */
72export async function migrate($: EngineInterface) {
73 const m = await read($, fdMeta)
74 if ((m?.schemaVersion ?? 0) >= SCHEMA_VERSION) return
75 await update($, fdLog, list => normalizeLog(list))
76 await update($, fdAgents, list => listOf<unknown>(list).map(normalizeCard))
77 await update($, fdGate, g => normalizeGate(g))
78 await update($, fdMeta, () => ({ schemaVersion: SCHEMA_VERSION }))
79}
80
81export async function say($: EngineInterface, who: string, text: string, kind: LogLine['kind'] = 'info', agentId: string | null = null) {
82 const line: LogLine = { at: await $.clock.now(), who, text, kind, agentId }
83 await update($, fdLog, list => [...normalizeLog(list), line].slice(-60))
84}
85
86export async function refreshStatus($: EngineInterface, cfg: Config) {
87 if (!cfg.statusLine) return $.ui.status(undefined)
88 const [u, a, g, cards] = await Promise.all([getUsage($), getArchitect($), getGate($), getCards($)])
89 const running = cards.filter(c => c.status === 'running').length
90 const s = gateSummary(g)
91 const parts = [
92 u.pct !== null ? `ctx ${Math.round(u.pct)}%` : null,
93 cards.length > 0 ? `agents ${running}/${cards.length}` : null,
94 a.consults.length > 0 || a.ids.length > 0 ? `${cfg.architectLabel.toLowerCase()} ${isAdvising(a) ? 'advising' : a.consults.length}` : null,
95 s.deny > 0 ? `denied ${s.deny}` : null,
96 ]
97 // Only fields with something to say; with none, no status entry at all.
98 const shown = parts.filter(Boolean)
99 $.ui.status(shown.length > 0 ? shown.join(' · ') : undefined)
100}
101
102export async function whoIs($: EngineInterface, agentId: string | undefined) {
103 if (!agentId) return 'main'
104 const card = (await getCards($)).find(c => c.id === agentId)
105 return card ? shorten(cardTitle(card), 14) : 'agent'
106}
107
108export async function consultStarted($: EngineInterface, cfg: Config, id: string, via: string) {
109 const t = await getTurn($)
110 const moment = momentOf(t)
111 const at = await $.clock.now()
112 await update($, fdArchitect, a => startConsult(normalize(DEFAULT_ARCHITECT, a), { id, at, moment, via }))
113 if (moment === 'before done') await update($, fdTurn, x => ({ ...normalize(DEFAULT_TURN, x), isReviewing: true }))
114 await say($, cfg.architectLabel.toLowerCase(), cfg.moments ? `${moment} · ${via}` : `consulted · ${via}`, 'consult')
115 await refreshStatus($, cfg)
116}
117
118export async function consultEnded($: EngineInterface, cfg: Config, advice: string | null, id?: string) {
119 const at = await $.clock.now()
120 const first = advice?.split('\n').find(l => l.trim()) ?? null
121 const text = first ? shorten(first.replace(/^[#>*\s-]+/, ''), 160) : null
122 await update($, fdArchitect, a => endConsult(normalize(DEFAULT_ARCHITECT, a), at, text, id))
123 await update($, fdTurn, t => ({ ...normalize(DEFAULT_TURN, t), isReviewing: false }))
124 await say($, cfg.architectLabel.toLowerCase(), text ? `advice: ${shorten(text, 60)}` : 'advice returned', 'consult')
125 await refreshStatus($, cfg)
126}
127
128export async function noteAdvice($: EngineInterface, cfg: Config, advice: string) {
129 await update($, fdArchitect, x => ({ ...normalize(DEFAULT_ARCHITECT, x), lastAdvice: advice }))
130 await say($, cfg.architectLabel.toLowerCase(), `advice: ${shorten(advice, 60)}`, 'consult')
131}
132
133export async function isArchitectType($: EngineInterface, cfg: Config, type: string) {
134 return cfg.architect.test(type) || (await getRoster($)).architectTypes.includes(type)
135}
136
137export async function resetAll($: EngineInterface) {
138 await update($, fdMain, m => ({ ...DEFAULT_MAIN, model: normalize(DEFAULT_MAIN, m).model, mode: normalize(DEFAULT_MAIN, m).mode }))
139 await update($, fdArchitect, () => DEFAULT_ARCHITECT)
140 await update($, fdGate, () => DEFAULT_GATE)
141 await update($, fdAgents, () => [])
142 await update($, fdLoops, () => [])
143 await update($, fdLog, () => [])
144 await update($, fdTasks, () => [])
145 await update($, fdBlocked, () => DEFAULT_BLOCKED)
146 await update($, fdFiles, () => [])
147 await update($, fdTurn, () => DEFAULT_TURN)
148 await update($, fdReceipt, () => null)
149 await update($, fdView, () => DEFAULT_VIEW)
150 // The context gauge waits for the next measurement rather than showing the pre-clear fill.
151 await update($, fdUsage, x => ({ ...normalize(DEFAULT_USAGE, x), pct: null, tokens: null }))
152}
153
154/** The session's cost read fresh, not from the last measurement: the receipt subtracts two of these. */
155export async function costNow($: EngineInterface): Promise<number | null> {
156 const u = await $.session.usage().catch(() => null)
157 return u?.cost?.usd ?? null
158}
159
160export async function noteMode($: EngineInterface, mode: string | undefined) {
161 if (mode) await update($, fdMain, m => (normalize(DEFAULT_MAIN, m).mode === mode ? normalize(DEFAULT_MAIN, m) : { ...normalize(DEFAULT_MAIN, m), mode }))
162}
163
164
165// The tab replaces the standalone pane and the status entry.
166export const configOf = (options: Readonly<Record<string, unknown>>): Config => ({ ...parseConfig(options), openOnStart: false, statusLine: false })
167
168// The validator reads a state write only when the atom is defined in the file that writes it, so other modules write through these.
169export async function updateFdTasks($: EngineInterface, fn: (current: BgTask[]) => BgTask[]): Promise<void> {
170 await update($, fdTasks, fn)
171}
172export async function updateFdFiles($: EngineInterface, fn: (current: FileEvent[]) => FileEvent[]): Promise<void> {
173 await update($, fdFiles, fn)
174}
175export async function updateFdRoster($: EngineInterface, fn: (current: Roster) => Roster): Promise<void> {
176 await update($, fdRoster, fn)
177}
178export async function updateFdTurn($: EngineInterface, fn: (current: Turn) => Turn): Promise<void> {
179 await update($, fdTurn, fn)
180}
181export async function updateFdMain($: EngineInterface, fn: (current: Main) => Main): Promise<void> {
182 await update($, fdMain, fn)
183}
184export async function updateFdAgents($: EngineInterface, fn: (current: AgentCard[]) => AgentCard[]): Promise<void> {
185 await update($, fdAgents, fn)
186}
187export async function updateFdLoops($: EngineInterface, fn: (current: Loop[]) => Loop[]): Promise<void> {
188 await update($, fdLoops, fn)
189}
190export async function updateFdUsage($: EngineInterface, fn: (current: Usage) => Usage): Promise<void> {
191 await update($, fdUsage, fn)
192}
193export async function updateFdGate($: EngineInterface, fn: (current: Gate) => Gate): Promise<void> {
194 await update($, fdGate, fn)
195}
196export async function updateFdArchitect($: EngineInterface, fn: (current: Architect) => Architect): Promise<void> {
197 await update($, fdArchitect, fn)
198}
199export async function updateFdReceipt($: EngineInterface, fn: (current: Receipt | null) => Receipt | null): Promise<void> {
200 await update($, fdReceipt, fn)
201}
202export async function updateFdView($: EngineInterface, fn: (current: View) => View): Promise<void> {
203 await update($, fdView, fn)
204}
205export async function getReceipt($: EngineInterface): Promise<Receipt | null> {
206 return (await read($, fdReceipt)) ?? null
207}
208export async function updateFdBlocked($: EngineInterface, fn: (current: BlockedCommand[]) => BlockedCommand[]): Promise<void> {
209 await update($, fdBlocked, fn)
210}
211hooks/fd-view.tsx 731 lines1// The Flightdeck tab body: every panel, laid out from fd-state's readers and fd-core's reducers.
2import { type EngineInterface } from 'claude-code'
3import { dismissBlockedCommand, runBlocked, sendBlockedToPrompt } from './blocked'
4import { type AgentCard, type BlockedCommand, type Bucket, type Check, type FileEvent, type LogLine } from '../types'
5import { DEFAULT_VIEW, cardTitle, consultTimeline, fitLegend, fmtClock, fmtDuration, fmtTimer, fmtUsd, plural, gateSummary, gauge, isAdvising, isLoopActive, kTokens, lanes, limitLabel, fmtResetIn, fmtSpeed, logRows, normalize, PALETTES, SVG_COLORS, prettyModel, shorten } from './fd-core'
6import { type Panel } from './fd-core'
7import { configOf, getBlocked, getArchitect, getCards, getFiles, getGate, getLog, getLoops, getMain, getReceipt, getTasks, getTurn, getUsage, getView, updateFdView } from './fd-state'
8
9// ---------------------------------------------------------------- drawing
10
11type PaneEvent = Parameters<EngineInterface['ui']['resolve']>[0]
12
13/** What the tab needs from the file tree, passed in so this module never imports register.tsx. */
14export type FlightdeckHost = {
15 treeRoot: () => Promise<string>
16 showFile: (f: FileEvent) => Promise<void>
17 shortPath: (path: string) => string
18}
19
20/** The Flightdeck tab's body. */
21export async function renderFlightdeck($: EngineInterface, e: PaneEvent, options: Readonly<Record<string, unknown>>, host: FlightdeckHost) {
22 const cfg = configOf(options)
23 const C = PALETTES[cfg.palette]
24 const els = $.ui.resolve(e)
25 const { Box, Text, Button } = els
26 const hasClient = 'Client' in els
27 const [m, u, a, g, cards, lp, tasks, blocked, files, treeRoot, lines, t, r, v, now] = await Promise.all([
28 getMain($),
29 getUsage($),
30 getArchitect($),
31 getGate($),
32 getCards($),
33 getLoops($),
34 getTasks($),
35 getBlocked($),
36 getFiles($),
37 host.treeRoot(),
38 getLog($),
39 getTurn($),
40 getReceipt($),
41 getView($),
42 $.clock.now(),
43 ])
44 const W = Math.max(40, e.props.bodyColumns)
45 const layout = v.layout ?? cfg.layout
46 const isWide = layout === 'wide' || (layout === 'auto' && W >= 110)
47 const colW = isWide ? Math.floor((W - 2) / 2) : W
48 const modelName = prettyModel(m.model)
49 const viewed = e.props.view?.agentId ?? null
50 const advising = isAdvising(a)
51 const running = cards.filter(c => c.status === 'running')
52 const showArchitect = a.consults.length > 0 || a.ids.length > 0
53 const motion = cfg.motion && hasClient
54 // A panel with nothing to show yet takes no room: most sessions never spawn an agent.
55 const isEmpty: Record<Panel, boolean> = {
56 blocked: blocked.length === 0,
57 main: false,
58 architect: !showArchitect,
59 gate: g.recent.length === 0 && gateSummary(g).total === 0,
60 agents: cards.length === 0,
61 loops: lp.length === 0,
62 files: files.length === 0,
63 shell: tasks.length === 0,
64 receipt: !m.isRunning && !r,
65 log: false,
66 }
67 const panels = cfg.panels.filter(p => !isEmpty[p])
68 const decider = m.mode === 'auto' ? 'classifier' : 'you'
69
70 // A connector between panels: animated while its flow is live, a dim line otherwise.
71 const rail = (key: string, active: boolean, color: string, width: number, marks: number[] = [], isMerge = false) =>
72 motion ? (
73 <els.Client
74 key={key}
75 module="./fd-rail.tsx"
76 width={width}
77 height={1}
78 props={{ active, width, color, dim: C.faint, marks, isMerge }}
79 />
80 ) : (
81 <Text color={C.faint}>{'─'.repeat(Math.max(1, width))}</Text>
82 )
83
84 // A start time of 0 is unknown (state saved before it was recorded): no clock, not decades.
85 const clock = (key: string, since: number, endAt: number | null, color: string) =>
86 since <= 0 ? (
87 <Text color={color}>—</Text>
88 ) : hasClient ? (
89 <els.Client key={key} module="./fd-elapsed.tsx" props={{ since, now, endAt, color }} />
90 ) : (
91 <Text color={color}>{fmtTimer((endAt ?? now) - since)}</Text>
92 )
93
94 // ---- main
95 const effortN = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }[m.effort] ?? 0
96 const ctxGauge = u.pct !== null ? gauge(u.pct, 10) : null
97 const mainPanel = (w: number) => (
98 <Box flexDirection="column" borderStyle="round" borderColor={C.main} paddingX={1} width={w}>
99 <Box justifyContent="space-between">
100 <Text color={C.main} bold>
101 {modelName} · main
102 </Text>
103 <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? '● working' : '○ idle'}</Text>
104 </Box>
105 <Text wrap="truncate">
106 <Text dimColor>effort </Text>
107 <Text color={C.main}>{'▮'.repeat(effortN) + '▯'.repeat(4 - effortN)} </Text>
108 <Text color={C.main} bold>
109 {m.effort || '—'}
110 </Text>
111 {m.mode ? <Text dimColor>{` mode ${m.mode}`}</Text> : null}
112 <Text dimColor>{` ${m.steps} req`}</Text>
113 </Text>
114 {ctxGauge ? (
115 <Text wrap="truncate">
116 <Text dimColor>ctx </Text>
117 <Text color={u.pct !== null && u.pct >= 80 ? C.warn : C.main}>{ctxGauge.on}</Text>
118 <Text color={C.faint}>{ctxGauge.off}</Text>
119 <Text bold>{` ${Math.round(u.pct ?? 0)}%`}</Text>
120 {u.tokens !== null ? <Text dimColor>{` ${kTokens(u.tokens)}/${kTokens(u.window)}`}</Text> : null}
121 {u.compactions > 0 ? <Text color={C.amber}>{` ⟲${u.compactions}`}</Text> : null}
122 </Text>
123 ) : null}
124 {u.costUsd !== null || u.limits.length > 0 ? (
125 <Text wrap="truncate">
126 {u.costUsd !== null ? <Text color={C.text}>{`${fmtUsd(u.costUsd)} `}</Text> : null}
127 {u.limits.slice(0, 2).map(l => {
128 const lg = gauge(l.pct, 5)
129 // The time to each reset where the line has room for it: both windows fit from 56 columns,
130 // so a wide pane's half column (59 at 120) has them and a 40-column pane keeps its gauges.
131 const resetIn = w >= 56 ? fmtResetIn(l.resetsAt, now) : ''
132 return (
133 <Text wrap="truncate">
134 <Text dimColor>{`${limitLabel(l.kind)} `}</Text>
135 <Text color={l.pct >= 80 ? C.warn : C.main}>{lg.on}</Text>
136 <Text color={C.faint}>{lg.off}</Text>
137 <Text dimColor>{` ${Math.round(l.pct)}%${resetIn ? ` ↻${resetIn}` : ''} `}</Text>
138 </Text>
139 )
140 })}
141 </Text>
142 ) : null}
143 {m.speed ? <Text dimColor wrap="truncate">{`last req ${fmtSpeed(m.speed)}`}</Text> : null}
144 </Box>
145 )
146
147 // ---- architect
148 const lastConsult = a.consults[a.consults.length - 1]
149 const architectPanel = (w: number) => {
150 const tl = consultTimeline(a, now, Math.max(8, w - 4))
151 return (
152 <Box flexDirection="column" borderStyle="round" borderColor={C.arch} paddingX={1} width={w}>
153 <Box justifyContent="space-between">
154 <Text color={C.arch} bold>
155 {cfg.architectLabel} · {advising ? 'advising' : 'on call'}
156 </Text>
157 <Text>
158 <Text dimColor>consults </Text>
159 <Text color={C.arch} bold>
160 {a.consults.length}
161 </Text>
162 </Text>
163 </Box>
164 <Text color={C.arch}>{tl}</Text>
165 {lastConsult ? (
166 <Text dimColor wrap="truncate">
167 {advising
168 ? `consulting since ${fmtClock(lastConsult.at)}`
169 : `last ${fmtDuration(now - (lastConsult.endAt ?? lastConsult.at))} ago · took ${fmtDuration((lastConsult.endAt ?? now) - lastConsult.at)}`}
170 </Text>
171 ) : (
172 <Text dimColor>not consulted yet</Text>
173 )}
174 {cfg.moments ? (
175 <Box flexWrap="wrap" columnGap={2}>
176 {(['before a plan', 'error repeats', 'before done'] as const).map(mo => {
177 const isOn = lastConsult?.moment === mo
178 return (
179 <Text color={isOn ? C.arch : C.dim} bold={isOn}>
180 {isOn ? '◆' : '◇'} {mo}
181 </Text>
182 )
183 })}
184 <Text color={C.faint}>(inferred)</Text>
185 </Box>
186 ) : null}
187 {a.lastAdvice ? (
188 <Text color={C.arch} wrap="truncate">
189 » {a.lastAdvice}
190 </Text>
191 ) : null}
192 </Box>
193 )
194 }
195
196 // ---- gate
197 const s = gateSummary(g)
198 const verdictColor = (c: Check) =>
199 c.verdict === 'rule' ? C.gate : c.verdict === 'cleared' ? C.cleared : c.verdict === 'ask' ? C.amber : C.warn
200 const gatePanel = (w: number) => {
201 const strip = g.recent.slice(-Math.max(8, w - 4))
202 const open = v.gateOpen
203 return (
204 <Box flexDirection="column" borderStyle="round" borderColor={C.gate} paddingX={1} width={w}>
205 <Box justifyContent="space-between">
206 <Text color={C.gate} bold>
207 {cfg.gateLabel} · permissions
208 </Text>
209 <Text dimColor>{`${s.total} checks`}</Text>
210 </Box>
211 <Box>
212 {strip.length === 0 ? <Text color={C.faint}>no checks yet</Text> : null}
213 {strip.map(c => (
214 <Text color={verdictColor(c)} dimColor={c.inSubagent}>
215 {c.verdict === 'deny' ? '✗' : '■'}
216 </Text>
217 ))}
218 </Box>
219 <Text wrap="truncate">
220 <Text color={C.gate}>■</Text>
221 <Text dimColor>{` ${s.rule} allowed `}</Text>
222 <Text color={C.cleared}>■</Text>
223 <Text dimColor>{` ${s.cleared} ${decider} `}</Text>
224 {s.ask > 0 ? <Text color={C.amber}>{`■ ${s.ask} pending `}</Text> : null}
225 <Text color={s.deny > 0 ? C.warn : C.dim}>{`✗ ${s.deny} denied`}</Text>
226 {w >= 80 && g.recent.some(c => c.inSubagent) ? <Text color={C.faint}>{' dim: in subagents'}</Text> : null}
227 </Text>
228 <Box columnGap={2}>
229 {(['file', 'shell', 'other'] as const).map((b: Bucket) => {
230 const tl = g.totals[b]
231 const n = tl.rule + tl.ask + tl.cleared + tl.deny
232 return (
233 <Button
234 key={`gate-${b}`}
235 plain
236 hotkey={b[0]}
237 label={`${b} ${n}${open === b ? ' ▾' : ''}`}
238 dimColor={n === 0}
239 onPress={() => updateFdView($, x => ({ ...normalize(DEFAULT_VIEW, x), gateOpen: normalize(DEFAULT_VIEW, x).gateOpen === b ? null : b }))}
240 />
241 )
242 })}
243 </Box>
244 {open
245 ? g.recent
246 .filter(c => c.bucket === open)
247 .slice(-5)
248 .map(c => (
249 <Text wrap="truncate">
250 <Text color={verdictColor(c)}>{c.verdict === 'deny' ? '✗ ' : '■ '}</Text>
251 <Text color={C.dim}>{`${(c.verdict === 'rule' ? 'allowed' : c.verdict === 'cleared' ? decider : c.verdict === 'ask' ? 'pending' : 'denied').padEnd(10)} `}</Text>
252 <Text dimColor={c.inSubagent}>{shorten(c.detail, Math.max(10, w - 18))}</Text>
253 </Text>
254 ))
255 : null}
256 </Box>
257 )
258 }
259
260 // ---- agents: cards up to the limit, swimlanes beyond it
261 const statusColor = (c: AgentCard) => (c.status === 'failed' ? C.warn : c.status === 'done' ? C.gate : C.agent)
262 const glyph = (c: AgentCard) => (c.status === 'running' ? '◐' : c.status === 'done' ? '✓' : c.status === 'failed' ? '✗' : '■')
263 const expandOnPress = (id: string) => () =>
264 updateFdView($, x => ({ ...normalize(DEFAULT_VIEW, x), expanded: normalize(DEFAULT_VIEW, x).expanded === id ? null : id }))
265
266 const agentsPanel = (w: number) => {
267 const shown = cards.slice(-6)
268 // Inner width after the border and padding. Fixed columns: glyph, type, clock; the detail and the title (hotkey hint "1: " included) share the rest.
269 const inner = w - 4
270 const clockW = 7
271 const typeW = Math.min(12, shown.reduce((n, c) => Math.max(n, c.type.length), 4) + 2)
272 const detailW = inner >= 62 ? Math.min(32, inner - 2 - typeW - clockW - 24) : 0
273 const titleW = inner - 2 - typeW - detailW - clockW
274 const detailOf = (c: AgentCard): { text: string; color: string } => {
275 if (c.lastStop === 'max_tokens') return { text: 'max_tokens', color: C.warn }
276 if (c.status === 'running') {
277 const t = c.tools[c.tools.length - 1]
278 return t ? { text: t.text, color: t.isError ? C.warn : C.text } : { text: 'starting…', color: C.faint }
279 }
280 return { text: c.steps > 0 ? `${kTokens(c.ctx)} ctx · ${kTokens(c.out)} out · ${plural(c.steps, 'step')}` : '', color: C.faint }
281 }
282 return (
283 <Box flexDirection="column" borderStyle="round" borderColor={C.agent} borderDimColor={running.length === 0} paddingX={1} width={w}>
284 <Box justifyContent="space-between">
285 <Text color={C.agent} bold>
286 {`agents · ${running.length} running`}
287 </Text>
288 <Text dimColor>{`${cards.length} total${cards.length > 0 ? ' · 1-' + shown.length + ' expand' : ''}`}</Text>
289 </Box>
290 {cards.length > shown.length ? <Text color={C.faint}>{`+${cards.length - shown.length} earlier`}</Text> : null}
291 {shown.map((c, i) => {
292 const isViewed = viewed === c.id
293 const live = c.status === 'running'
294 const d = detailOf(c)
295 return (
296 <Box flexDirection="row" height={1} overflow="hidden">
297 <Box width={2} flexShrink={0}>
298 <Text color={c.lastStop === 'max_tokens' ? C.warn : statusColor(c)} bold={isViewed}>{isViewed ? '▶' : glyph(c)}</Text>
299 </Box>
300 <Box width={titleW} flexShrink={0}>
301 <Button key={`card-${c.id}`} plain hotkey={String(i + 1)} label={shorten(cardTitle(c), Math.max(4, titleW - 3))} onPress={expandOnPress(c.id)} />
302 </Box>
303 <Box width={typeW} flexShrink={0}>
304 <Text color={live ? C.agent : C.faint} wrap="truncate">
305 {c.type}
306 </Text>
307 </Box>
308 {detailW > 0 ? (
309 <Box width={detailW} flexShrink={0}>
310 <Text color={d.color} wrap="truncate">
311 {d.text}
312 </Text>
313 </Box>
314 ) : null}
315 <Box width={clockW} flexShrink={0} justifyContent="flex-end">
316 {clock(`card-clock-${c.id}`, c.spawnedAt, c.endedAt, live ? C.text : C.faint)}
317 </Box>
318 </Box>
319 )
320 })}
321 </Box>
322 )
323 }
324
325 const expandedCard = cards.find(c => c.id === v.expanded)
326 const expandedPanel = (w: number) =>
327 expandedCard ? (
328 <Box flexDirection="column" borderStyle="single" borderColor={C.agent} paddingX={1} width={w}>
329 <Text bold wrap="wrap">
330 {expandedCard.description || expandedCard.type}
331 </Text>
332 <Text dimColor wrap="truncate">{`${expandedCard.type} · ${prettyModel(expandedCard.model)} · ${expandedCard.status} · ${expandedCard.steps} steps`}</Text>
333 {expandedCard.tools.length === 0 ? <Text color={C.faint}>no tool calls yet</Text> : null}
334 {expandedCard.tools.map(n => (
335 <Text color={n.isError ? C.warn : C.text} wrap="truncate">
336 {`${n.isError ? '✗' : '·'} ${n.text}`}
337 </Text>
338 ))}
339 {expandedCard.answer ? (
340 <Text dimColor wrap="wrap">
341 {`» ${shorten(expandedCard.answer, 240)}`}
342 </Text>
343 ) : null}
344 </Box>
345 ) : null
346
347 // ---- other loops (workflow agents, forks): ids that match no card
348 const loopsPanel = (w: number) => {
349 if (lp.length === 0) return null
350 const active = lp.filter(l => isLoopActive(l, now)).length
351 const dots = lp.slice(-Math.max(4, w - 38))
352 return (
353 <Box width={w}>
354 <Text bold>other loops </Text>
355 <Text dimColor>{`${lp.length} seen · ${active} active `}</Text>
356 {dots.map(l => (
357 <Text color={isLoopActive(l, now) ? C.agent : l.isDone ? C.dim : C.faint}>{isLoopActive(l, now) ? '●' : l.isDone ? '✓' : '○'}</Text>
358 ))}
359 </Box>
360 )
361 }
362
363 // ---- files: what Claude read, wrote and committed, newest first
364 const filesPanel = (w: number) => {
365 const root = treeRoot
366 const open = v.filesOpen
367 const strip = files.slice(-Math.max(8, w - 4))
368 const count = (k: FileEvent['kind']) => files.filter(f => f.kind === k).length
369 const shown = open ? files.filter(f => f.kind === open).slice(-6).reverse() : []
370 const color = (k: FileEvent['kind']) => (k === 'read' ? C.arch : k === 'write' ? C.amber : C.gate)
371 const toggle = (k: FileEvent['kind']) => updateFdView($, x => ({ ...normalize(DEFAULT_VIEW, x), filesOpen: normalize(DEFAULT_VIEW, x).filesOpen === k ? null : k }))
372 return (
373 <Box flexDirection="column" borderStyle="round" borderColor={C.amber} paddingX={1} width={w}>
374 <Box justifyContent="space-between">
375 <Text color={C.amber} bold>
376 files · claude
377 </Text>
378 <Text dimColor>{`${files.length} touched`}</Text>
379 </Box>
380 <Box>
381 {strip.map(f => (
382 <Text color={color(f.kind)}>■</Text>
383 ))}
384 </Box>
385 <Box columnGap={2}>
386 {(['read', 'write', 'git'] as const).map(k => (
387 <Box>
388 <Text color={color(k)}>■ </Text>
389 <Button key={`files-${k}`} plain label={`${k === 'write' ? 'written' : k} ${count(k)}${open === k ? ' ▾' : ''}`} dimColor={count(k) === 0} onPress={() => toggle(k)} />
390 </Box>
391 ))}
392 </Box>
393 {shown.map(f => (
394 <Box>
395 <Box width={9} flexShrink={0}>
396 <Text color={C.faint}>{fmtClock(f.at)}</Text>
397 </Box>
398 {f.kind === 'git' && f.verb ? (
399 <Box width={8} flexShrink={0}>
400 <Text color={color(f.kind)}>{f.verb}</Text>
401 </Box>
402 ) : null}
403 <Button
404 key={`file-${f.kind}-${f.path}`}
405 plain
406 label={shorten(root && f.path.startsWith(`${root}/`) ? f.path.slice(root.length + 1) : host.shortPath(f.path), Math.max(8, w - 22))}
407 onPress={() => void host.showFile(f)}
408 />
409 </Box>
410 ))}
411 </Box>
412 )
413 }
414
415 // ---- blocked: a command the engine refused, held for the user to run themselves
416 const blockedButtons = (b: BlockedCommand) => (
417 <Box columnGap={2}>
418 {b.status === 'waiting' ? <Button key={`run-${b.id}`} label="Run it" hotkey="y" variant="secondary" onPress={() => void runBlocked($, b.id)} /> : null}
419 {b.status === 'done' || b.status === 'failed' ? <Button key={`send-${b.id}`} label="Send to prompt" hotkey="p" variant="secondary" onPress={() => void sendBlockedToPrompt($, b.id)} /> : null}
420 {b.status !== 'running' ? <Button key={`dismiss-${b.id}`} label="Dismiss" hotkey="n" variant="primary" autoFocus onPress={() => void dismissBlockedCommand($, b.id)} /> : null}
421 </Box>
422 )
423 const blockedPanel = (w: number) => {
424 const b = blocked[0]
425 if (!b) return null
426 const title = b.status === 'waiting' ? 'command blocked · run it yourself?' : b.status === 'running' ? 'running…' : `ran it · exit ${b.exitCode ?? '?'}`
427 return (
428 <Box flexDirection="column" borderStyle="double" borderColor={b.status === 'failed' ? C.warn : b.status === 'done' ? C.gate : C.amber} paddingX={1} width={w}>
429 <Box justifyContent="space-between">
430 <Text color={C.amber} bold>
431 {title}
432 </Text>
433 <Text dimColor>{blocked.length > 1 ? `1 of ${blocked.length}` : ''}</Text>
434 </Box>
435 {b.reason ? (
436 <Text dimColor wrap="wrap">
437 {b.reason}
438 </Text>
439 ) : null}
440 <Box flexDirection="column">
441 {b.command.split('\n').map(line => (
442 <Text wrap="wrap">{line === '' ? ' ' : `${b.status === 'waiting' ? '' : '$ '}${line}`}</Text>
443 ))}
444 </Box>
445 {b.output !== '' ? (
446 <Box flexDirection="column">
447 {b.output.split('\n').map(line => (
448 <Text color={C.dim} wrap="wrap">
449 {line === '' ? ' ' : line}
450 </Text>
451 ))}
452 </Box>
453 ) : null}
454 {blockedButtons(b)}
455 </Box>
456 )
457 }
458
459 // ---- shell: background shells and monitors, running first
460 const shellPanel = (w: number) => {
461 const live = tasks.filter(x => x.endedAt === null)
462 const shown = [...live, ...tasks.filter(x => x.endedAt !== null).reverse()].slice(0, 6)
463 return (
464 <Box flexDirection="column" borderStyle="round" borderColor={C.agent} borderDimColor={live.length === 0} paddingX={1} width={w}>
465 <Box justifyContent="space-between">
466 <Text color={C.agent} bold>
467 shell · background
468 </Text>
469 <Text dimColor>{`${live.length} running`}</Text>
470 </Box>
471 {shown.map(x => (
472 <Box>
473 <Box width={2} flexShrink={0}>
474 <Text color={x.endedAt === null ? C.agent : x.status === 'completed' ? C.gate : C.warn}>{x.endedAt === null ? '●' : x.status === 'completed' ? '✓' : '✗'}</Text>
475 </Box>
476 <Box width={9} flexShrink={0}>
477 <Text color={C.dim}>{x.kind}</Text>
478 </Box>
479 <Box width={8} flexShrink={0}>
480 <Text color={C.faint}>{fmtDuration((x.endedAt ?? now) - x.startedAt)}</Text>
481 </Box>
482 <Text color={x.endedAt === null ? C.text : C.dim} wrap="truncate">
483 {x.label}
484 </Text>
485 </Box>
486 ))}
487 </Box>
488 )
489 }
490
491 // ---- receipt: the turn now, or the last one
492 const receiptPanel = (w: number) => {
493 const isReview = t.isReviewing
494 return (
495 <Box flexDirection="column" borderStyle="round" borderColor={C.main} borderDimColor={!m.isRunning && !isReview} paddingX={1} width={w}>
496 {m.isRunning ? (
497 <Box>
498 <Text color={C.main} wrap="truncate">{`◐ back to ${modelName.toLowerCase()} · turn `}</Text>
499 <Box flexShrink={0}>{clock('turn-clock', t.startedAt, null, C.main)}</Box>
500 {w >= 60 ? <Text dimColor wrap="truncate">{` · ${plural(t.edits, 'edit')} · ${plural(t.errors, 'error')}`}</Text> : null}
501 </Box>
502 ) : r ? (
503 <Text wrap="truncate">
504 <Text color={r.reason === 'answer' ? C.gate : C.warn}>{r.reason === 'answer' ? '✓ ' : '✗ '}</Text>
505 <Text>{`last turn ${fmtDuration(r.durationMs)} · ${plural(r.agents, 'agent')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}`}</Text>
506 {r.costDelta !== null ? <Text color={C.main}>{` · +${fmtUsd(r.costDelta)}`}</Text> : null}
507 </Text>
508 ) : (
509 <Text color={C.faint}>no turn finished yet</Text>
510 )}
511 {isReview ? <Text color={C.arch}>{`${cfg.architectLabel.toLowerCase()} reviewing before done (inferred)`}</Text> : null}
512 </Box>
513 )
514 }
515
516 // ---- log: whatever rows the other panels leave, 4 to 8
517 // The main box's speed line and the gap above the log are a row each.
518 const used = 2 + 5 + (m.speed ? 1 : 0) + 1 + (showArchitect ? 6 : 0) + 6 + (v.gateOpen ? 5 : 0) + (cards.length ? 3 + Math.min(6, cards.length) : 0) + (expandedCard ? 8 : 0) + (lp.length ? 1 : 0) + (blocked[0] ? 6 + Math.min(10, blocked[0].command.split('\n').length) + Math.min(15, blocked[0].output.split('\n').length) : 0) + (files.length ? 5 + (v.filesOpen ? Math.min(6, files.filter(f => f.kind === v.filesOpen).length) : 0) : 0) + (tasks.length ? 3 + Math.min(6, tasks.length) : 0) + 3
519 const bodyRows = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 40
520 const nLog = logRows(bodyRows, used)
521 const shownLines = (viewed ? lines.filter(l => l.agentId === viewed) : lines).slice(-nLog)
522 const colorOf = (l: LogLine) =>
523 l.kind === 'error' ? C.warn : l.kind === 'consult' ? C.arch : l.who === 'main' ? C.main : l.who === 'gate' ? C.gate : l.who === 'you' ? C.text : C.agent
524 const logPanel = (w: number) => (
525 <Box flexDirection="column" borderStyle="round" borderColor={C.faint} paddingX={1} marginTop={1} width={w}>
526 <Text dimColor>{viewed ? 'session log · this agent' : 'session log'}</Text>
527 {shownLines.length === 0 ? <Text color={C.faint}>nothing yet</Text> : null}
528 {shownLines.map(l => (
529 <Box>
530 <Box width={9} flexShrink={0}>
531 <Text color={C.faint}>{fmtClock(l.at)}</Text>
532 </Box>
533 <Box width={13} flexShrink={0}>
534 <Text color={colorOf(l)} bold wrap="truncate">
535 {l.who}
536 </Text>
537 </Box>
538 <Text color={l.kind === 'error' ? C.warn : C.text} wrap="truncate">
539 {l.text}
540 </Text>
541 </Box>
542 ))}
543 </Box>
544 )
545
546 const draw = (p: Panel, w: number) =>
547 p === 'blocked'
548 ? blockedPanel(w)
549 : p === 'main'
550 ? mainPanel(w)
551 : p === 'architect'
552 ? architectPanel(w)
553 : p === 'gate'
554 ? gatePanel(w)
555 : p === 'agents'
556 ? agentsPanel(w)
557 : p === 'loops'
558 ? loopsPanel(w)
559 : p === 'files'
560 ? filesPanel(w)
561 : p === 'shell'
562 ? shellPanel(w)
563 : p === 'receipt'
564 ? receiptPanel(w)
565 : logPanel(w)
566
567 // Panels with the flow between them; the agents panel draws its own rails.
568 const column = (ps: Panel[], w: number) => (
569 <Box flexDirection="column" width={w}>
570 {ps.map((p, i) => {
571 const prev = ps[i - 1]
572 const link =
573 i === 0 || p === 'agents' || prev === 'agents' || p === 'log' || p === 'loops' || prev === 'loops' || p === 'files' || prev === 'files' || p === 'shell' || prev === 'shell' || p === 'receipt' || prev === 'receipt' || p === 'blocked' || prev === 'blocked'
574 ? null
575 : rail(`link-${p}`, p === 'architect' ? advising : m.isRunning, p === 'architect' ? C.arch : C.main, w)
576 return (
577 <Box flexDirection="column">
578 {link}
579 {draw(p, w)}
580 {p === 'agents' ? expandedPanel(w) : null}
581 </Box>
582 )
583 })}
584 </Box>
585 )
586
587 // Desktop draws the agents' time axis as SVG under the panels.
588 const svgLanes =
589 e.surface !== 'terminal' && cards.length > 0
590 ? (() => {
591 const { Svg } = $.ui.resolve(e)
592 const rowH = 18
593 const pxW = 520
594 const geo = lanes(cards.slice(-10), now, 100)
595 const rects = cards
596 .slice(-10)
597 .map((c, i) => {
598 const gm = geo[i]
599 const x = 150 + ((gm?.before ?? 0) / 100) * (pxW - 160)
600 const wpx = Math.max(3, ((gm?.bar ?? 1) / 100) * (pxW - 160))
601 const fill = c.status === 'running' ? SVG_COLORS.running : c.status === 'done' ? SVG_COLORS.done : c.status === 'failed' ? SVG_COLORS.failed : SVG_COLORS.other
602 const label = shorten(cardTitle(c), 22).replace(/[<&>]/g, '')
603 return `<text x="4" y="${i * rowH + 13}" font-size="11" fill="${SVG_COLORS.label}">${label}</text><rect x="${x}" y="${i * rowH + 4}" width="${wpx}" height="10" rx="3" fill="${fill}"/>`
604 })
605 .join('')
606 const svgH = Math.min(10, cards.length) * rowH + 4
607 return (
608 <Svg
609 source={`<svg xmlns="http://www.w3.org/2000/svg" width="${pxW}" height="${svgH}" viewBox="0 0 ${pxW} ${svgH}">${rects}</svg>`}
610 alt={`${cards.length} agents on a time axis`}
611 width={pxW}
612 height={svgH}
613 />
614 )
615 })()
616 : null
617
618 // Inline above the prompt (the terminal's main screen), the pane is a summary of at most 8 rows.
619 const isMini = layout === 'mini' || (layout === 'auto' && e.props.placement === 'inline')
620 if (isMini) {
621 const live = [...cards.filter(c => c.status === 'running'), ...cards.filter(c => c.status !== 'running').reverse()].slice(0, 3)
622 const counts = ` ${s.rule} allowed · ${s.cleared} ${decider}${s.ask > 0 ? ` · ${s.ask} pending` : ''} · ${s.deny} denied`
623 const strip = g.recent.slice(-Math.max(4, W - cfg.gateLabel.length - 1 - counts.length))
624 const mg = u.pct !== null ? gauge(u.pct, 6) : null
625 const waiting = blocked[0]
626 return (
627 <Box flexDirection="column" width={W}>
628 {waiting ? (
629 <Text color={C.warn} bold wrap="truncate">
630 {`⚠ command blocked: ${shorten(waiting.command.replace(/\s+/g, ' '), Math.max(10, W - 24))}`}
631 </Text>
632 ) : null}
633 {waiting ? blockedButtons(waiting) : null}
634 <Text wrap="truncate">
635 <Text color={C.main} bold>
636 {modelName}
637 </Text>
638 <Text color={m.isRunning ? C.main : C.dim}>{m.isRunning ? ' ● working' : ' ○ idle'}</Text>
639 {mg ? <Text dimColor> · ctx </Text> : null}
640 {mg ? <Text color={(u.pct ?? 0) >= 80 ? C.warn : C.main}>{mg.on}</Text> : null}
641 {mg ? <Text color={C.faint}>{mg.off}</Text> : null}
642 {mg ? <Text>{` ${Math.round(u.pct ?? 0)}%`}</Text> : null}
643 {u.compactions > 0 ? <Text color={C.amber}>{` ⟲${u.compactions}`}</Text> : null}
644 {u.costUsd !== null ? <Text dimColor>{` · ${fmtUsd(u.costUsd)}`}</Text> : null}
645 {showArchitect ? <Text color={C.arch}>{` · ${cfg.architectLabel.toLowerCase()} ${advising ? 'advising' : a.consults.length}`}</Text> : null}
646 </Text>
647 {strip.length > 0 ? (
648 <Box>
649 <Text dimColor>{`${cfg.gateLabel.toLowerCase()} `}</Text>
650 {strip.map(c => (
651 <Text color={verdictColor(c)} dimColor={c.inSubagent}>
652 {c.verdict === 'deny' ? '✗' : '■'}
653 </Text>
654 ))}
655 <Text color={s.deny > 0 ? C.warn : s.ask > 0 ? C.amber : C.dim} wrap="truncate">
656 {counts}
657 </Text>
658 </Box>
659 ) : null}
660 {live.map(c => (
661 <Box>
662 <Text color={statusColor(c)}>{`${glyph(c)} `}</Text>
663 <Box width={Math.max(10, W - 30)}>
664 <Text wrap="truncate">{cardTitle(c)}</Text>
665 </Box>
666 <Text dimColor>{c.steps > 0 ? ` ctx ${kTokens(c.ctx)} ` : ' '}</Text>
667 {clock(`mini-clock-${c.id}`, c.spawnedAt, c.endedAt, C.dim)}
668 </Box>
669 ))}
670 {cards.length > live.length ? (
671 <Text color={C.faint} wrap="truncate">{`+${cards.length - live.length} more agents · /flightdeck layout compact for all`}</Text>
672 ) : null}
673 {lp.length > 0 ? <Text dimColor>{`other loops ${lp.length} · ${lp.filter(l => isLoopActive(l, now)).length} active`}</Text> : null}
674 {!m.isRunning && r ? (
675 <Text dimColor wrap="truncate">
676 {`last turn ${fmtDuration(r.durationMs)} · ${plural(r.agents, 'agent')} · ${plural(r.edits, 'edit')} · ${plural(r.errors, 'error')}${r.costDelta !== null ? ` · +${fmtUsd(r.costDelta)}` : ''}`}
677 </Text>
678 ) : null}
679 </Box>
680 )
681 }
682
683 const legend = fitLegend(
684 [
685 { label: 'main', color: C.main },
686 { label: 'agents', color: C.agent },
687 { label: cfg.gateLabel.toLowerCase(), color: C.gate },
688 ...(showArchitect ? [{ label: cfg.architectLabel.toLowerCase(), color: C.arch }] : []),
689 ],
690 W,
691 )
692
693 const body = isWide ? (
694 <Box flexDirection="column">
695 <Box columnGap={2}>
696 {column(panels.filter(p => p === 'blocked' || p === 'receipt' || p === 'main' || p === 'architect' || p === 'gate' || p === 'files'), colW)}
697 {column(panels.filter(p => p === 'agents' || p === 'shell' || p === 'loops'), colW)}
698 </Box>
699 {panels.includes('log') ? logPanel(W) : null}
700 </Box>
701 ) : (
702 column(panels, W)
703 )
704
705 return (
706 <Box flexDirection="column" width={W}>
707 <Box justifyContent="center">
708 <Text bold wrap="truncate">
709 <Text>FLIGHTDECK</Text>
710 <Text color={C.dim}> · </Text>
711 <Text color={C.main}>{modelName.toUpperCase()}</Text>
712 <Text>{m.isRunning ? ' WORKS' : ' IDLE'}</Text>
713 {showArchitect ? <Text color={C.dim}> · </Text> : null}
714 {showArchitect ? <Text color={C.arch}>{cfg.architectLabel}</Text> : null}
715 {showArchitect ? <Text>{advising ? ' ADVISING' : ' ON CALL'}</Text> : null}
716 </Text>
717 </Box>
718 <Box justifyContent="center" columnGap={2}>
719 {legend.map(l => (
720 <Text>
721 <Text color={l.color}>■</Text>
722 <Text dimColor>{` ${l.label}`}</Text>
723 </Text>
724 ))}
725 </Box>
726 {body}
727 {svgLanes}
728 </Box>
729 )
730}
731hooks/config.ts 40 lines1// The mod's `userConfig` values, parsed in one place: an unknown or mistyped value takes the default.
2export type ViewerLayout = 'auto' | 'wide' | 'narrow'
3
4export type Options = {
5 glyphs: string
6 showReads: boolean
7 showWrites: boolean
8 accordion: boolean
9 pinRoot: boolean
10 showBranch: boolean
11 sizeDefault: boolean
12 viewerLayout: ViewerLayout
13 approval: boolean
14}
15
16type Raw = Readonly<Record<string, unknown>> | undefined
17
18const pick = <T extends string>(raw: Raw, key: string, allowed: readonly T[], fallback: T): T => {
19 const v = raw?.[key]
20 return typeof v === 'string' && (allowed as readonly string[]).includes(v) ? (v as T) : fallback
21}
22
23export const parseOptions = (raw: Raw): Options => {
24 const activity = pick(raw, 'activity', ['reads and writes', 'writes', 'reads', 'none'], 'reads and writes')
25 return {
26 glyphs: pick(raw, 'glyphs', ['auto', 'nerd', 'plain'], 'auto'),
27 showReads: activity.includes('reads'),
28 showWrites: activity.includes('writes'),
29 accordion: pick(raw, 'accordion', ['on', 'off'], 'on') === 'on',
30 pinRoot: pick(raw, 'root', ['launch', 'cwd'], 'launch') === 'launch',
31 showBranch: pick(raw, 'branch', ['on', 'off'], 'off') === 'on',
32 sizeDefault: pick(raw, 'column', ['date', 'size'], 'date') === 'size',
33 viewerLayout: pick(raw, 'viewerLayout', ['auto', 'wide', 'narrow'], 'auto'),
34 approval: pick(raw, 'approval', ['on', 'off'], 'on') === 'on',
35 }
36}
37
38/** Header buttons collapse to their hotkeys on a narrow viewer. */
39export const isCompact = (layout: ViewerLayout, columns: number): boolean => layout === 'narrow' || (layout === 'auto' && columns < 60)
40hooks/md.ts 69 lines1// Pure markdown helpers for the preview: nothing here touches `$`, so each is tested directly.
2// `scroll` blocks (mermaid, code fences, tables) slide sideways; `markdown` blocks keep the engine's own wrapping
3export type Segment = { kind: 'markdown' | 'mermaid' | 'code' | 'table'; text: string; language?: string }
4
5export const splitBlocks = (text: string): Segment[] => {
6 const segments: Segment[] = []
7 let prose: string[] = []
8 const flush = (): void => {
9 if (prose.length > 0) segments.push({ kind: 'markdown', text: prose.join('\n') })
10 prose = []
11 }
12 const lines = text.split('\n')
13 for (let i = 0; i < lines.length; i++) {
14 const line = lines[i] ?? ''
15 const fence = /^\s*```\s*([\w+-]*)/.exec(line)
16 if (fence) {
17 const body: string[] = []
18 i++
19 while (i < lines.length && !/^\s*```\s*$/.test(lines[i] ?? '')) body.push(lines[i++] ?? '')
20 flush()
21 const language = fence[1] ?? ''
22 segments.push(language === 'mermaid' ? { kind: 'mermaid', text: body.join('\n') } : { kind: 'code', text: body.join('\n'), language })
23 } else if (/^\s*\|.*\|\s*$/.test(line)) {
24 const rows: string[] = []
25 while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i] ?? '')) rows.push(lines[i++] ?? '')
26 i--
27 flush()
28 segments.push({ kind: 'table', text: rows.join('\n') })
29 } else prose.push(line)
30 }
31 flush()
32
33 return segments
34}
35
36// Markdown table -> box-drawing grid; column alignment comes from the `:---:` row, inline markup is stripped
37export const formatTable = (text: string): string => {
38 const cells = (row: string): string[] =>
39 row
40 .trim()
41 .replace(/^\||\|$/g, '')
42 .split('|')
43 .map(c => c.trim().replace(/\*\*(.+?)\*\*|__(.+?)__/g, '$1$2').replace(/`([^`]+)`/g, '$1'))
44 const rows = text.split('\n').map(cells)
45 const rule = rows[1]
46 const hasRule = rule !== undefined && rule.every(c => /^:?-{1,}:?$/.test(c))
47 const align = (hasRule ? rule : []).map(c => (c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left'))
48 const body = hasRule ? [rows[0] ?? [], ...rows.slice(2)] : rows
49 const count = body.reduce((n, r) => Math.max(n, r.length), 0)
50 const widths = Array.from({ length: count }, (_, c) => body.reduce((n, r) => Math.max(n, (r[c] ?? '').length), 1))
51 const pad = (value: string, c: number): string => {
52 const room = (widths[c] ?? 1) - value.length
53 const how = align[c] ?? 'left'
54 if (how === 'right') return ' '.repeat(room) + value
55 if (how === 'center') return ' '.repeat(Math.floor(room / 2)) + value + ' '.repeat(room - Math.floor(room / 2))
56 return value + ' '.repeat(room)
57 }
58 const line = (l: string, m: string, r: string): string => l + widths.map(w => '─'.repeat(w + 2)).join(m) + r
59 const row = (r: string[]): string => '│ ' + widths.map((_, c) => pad(r[c] ?? '', c)).join(' │ ') + ' │'
60 const out = [line('┌', '┬', '┐')]
61 body.forEach((r, i) => {
62 out.push(row(r))
63 if (i === 0 && body.length > 1) out.push(line('├', '┼', '┤'))
64 })
65 out.push(line('└', '┴', '┘'))
66
67 return out.join('\n')
68}
69hooks/pure.ts 54 lines1// `$`-free helpers lifted out of register.tsx so each one is tested directly.
2import type { GitAction } from './git'
3import type { Seg } from './rows'
4import { inside } from './tree'
5import type { FileTree } from '../types'
6
7export type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
8export type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
9
10/** Drops the keys a row segment leaves undefined, so the renderer sees only what is set. */
11export function clean(segs: Seg[]): Seg[] {
12 for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
13 return segs
14}
15
16/** A dimmed variant of a `#rrggbb` colour: 30% of the way from the dark background (#262626) to the colour. */
17export function faint(hex: string): string {
18 const v = parseInt(hex.slice(1), 16)
19 const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
20 return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
21}
22
23/** Directories that are loaded and open: the ones whose children are on screen. */
24export function openDirs(t: FileTree): string[] {
25 const open = new Set(t.expanded)
26 return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
27}
28
29/** Every action of a Bash call marked failed. */
30export function failAll(p: Pending): Outcome[] {
31 return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
32}
33
34/** The actions whose outcome was not a failure. */
35export function proven(p: Pending | null, results: Outcome[]): GitAction[] {
36 return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
37}
38
39/** A filesystem root (`/` or a drive letter). */
40export function rootOfDisk(root: string): boolean {
41 return root === '/' || /^[A-Za-z]:\/$/.test(root)
42}
43
44/** `~`-relative form of a path under `home`; the path itself otherwise. */
45export function shortenHome(path: string, home: string): string {
46 return home && inside(home, path) ? `~${path.slice(home.length)}` : path
47}
48
49/** A media path as a safe cache file name. */
50export const cacheName = (path: string): string => path.replace(/[^A-Za-z0-9]+/g, '_').slice(-120)
51
52/** `m:ss` for a number of seconds. */
53export const clock = (seconds: number): string => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, '0')}`
54hooks/fd-core.ts 575 lines1// Pure data: defaults, reducers, formatting and layout math. Nothing here touches `$`, so every
2// behaviour is testable directly (the test kit cannot raise a subagent's tool call or a
3// permission check inside a call; these functions are what the hooks apply).
4import type {
5 AgentCard,
6 Architect,
7 BgTask,
8 Bucket,
9 Check,
10 FileEvent,
11 Gate,
12 Layout,
13 LogLine,
14 Loop,
15 Main,
16 Moment,
17 Receipt,
18 Roster,
19 Tally,
20 ToolNote,
21 Turn,
22 Usage,
23 View,
24} from '../types'
25
26export const SCHEMA_VERSION = 2
27
28// ---------------------------------------------------------------- defaults
29
30export const DEFAULT_MAIN: Main = { model: '', effort: '', mode: '', steps: 0, isRunning: false, speed: null }
31export const DEFAULT_USAGE: Usage = {
32 pct: null,
33 tokens: null,
34 window: 0,
35 costUsd: null,
36 limits: [],
37 compactions: 0,
38 lastCompactAt: null,
39}
40export const DEFAULT_ARCHITECT: Architect = { consults: [], ids: [], seen: [], lastAdvice: '' }
41const ZERO: Tally = { rule: 0, ask: 0, cleared: 0, deny: 0 }
42export const DEFAULT_GATE: Gate = { recent: [], totals: { file: ZERO, shell: ZERO, other: ZERO } }
43export const DEFAULT_TURN: Turn = { edits: 0, errorStreak: 0, errors: 0, isReviewing: false, startedAt: 0, costAtStart: null }
44export const DEFAULT_VIEW: View = { expanded: null, gateOpen: null, filesOpen: null, layout: null }
45export const DEFAULT_ROSTER: Roster = { architectTypes: [] }
46
47const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
48
49const kindOf = (v: unknown): string => (Array.isArray(v) ? 'array' : v === null ? 'null' : typeof v)
50
51/** A stored object merged over its defaults: a key missing from an older shape takes the default, and so does a key whose stored type no longer matches it (a default of null accepts anything). */
52export const normalize = <T extends object>(def: T, stored: unknown): T => {
53 if (!isObject(stored)) return def
54 const out: Record<string, unknown> = { ...(def as Record<string, unknown>) }
55 for (const [k, v] of Object.entries(stored)) {
56 const want = kindOf((def as Record<string, unknown>)[k])
57 if (want === 'null' || want === 'undefined' || want === kindOf(v)) out[k] = v
58 }
59 return out as T
60}
61
62/** A stored list, or empty when what is stored is not a list. */
63export const listOf = <T>(stored: unknown): T[] => (Array.isArray(stored) ? (stored as T[]) : [])
64
65export const normalizeGate = (stored: unknown): Gate => {
66 const g = normalize(DEFAULT_GATE, stored)
67 const totals = normalize(DEFAULT_GATE.totals, g.totals)
68 return {
69 recent: listOf<Check>(g.recent),
70 totals: { file: normalize(ZERO, totals.file), shell: normalize(ZERO, totals.shell), other: normalize(ZERO, totals.other) },
71 }
72}
73
74export const normalizeCard = (stored: unknown): AgentCard =>
75 normalize<AgentCard>(
76 {
77 id: '',
78 type: 'agent',
79 model: '',
80 description: '',
81 status: 'running',
82 spawnedAt: 0,
83 endedAt: null,
84 ctx: 0,
85 out: 0,
86 steps: 0,
87 lastStop: null,
88 tools: [],
89 answer: '',
90 },
91 stored,
92 )
93
94export const normalizeLog = (stored: unknown): LogLine[] =>
95 listOf<Record<string, unknown>>(stored).map(l => ({
96 at: typeof l.at === 'number' ? l.at : 0,
97 who: String(l.who ?? ''),
98 text: String(l.text ?? ''),
99 agentId: typeof l.agentId === 'string' ? l.agentId : null,
100 kind: l.kind === 'error' || l.kind === 'consult' || l.kind === 'done' ? l.kind : 'info',
101 }))
102
103// ---------------------------------------------------------------- config
104
105export type Panel = 'blocked' | 'main' | 'architect' | 'gate' | 'agents' | 'loops' | 'files' | 'shell' | 'receipt' | 'log'
106const PANELS: readonly Panel[] = ['blocked', 'receipt', 'main', 'architect', 'gate', 'files', 'agents', 'shell', 'loops', 'log']
107// The statusline already shows model, context, quota, cost and effort, so `main` is opt-in through `panels`.
108const DEFAULT_PANELS: readonly Panel[] = PANELS.filter(p => p !== 'main')
109
110export type Config = {
111 architect: RegExp
112 architectLabel: string
113 gateLabel: string
114 panels: Panel[]
115 motion: boolean
116 moments: boolean
117 matchDescriptions: boolean
118 maxCards: number
119 layout: Layout
120 palette: Palette
121 openOnStart: boolean
122 statusLine: boolean
123}
124
125const safeRegExp = (source: string, fallback: string) => {
126 try {
127 return new RegExp(source || fallback, 'i')
128 } catch {
129 return new RegExp(fallback, 'i')
130 }
131}
132
133/** The plugin's `/config` values, read leniently: anything malformed falls back to the default. */
134export const parseConfig = (o: Readonly<Record<string, unknown>>): Config => {
135 const str = (k: string, d: string) => (typeof o[k] === 'string' && o[k] !== '' ? (o[k] as string) : d)
136 const bool = (k: string, d: boolean) => (typeof o[k] === 'boolean' ? (o[k] as boolean) : d)
137 const panels = str('panels', DEFAULT_PANELS.join(','))
138 .split(',')
139 .map(s => s.trim())
140 .filter((p): p is Panel => (PANELS as readonly string[]).includes(p))
141 const layout = str('layout', 'auto')
142 const max = typeof o.maxCards === 'number' ? Math.round(o.maxCards) : 3
143 return {
144 architect: safeRegExp(str('architectPattern', ''), 'advisor|architect'),
145 architectLabel: str('architectLabel', 'ARCHITECT'),
146 gateLabel: str('gateLabel', 'GATE'),
147 panels: panels.length > 0 ? [...new Set(panels)] : [...DEFAULT_PANELS],
148 motion: str('motion', 'while-active') !== 'off',
149 moments: bool('moments', true),
150 matchDescriptions: bool('matchDescriptions', false),
151 maxCards: Math.min(6, Math.max(1, max)),
152 layout: layout === 'compact' || layout === 'wide' || layout === 'mini' ? layout : 'auto',
153 palette: str('palette', 'theme') === 'pastel' ? 'pastel' : 'theme',
154 openOnStart: bool('openOnStart', true),
155 statusLine: bool('statusLine', true),
156 }
157}
158
159// ---------------------------------------------------------------- palette
160
161export type Palette = 'theme' | 'pastel'
162export type Colors = Record<'main' | 'agent' | 'gate' | 'cleared' | 'arch' | 'amber' | 'warn' | 'dim' | 'faint' | 'text', string>
163
164/**
165 * `theme` names the person's own theme colours (they follow light, dark and colour-blind themes);
166 * `pastel` is fixed hex tuned for dark terminals.
167 */
168export const PALETTES: Record<Palette, Colors> = {
169 theme: {
170 main: 'claude',
171 agent: 'suggestion',
172 gate: 'success',
173 cleared: 'permission',
174 arch: 'merged',
175 amber: 'warning',
176 warn: 'error',
177 dim: 'inactive',
178 faint: 'subtle',
179 text: 'text',
180 },
181 pastel: {
182 main: '#7dd3fc',
183 agent: '#93c5fd',
184 gate: '#86efac',
185 cleared: '#5eead4',
186 arch: '#c4b5fd',
187 amber: '#fcd34d',
188 warn: '#fca5a5',
189 dim: '#6b7280',
190 faint: '#3f4654',
191 text: '#e5e7eb',
192 },
193}
194
195/** SVG cannot name theme keys: mid-tone colours that read on light and dark backgrounds. */
196export const SVG_COLORS = { running: '#3b82f6', done: '#16a34a', failed: '#dc2626', other: '#8b5cf6', label: '#6b7280' }
197
198// ---------------------------------------------------------------- formatting
199
200/** `claude-opus-5-5` → `Opus 5.5`; anything else is shown as given. */
201/**
202 * A model id as people say it, from any provider's spelling: `claude-opus-5-5[1m]` → `Opus 5.5 1M`,
203 * `us.anthropic.claude-sonnet-4-5-20250929-v1:0` → `Sonnet 4.5`, `claude-3-5-haiku-20241022` →
204 * `Haiku 3.5`. Anything else is shown as given, cut to 22 characters.
205 */
206export const prettyModel = (id: string) => {
207 if (!id) return '—'
208 const cap = (f: string) => f.charAt(0).toUpperCase() + f.slice(1)
209 const big = /\[1m\]|-1m\b/i.test(id) ? ' 1M' : ''
210 const now = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?![\d])/i.exec(id)
211 if (now?.[1] && !/^\d/.test(now[1])) return `${cap(now[1].toLowerCase())} ${now[2]}${now[3] ? `.${now[3]}` : ''}${big}`
212 const old = /claude-(\d+)(?:-(\d))?-([a-z]+)/i.exec(id)
213 if (old?.[3]) return `${cap(old[3].toLowerCase())} ${old[1]}${old[2] ? `.${old[2]}` : ''}${big}`
214 return shorten(id, 22)
215}
216
217export const shorten = (s: string, n: number) => {
218 const one = s.replace(/\s+/g, ' ').trim()
219 return n <= 0 ? '' : one.length > n ? `${one.slice(0, Math.max(0, n - 1)).trimEnd()}…` : one
220}
221
222export const kTokens = (n: number) =>
223 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
224
225export const fmtDuration = (ms: number) => {
226 const s = Math.max(0, Math.round(ms / 1000))
227 if (s < 60) return `${s}s`
228 const m = Math.floor(s / 60)
229 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
230}
231
232/** A running clock as the live cards draw it: m:ss, or XhYY past an hour. */
233export const fmtTimer = (ms: number) => {
234 const s = Math.max(0, Math.floor(ms / 1000))
235 const m = Math.floor(s / 60)
236 return m < 60 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
237}
238
239export const fmtClock = (ms: number) => (ms > 0 ? new Date(ms).toTimeString().slice(0, 8) : '--:--:--')
240
241/** `1 error`, `2 errors`. */
242export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
243
244export const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
245
246/**
247 * How fast a request answered, from three readings of the clock: when it was sent, when its first
248 * piece of response arrived, and when it stopped. Null when no response came. `tokPerS` is null
249 * when there is no usage or no time between the first piece and the stop to divide by.
250 */
251export const stepSpeed = (sentAt: number, firstAt: number | null, stopAt: number, outTokens: number | null) => {
252 if (firstAt === null) return null
253 const decodeMs = stopAt - firstAt
254 return {
255 ttftMs: Math.max(0, firstAt - sentAt),
256 tokPerS: outTokens !== null && outTokens > 0 && decodeMs > 0 ? (outTokens * 1000) / decodeMs : null,
257 }
258}
259
260/** `ttft 0.8s · 42 tok/s`; the rate is left off when it is unknown. */
261export const fmtSpeed = (s: { ttftMs: number; tokPerS: number | null }) =>
262 `ttft ${(s.ttftMs / 1000).toFixed(1)}s` + (s.tokPerS !== null ? ` · ${Math.round(s.tokPerS)} tok/s` : '')
263
264/** A gauge of `width` cells: ▰ filled, ▱ empty. */
265export const gauge = (pct: number, width: number) => {
266 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
267 return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
268}
269
270/** A rate-limit reading as the pane keeps it, its ISO reset time turned into ms since the epoch. */
271export const limitOf = (r: { kind: string; percentUsed: number; resetsAt?: string }) => {
272 const at = r.resetsAt ? Date.parse(r.resetsAt) : NaN
273 return { kind: r.kind, pct: r.percentUsed, resetsAt: Number.isFinite(at) ? at : null }
274}
275
276/** Time left until a window resets, its two largest units: `25m`, `3h10m`, `4d2h`; empty when unknown or past. */
277export const fmtResetIn = (resetsAt: number | null | undefined, now: number) => {
278 if (resetsAt === null || resetsAt === undefined || resetsAt <= now) return ''
279 const m = Math.max(1, Math.floor((resetsAt - now) / 60_000))
280 const d = Math.floor(m / 1440)
281 const h = Math.floor((m % 1440) / 60)
282 if (d > 0) return `${d}d${h}h`
283 return h > 0 ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
284}
285
286/** A rate-limit window's short name: `five_hour` → `5h`, `seven_day_opus` → `7d opus`. */
287export const limitLabel = (kind: string) =>
288 kind
289 .replace(/five[_ -]?hours?/i, '5h')
290 .replace(/seven[_ -]?days?/i, '7d')
291 .replace(/[_-]+/g, ' ')
292 .trim()
293
294// ---------------------------------------------------------------- redaction
295
296const SECRETS: [RegExp, string][] = [
297 [/(authorization\s*[:=]\s*)(bearer\s+|basic\s+)?\S+/gi, '$1$2•••'],
298 [/\b(bearer)\s+[A-Za-z0-9._~+/-]{8,}=*/gi, '$1 •••'],
299 [/\b(sk|pk|rk|ghp|gho|ghs|github_pat|xox[abprs])[-_][A-Za-z0-9_-]{8,}/g, '•••'],
300 [/((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1•••'],
301 [/(--(?:token|password|api-key|secret)[= ])\S+/gi, '$1•••'],
302 [/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)=)\S+/g, '$1•••'],
303 [/(\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, '$1•••@'],
304]
305
306/** What the gate drill-down may store: credentials masked before anything is written to state. */
307export const redact = (s: string) => SECRETS.reduce((t, [re, to]) => t.replace(re, to), s)
308
309// ---------------------------------------------------------------- tools and gate
310
311const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'Glob', 'Grep'])
312const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
313export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
314
315export const bucketOf = (tool: string): Bucket =>
316 FILE_TOOLS.has(tool) ? 'file' : SHELL_TOOLS.has(tool) ? 'shell' : 'other'
317
318/** One line saying what a call was about: its command, path or pattern; redacted. */
319export const describeInput = (tool: string, input: unknown) => {
320 const i = isObject(input) ? input : {}
321 const path = typeof i.file_path === 'string' ? i.file_path.split(/[\\/]/).slice(-2).join('/') : ''
322 const what =
323 typeof i.command === 'string'
324 ? i.command
325 : path || (typeof i.pattern === 'string' ? i.pattern : typeof i.url === 'string' ? i.url : typeof i.description === 'string' ? i.description : '')
326 return redact(what ? `${tool} → ${what}` : tool)
327}
328
329/** Keeps the last `max` checks, but never drops a pending ask: its settle must still find it. */
330export const trimRecent = (list: Check[], max: number): Check[] => {
331 let extra = list.length - max
332 if (extra <= 0) return list
333 const out: Check[] = []
334 for (const c of list) {
335 if (extra > 0 && c.verdict !== 'ask') {
336 extra -= 1
337 continue
338 }
339 out.push(c)
340 }
341 return out.slice(-max * 2)
342}
343
344export const recordCheck = (g: Gate, c: Check): Gate => {
345 const t = g.totals[c.bucket]
346 return {
347 recent: trimRecent([...g.recent, c], 80),
348 totals: { ...g.totals, [c.bucket]: { ...t, [c.verdict]: t[c.verdict] + 1 } },
349 }
350}
351
352/** An `ask` settled by the call that followed it: it ran (cleared) or was refused (deny). */
353export const settleCheck = (g: Gate, id: string, didRun: boolean): Gate => {
354 const c = g.recent.find(r => r.id === id && r.verdict === 'ask')
355 if (!c) return g
356 const verdict = didRun ? 'cleared' : 'deny'
357 const t = g.totals[c.bucket]
358 return {
359 recent: g.recent.map(r => (r === c ? { ...r, verdict } : r)),
360 totals: { ...g.totals, [c.bucket]: { ...t, ask: Math.max(0, t.ask - 1), [verdict]: t[verdict] + 1 } },
361 }
362}
363
364export const gateSummary = (g: Gate) => {
365 const all = (['file', 'shell', 'other'] as const).reduce(
366 (s, k) => ({
367 rule: s.rule + g.totals[k].rule,
368 ask: s.ask + g.totals[k].ask,
369 cleared: s.cleared + g.totals[k].cleared,
370 deny: s.deny + g.totals[k].deny,
371 }),
372 { ...ZERO },
373 )
374 return { ...all, total: all.rule + all.ask + all.cleared + all.deny }
375}
376
377// ---------------------------------------------------------------- turn and architect
378
379/**
380 * The turn after one tool call. Errors count in the main loop only (a subagent's failure is its
381 * own); edits count from every loop, so delegated work still reaches "before done".
382 */
383export const afterCall = (t: Turn, c: { inSubagent: boolean; hasFailed: boolean; isEdit: boolean }): Turn => ({
384 ...t,
385 errorStreak: c.inSubagent ? t.errorStreak : c.hasFailed ? t.errorStreak + 1 : 0,
386 errors: t.errors + (!c.inSubagent && c.hasFailed ? 1 : 0),
387 edits: t.edits + (c.isEdit ? 1 : 0),
388})
389
390/** Which of the architect's three moments a consult falls at: an inference over this turn so far. */
391export const momentOf = (t: Pick<Turn, 'edits' | 'errorStreak'>): Moment =>
392 t.errorStreak >= 2 ? 'error repeats' : t.edits === 0 ? 'before a plan' : 'before done'
393
394export const startConsult = (a: Architect, c: { id: string; at: number; moment: Moment; via: string }): Architect =>
395 a.consults.some(x => x.id === c.id) ? a : { ...a, consults: [...a.consults, { ...c, endAt: null }].slice(-40) }
396
397/** Ends the open consult (the latest without an end), or the one named. */
398export const endConsult = (a: Architect, at: number, advice: string | null, id?: string): Architect => {
399 const open = [...a.consults].reverse().find(c => c.endAt === null && (id === undefined || c.id === id))
400 return {
401 ...a,
402 consults: a.consults.map(c => (c === open ? { ...c, endAt: at } : c)),
403 lastAdvice: advice ?? a.lastAdvice,
404 }
405}
406
407export const isAdvising = (a: Architect) => a.consults.some(c => c.endAt === null)
408
409/** A one-row timeline of consults across `width` cells: ◆ a consult, ━ while it ran. */
410export const consultTimeline = (a: Architect, now: number, width: number) => {
411 if (a.consults.length === 0 || width < 4) return '─'.repeat(Math.max(0, width))
412 const first = a.consults[0]?.at ?? now
413 const span = Math.max(1, now - first)
414 const cells = Array.from({ length: width }, () => '─')
415 for (const c of a.consults) {
416 const from = Math.min(width - 1, Math.floor(((c.at - first) / span) * (width - 1)))
417 const to = Math.min(width - 1, Math.floor((((c.endAt ?? now) - first) / span) * (width - 1)))
418 for (let i = from + 1; i <= to; i += 1) cells[i] = '━'
419 cells[from] = '◆'
420 }
421 return cells.join('')
422}
423
424export const receiptOf = (t: Turn, o: { durationMs: number; agentsSince: number; costNow: number | null; reason: string }): Receipt => ({
425 durationMs: o.durationMs,
426 agents: o.agentsSince,
427 edits: t.edits,
428 errors: t.errors,
429 costDelta: o.costNow !== null && t.costAtStart !== null && o.costNow - t.costAtStart >= 0.005 ? o.costNow - t.costAtStart : null,
430 reason: o.reason,
431})
432
433// ---------------------------------------------------------------- agents and loops
434
435type StepUsage = { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; output_tokens?: number } | null
436
437/** A card after one of its model requests: its context is the latest step's whole input; output adds up. */
438export const applyStep = (c: AgentCard, s: { model: string; usage: StepUsage; stopReason: string | null }): AgentCard => {
439 const u = s.usage ?? {}
440 const ctx = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
441 return {
442 ...c,
443 model: c.model || s.model,
444 steps: c.steps + 1,
445 ctx: ctx > 0 ? ctx : c.ctx,
446 out: c.out + (u.output_tokens ?? 0),
447 lastStop: s.stopReason,
448 }
449}
450
451export const noteTool = (c: AgentCard, n: ToolNote): AgentCard => ({ ...c, tools: [...c.tools, n].slice(-3) })
452
453export const stepLoop = (loops: Loop[], id: string, at: number): Loop[] => {
454 const found = loops.find(l => l.id === id)
455 const next = found
456 ? loops.map(l => (l === found ? { ...l, steps: l.steps + 1, lastAt: at } : l))
457 : [...loops, { id, steps: 1, firstAt: at, lastAt: at, isDone: false }]
458 return next.slice(-60)
459}
460
461/** `[id, status]` for each `<task-notification>` block in a message. */
462export function taskEnds(text: string): [string, string][] {
463 return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
464 const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
465 const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
466 return id && status ? [[id, status] as [string, string]] : []
467 })
468}
469
470const FILES_KEEP = 50
471
472/** Adds file events, newest last; an earlier event for the same path and kind is replaced so a file edited ten times is one row. */
473export const recordFiles = (list: FileEvent[], events: FileEvent[]): FileEvent[] => {
474 if (events.length === 0) return list
475 const fresh = new Set(events.map(e => `${e.kind}\0${e.path}`))
476 return [...list.filter(e => !fresh.has(`${e.kind}\0${e.path}`)), ...events].slice(-FILES_KEEP)
477}
478
479const TASK_KEEP = 20
480
481/** Drops the oldest ended tasks past `TASK_KEEP`; a running one is never dropped. */
482export const trimTasks = (tasks: BgTask[]): BgTask[] => {
483 let extra = tasks.length - TASK_KEEP
484 return tasks.filter(t => (extra > 0 && t.endedAt !== null ? (extra--, false) : true))
485}
486
487export const startTask = (tasks: BgTask[], t: { id: string; kind: BgTask['kind']; label: string; at: number }): BgTask[] =>
488 tasks.some(x => x.id === t.id)
489 ? tasks
490 : trimTasks([...tasks, { id: t.id, kind: t.kind, label: redact(shorten(t.label, 200)), startedAt: t.at, endedAt: null, status: 'running' }])
491
492/** Ends a running task; an id seen at no start (the mod reloaded mid-run) is left alone. */
493export const endTask = (tasks: BgTask[], id: string, status: string, at: number): BgTask[] =>
494 tasks.some(x => x.id === id && x.endedAt === null) ? tasks.map(x => (x.id === id && x.endedAt === null ? { ...x, endedAt: at, status } : x)) : tasks
495
496export const LOOP_ACTIVE_MS = 15_000
497export const isLoopActive = (l: Loop, now: number) => !l.isDone && now - l.lastAt < LOOP_ACTIVE_MS
498
499/** Swimlane geometry: each agent's bar on one shared axis from the first spawn to now. */
500export const lanes = (cards: AgentCard[], now: number, width: number) => {
501 const start = Math.min(...cards.map(c => c.spawnedAt).filter(n => n > 0), now)
502 const span = Math.max(1, now - start)
503 return cards.map(c => {
504 const from = Math.floor(((Math.max(c.spawnedAt, start) - start) / span) * width)
505 const to = Math.max(from + 1, Math.ceil((((c.endedAt ?? now) - start) / span) * width))
506 return { id: c.id, before: Math.min(from, width), bar: Math.min(to, width) - Math.min(from, width), after: width - Math.min(to, width) }
507 })
508}
509
510// ---------------------------------------------------------------- layout
511
512/** How many log lines fit: what the other panels leave, never fewer than 4 nor more than 8. */
513export const logRows = (bodyRows: number, used: number) => Math.max(4, Math.min(8, bodyRows - used - 3))
514
515/** Legend items that fit on one row of `width` cells, in order; the rest are dropped. */
516export const fitLegend = <T extends { label: string }>(items: T[], width: number) => {
517 const out: T[] = []
518 let used = 0
519 for (const it of items) {
520 const w = it.label.length + 4
521 if (used + w > width) break
522 out.push(it)
523 used += w
524 }
525 return out
526}
527
528/** A card's title row: the task in the agent's own words, the type only when there is none. */
529export const cardTitle = (c: AgentCard) => c.description || c.type
530
531/** A title split over two rows at a word boundary: `first` cells on row one, `rest` on row two. */
532export const titleLines = (title: string, first: number, rest: number): [string, string] => {
533 const t = title.replace(/\s+/g, ' ').trim()
534 if (t.length <= first) return [t, '']
535 const cut = t.lastIndexOf(' ', first)
536 const at = cut > 0 ? cut : first
537 return [t.slice(0, at).trim(), shorten(t.slice(at), rest)]
538}
539
540/**
541 * What a turn's opening text was, for the log: the person's words, or for a turn the engine
542 * opened with a tagged message (a subagent's hand-back, a task notification), that message's kind.
543 */
544export const promptLine = (text: string): { who: string; text: string } => {
545 const tag = /^\s*<([a-z][\w-]*)/i.exec(text)?.[1]
546 if (!tag) return { who: 'you', text: shorten(text, 70) }
547 const from = /\bfrom="([^"]+)"/.exec(text)?.[1]
548 return { who: 'engine', text: shorten(`${tag.replace(/[-_]/g, ' ')}${from ? ` from ${from.slice(0, 8)}` : ''}`, 70) }
549}
550
551/**
552 * A subagent's hand-back message: who sent it and the first line of what it said. The report
553 * follows a framing header in the message; without one, the first line after the opening tag.
554 */
555export const handbackOf = (text: string): { from: string; body: string } | null => {
556 const from = /^\s*<agent-message\s+from="([^"]+)"/.exec(text)?.[1]
557 if (!from) return null
558 const afterHeader = text.split(/The report follows:\s*\n/)[1]
559 const rest = afterHeader ?? text.replace(/^\s*<agent-message[^>]*>/, '')
560 const body =
561 rest
562 .split('\n')
563 .map(l => l.trim())
564 .find(l => l && !l.startsWith('[') && !l.startsWith('<') && !l.startsWith('</')) ?? ''
565 return body ? { from, body } : null
566}
567
568/** The advice line a pane shows for a report: its first real line, markdown markers stripped, cut to 160. */
569export const adviceLine = (report: string) => {
570 const first = report.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('[') && !l.startsWith('<')) ?? ''
571 return shorten(first.replace(/\*\*|__/g, '').replace(/^[#>*\s-]+/, ''), 160)
572}
573
574export const elapsedOf = (c: AgentCard, now: number) => (c.endedAt ?? now) - c.spawnedAt
575hooks/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}
262