SLOPSHOPPER

filetree

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

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

Draws

Pane · Files: app
app󰁝 󰋜 󰑐 󰈈 󰋊  no git repo · git status starts after git init / : search ⏎ jump ▣ client module ./rows.tsx 󰕒 pushed
Pane · File viewer
[ Repos ] [ Refresh ] [ All ] 0 of 0 repos with changes No git repos directly under this folder.
Pane · Flightdeck
FLIGHTDECK · — IDLE ■ main ■ agents ■ gate ╭──────────────────────────────────────────────────────╮ │ ✓ last turn 42s · 0 agents · 3 edits · 1 error │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ files · claude 5 touched │ │ ■■■■■ │ │ ■ read 2 ■ written 3 ■ git 0 │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ session log │ │ 08:53:20 main Edit → src/auth.ts │ │ 08:53:20 main Write → src/audit.ts │ │ 08:53:20 main Write → src/cache.ts │ │ 08:53:20 main Bash → bun test ✗ │ ╰──────────────────────────────────────────────────────╯
README

<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.


Installation

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.

Features

  • Interactive file tree for the working directory where you're using Claude Code; it follows the cwd, or /filetree <path> pins another folder
  • Search the file tree, including folders you have not opened yet
  • Git status per file and folder in color, with exact lines changed (+N -N) on modified files and ?:N M:N D:N file counts on folders
  • Branch, upstream and ahead/behind in the header
  • Claude's reads and searches (purple), writes (orange) and commits (green) are listed in the Flightdeck files 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 GitHub operations via 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">

  • Selection-aware: the selected file is passed to Claude as context through a 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">

  • File and folder sizes: the Σ 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 writes
  • Double-click a file to open it in its default app
  • Click to select, arrow keys to move through the tree
  • Light on large repos: outside a repo it only checks once whether one exists, and every git call is scoped to the cwd
  • Nerd Font icons with a plain Unicode fallback
  • On Omarchy, the pane takes its colors and background from the current theme and follows theme switches

Resizing the pane

You 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">

Settings

All settings are in /config under filetree.

  • Claude activity: what the Flightdeck files panel records: reads and writes (default), writes, reads or none. Git status, line counts and the git status at the bottom always show.
  • Right column: date (default) or size; what the right column shows when a session starts. The Σ button in the header toggles it.
  • Glyphs: 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.

Flightdeck tab

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

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.

herdr

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.

Contributing

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.

License

MIT

This project is inspired by my Omarchy app FileBlade

Source 19 files
hooks/register.tsx 2309 lines
1import { 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 done
hooks/delta.ts 135 lines
1import 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}
135
hooks/open.ts 41 lines
1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4  'set -f',
5  'f=$1',
6  'if command -v gio >/dev/null; then',
7  '  if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8  '    case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9  '      video/* | audio/*)',
10  '        app=$(xdg-mime query default text/plain)',
11  '        IFS=:',
12  '        for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13  '          [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14  '        done',
15  '        echo "No text/plain desktop application found for $f" >&2',
16  '        exit 1',
17  '        ;;',
18  '    esac',
19  '  fi',
20  '  if (exec gio open "$f"); then exit 0; fi',
21  'fi',
22  'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28  const argv = os === 'linux'
29    ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30    : os === 'darwin'
31      ? ['open', '--', target]
32      : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33  return {
34    argv,
35    init: {
36      timeoutMs: 10_000,
37      ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38    },
39  }
40}
41
hooks/blocked.ts 50 lines
1// 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}
50
hooks/media.ts 203 lines
1// 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}
203
hooks/fd-state.ts 211 lines
1// 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}
211
hooks/fd-view.tsx 731 lines
1// 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}
731
hooks/config.ts 40 lines
1// 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)
40
hooks/md.ts 69 lines
1// 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}
69
hooks/pure.ts 54 lines
1// `$`-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')}`
54
hooks/fd-core.ts 575 lines
1// 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
575
hooks/git.ts 262 lines
1import { drivesOn, isAbsolute, posix } from './tree'
2
3export type GitAction = {
4  kind: string
5  verb: string
6  running: string
7  done: string
8  tone: string
9  icon: { nerd: string; plain: string }
10  init?: boolean
11}
12
13const ICON = {
14  commit: { nerd: '\u{f417}', plain: '●' },
15  push: { nerd: '\u{f0552}', plain: '↑' },
16  pull: { nerd: '\u{f0553}', plain: '↓' },
17  branch: { nerd: '\u{f418}', plain: '⑂' },
18  merge: { nerd: '\u{f419}', plain: '⑃' },
19  pr: { nerd: '\u{f407}', plain: '⇄' },
20  github: { nerd: '\u{f408}', plain: '◎' },
21  stash: { nerd: '\u{f01bc}', plain: '≡' },
22  undo: { nerd: '\u{f054c}', plain: '↶' },
23  tag: { nerd: '\u{f412}', plain: '⌖' },
24  git: { nerd: '\u{e702}', plain: '±' },
25}
26
27export const TONES: Record<string, { bright: string[]; dim: string[]; solid: string }> = {
28  orange: { bright: ['#f97316', '#fb923c', '#fdba74', '#ffedd5'], dim: ['#8a4316', '#a3562a', '#bd7444', '#d29267'], solid: '#f97316' },
29  green: { bright: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'], dim: ['#14532d', '#166534', '#2f7a47', '#4f9a66'], solid: '#4ade80' },
30  teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'], solid: '#2dd4bf' },
31  blue: { bright: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'], dim: ['#1e3a8a', '#1d4ed8', '#3b6fd1', '#6b93dc'], solid: '#60a5fa' },
32  purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'], solid: '#c084fc' },
33  cyan: { bright: ['#06b6d4', '#22d3ee', '#67e8f9', '#cffafe'], dim: ['#155e75', '#0e7490', '#2b8ea3', '#5aa9b8'], solid: '#22d3ee' },
34  red: { bright: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'], dim: ['#7f1d1d', '#991b1b', '#b54040', '#c96a6a'], solid: '#f87171' },
35}
36
37const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
38  commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
39  push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
40  pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
41  fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
42  checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
43  switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
44  branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
45  merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
46  rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
47  'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
48  stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
49  reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
50  restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
51  revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
52  add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
53  rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
54  mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
55  tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
56  init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
57  clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
58}
59
60const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
61  'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
62  'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
63  'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
64  'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
65  'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
66  'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
67  'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
68  'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
69}
70
71const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
72
73function segments(command: string, seps: string[] = []): string[][] {
74  const out: string[][] = []
75  let cur: string[] = []
76  let tok = ''
77  let quote = ''
78  let has = false
79  const endTok = () => {
80    if (has) cur.push(tok)
81    tok = ''
82    has = false
83  }
84  const endSeg = () => {
85    endTok()
86    if (cur.length) out.push(cur)
87    cur = []
88  }
89  for (let i = 0; i < command.length; i++) {
90    const ch = command[i] ?? ''
91    if (quote) {
92      if (ch === quote) quote = ''
93      else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
94      else tok += ch
95    } else if (ch === '"' || ch === "'") {
96      quote = ch
97      has = true
98    } else if (ch === '\\' && i + 1 < command.length) {
99      tok += command[++i]
100      has = true
101    } else if (ch === '#' && !has) {
102      while (i + 1 < command.length && command[i + 1] !== '\n') i++
103    } else if (ch === ' ' || ch === '\t') endTok()
104    else if (ch === '\n' || ch === ';') {
105      seps.push(';')
106      endSeg()
107    } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
108      tok += ch
109      has = true
110    } else if (ch === '&' || ch === '|') {
111      const twice = command[i + 1] === ch
112      if (twice) i++
113      seps.push(twice ? ch + ch : ch)
114      endSeg()
115    } else {
116      tok += ch
117      has = true
118    }
119  }
120  endSeg()
121  if (!quote) return out
122  seps.push(';')
123  return command
124    .split(/&&|\|\||;|\||\n/)
125    .map(s => s.trim().split(/\s+/).filter(Boolean))
126    .filter(s => s.length > 0)
127}
128
129function stripGlobals(tokens: string[]): string[] {
130  const out = [...tokens]
131  if (out[0] === 'env') out.shift()
132  while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
133  return out
134}
135
136function gitVerb(tokens: string[]): number {
137  let i = 1
138  while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
139    if (tokens[i] === '-C' || tokens[i] === '-c') i++
140    i++
141  }
142  return i
143}
144
145function gitReadOnly(verb: string, rest: string[]): boolean {
146  if (!verb || READ_ONLY_GIT.has(verb)) return true
147  if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
148  if (verb === 'branch') return rest.every(t => t.startsWith('-'))
149  if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
150  return false
151}
152
153export function chainOf(command: string): { size: number; and: boolean } {
154  const seps: string[] = []
155  const size = segments(command, seps).length
156  return { size, and: seps.every(s => s === '&&') }
157}
158
159export function gitActions(command: string): GitAction[] {
160  const out: GitAction[] = []
161  for (const raw of segments(command)) {
162    const tokens = stripGlobals(raw)
163    const head = tokens[0]?.split('/').pop()
164    if (head === 'git') {
165      const i = gitVerb(tokens)
166      const verb = tokens[i] ?? ''
167      const rest = tokens.slice(i + 1)
168      if (gitReadOnly(verb, rest)) continue
169      const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
170      if (spec) out.push({ kind: `git ${verb}`, ...spec })
171    } else if (head === 'gh') {
172      const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
173      const spec = GH_VERBS[pair]
174      if (spec) out.push({ kind: `gh ${pair}`, ...spec })
175    }
176  }
177  return out
178}
179
180export const BRANCH_ICON = ICON.branch
181
182const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
183const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
184const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
185
186function tidy(path: string): string {
187  const s = path.replace(/\/+$/, '')
188  return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
189}
190
191export function resolve(cwd: string, p: string, home = ''): string {
192  let path = posix(p)
193  if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
194  const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
195  const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
196  for (const seg of path.slice(drive.length).split('/')) {
197    if (!seg || seg === '.') continue
198    if (seg === '..') {
199      if (parts.length > 1) parts.pop()
200    } else parts.push(seg)
201  }
202  return tidy(parts.join('/'))
203}
204
205const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
206
207export function readOnly(command: string): boolean {
208  const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
209  if (/[^>]>[^>&]|>>/.test(bare)) return false
210  return segments(command).every(raw => {
211    const tokens = stripGlobals(raw)
212    const head = tokens[0]?.split('/').pop() ?? ''
213    if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
214    if (head === 'git') {
215      const i = gitVerb(tokens)
216      return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
217    }
218    if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
219    if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
220    return READERS.has(head)
221  })
222}
223
224export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
225  let cwd = sessionCwd
226  const out = new Set<string>()
227  let listsPaths = false
228  for (const raw of segments(command)) {
229    const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
230    const head = tokens[0]?.split('/').pop() ?? ''
231    if (head === 'cd' && tokens[1]) {
232      cwd = resolve(cwd, tokens[1], home)
233      continue
234    }
235    if (!READERS.has(head)) continue
236    if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
237    if (OUTPUT_PATHS.has(head)) listsPaths = true
238    const args = tokens.slice(1)
239    let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
240    for (let i = 0; i < args.length; i++) {
241      const a = args[i] ?? ''
242      if (a.startsWith('-')) {
243        if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
244        continue
245      }
246      if (skipPattern) {
247        skipPattern = false
248        continue
249      }
250      if (/[*?<>|]/.test(a)) continue
251      out.add(resolve(cwd, a, home))
252    }
253  }
254  if (listsPaths) {
255    for (const line of stdout.split('\n').slice(0, 400)) {
256      const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
257      if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
258    }
259  }
260  return [...out].slice(0, 60)
261}
262