SLOPSHOPPER

crab-progress

Progress bar, subagent panel and file tree with a mascot that reacts to what Claude is doing. File tree merged from filetree (MIT, data-goblin).

newpanebandguardcommandtoast
★ 1v0.5.1MITupdated 2026-10-16RyanWeera/crab-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crab-progress
│ ┃ Files: app ✕ › fix the failing auth test and add an audit log call │ ┃ app▣ client module ./ft-button.tsx ▣ client │ ┃  no git repo · git status starts after git ⏺ Read(src/auth.ts) │ ┃ init ⎿ Read 6 lines │ ┃ / : search ⏎ jump ⏺ Update(src/auth.ts) │ ┃ ▣ client module ./ft-rows.tsx ⎿ Added 2 lines, removed 1 line │ ┃ 󰕒 pushed ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /filetree │ ┃ ⎿ crab-progress: File tree on /work/app (follows the cwd). │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ███████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 19% done ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
███████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 19% done
Pane · Files: app
app▣ client module ./ft-button.tsx ▣ client module ./ft-butt  no git repo · git status starts after git init / : search ⏎ jump ▣ client module ./ft-rows.tsx 󰕒 pushed
Pane · crab-agents
≈$0.00, 0 tokens, 0:00 Collapse No subagents yet.
README

<img src="docs/logo.svg" alt="crab-progress logo" width="128">

crab-progress

A mascot for your Claude Code session. A progress bar he rides, a panel for your subagents, a file tree he digs through, and costumes for everything Claude does.

License: MIT Windows macOS Linux

<img src="docs/preview.png" alt="The mascot in his costumes, the six characters, and the file-tree dig scene" width="560">

Unofficial fan project. Not affiliated with or endorsed by Anthropic, Mojang or Microsoft.

What you get

A progress bar with a passenger

  • A bar above the prompt that moves on its own during every turn and hits 100% when the turn ends. Use the progress tool or TodoWrite to set exact done/total instead.
  • The mascot rides the head of the fill. When the bar jumps 12% or more he sprints, and when it stops he pants. A leap of 30% or more is too far to run, so he gets in a car and drives it. After the car he parks and climbs out; he never pants. If it stalls he idles instead of freezing.
  • At 100% he celebrates, and how depends on the turn: out of the car after a drive, a medal after a sprint, a green-checked terminal after coding, a firework after fixing, a shut book after reading, a ticked page after writing. Every character has its own flourish (the frog hops, the creeper swells, the slime squishes, the robot shakes, the cat sways).
  • If you also run the cache-tax / keepwarm status line and the prompt cache has gone cold, he is frozen in a cube of ice, shivering, until the next turn warms it up.
  • His costume follows the task, picked by a tiny keyword classifier that runs locally (no model call, no network): wizard hat and wand for fixing, sunglasses and a terminal for coding, a book for reading, a chef's toque and a pan for long jobs, a nightcap after a while idle, a crown after a long task.
  • Between costumes he digs the next one out of a belly pocket and puts it on where it belongs.
  • With nothing to do he roams the bar: he walks, flies with a propeller cap, reads, waves, dances and sunbathes under an umbrella.

A subagents panel (/agents-info)

  • Cost, tokens and time up top, then one row per subagent with model, effort, context use, cost, elapsed time and its own progress bar. Each row's mascot dresses for what that agent is doing and cheers, then idles, when it finishes.

A file tree (/filetree)

  • An IDE-style tree with git status that shimmers where Claude reads and writes. The desktop layout is roomier (two-line rows) and styled to match the rest of the mod.
  • While files are being touched the crab works inside the real rows: a burrow strip opens under three of them, he dives into the file that was touched, throws things out until he finds a gem, pops up, jumps into another file and pokes his head out of a third. It is drawn in the tree's own grid, so it lines up with the rows and scrolls with them.

Characters (/mascot)

/mascot claude, frog, creeper, slime, robot or cat (or /frog, /creeper, ...). Every costume and animation works on every character, and your pick is remembered.

Install

You need Claude Code with plugin support (built and tested on 2.1.295) and git. The steps are the same on every system.

From inside Claude Code (all systems)

/plugin marketplace add RyanWeera/crab-progress
/plugin install crab-progress@crab-progress

Then start a new session. To update later:

/plugin marketplace update crab-progress

From a terminal

# Homebrew users: brew install git   (Xcode command line tools also include git)
claude plugin marketplace add RyanWeera/crab-progress
claude plugin install crab-progress@crab-progress

Run it without installing, straight from a clone:

git clone https://github.com/RyanWeera/crab-progress.git ~/crab-progress
claude --plugin-dir ~/crab-progress

The file tree uses git, find and du, which macOS already has. Quick Look preview uses qlmanage.

# Debian/Ubuntu: sudo apt install git     Fedora: sudo dnf install git     Arch: sudo pacman -S git
claude plugin marketplace add RyanWeera/crab-progress
claude plugin install crab-progress@crab-progress

Or from a clone:

git clone https://github.com/RyanWeera/crab-progress.git ~/crab-progress
claude --plugin-dir ~/crab-progress

The file tree uses git, find and du. Opening files uses your desktop's default handler.

# winget install --id Git.Git   (Git for Windows supplies find, du and sh, which the file tree uses)
claude plugin marketplace add RyanWeera/crab-progress
claude plugin install crab-progress@crab-progress

Or from a clone:

git clone https://github.com/RyanWeera/crab-progress.git $HOME\crab-progress
claude --plugin-dir $HOME\crab-progress

If you already use the originals

This mod includes the filetree and savvy-progress mods and registers the same /filetree and /agents-info commands. Remove the originals first so they don't fight:

claude plugin uninstall filetree
claude plugin uninstall savvy-progress

Uninstall

claude plugin uninstall crab-progress@crab-progress

Commands

CommandWhat it does
/mascot <name>Switch character: claude, frog, creeper, slime, robot, cat. /mascot list shows them.
/frog /creeper /slime /robot /catShortcuts for /mascot <name>.
/agents-infoShow or hide the subagents panel.
/filetree [path]Show the file tree (no path: the current directory).

Settings

The file tree keeps its upstream options, set in Claude Code's plugin settings:

OptionValuesDefaultDoes
activityreads and writes, writes, reads, nonereads and writesWhich file activity shimmers in the tree
glyphsauto, nerd, plainautoIcon set (Nerd Font icons need a Nerd Font)
followon, offonTree scrolls to what Claude touches
starton, offonOpen the pane when a session starts
columndate, age, sizedateRight-hand column
hintson, offon"git status starts after git init" line
opentextemptyWhat double-click/Enter runs (preview shows it in the pane)

FAQ

Is anything sent over the network? No. The mod has no network access. Costume choice is a local keyword classifier, not a model call. The file tree runs git, find and du on your machine.

Does it cost tokens? The mod adds one small tool (progress) and one for subagents (step), both optional for the model to call. It never edits your prompts.

The mascot isn't moving. Some systems have animations turned off in the OS. The mod ignores that setting on purpose, but a restart of the session is needed after installing or updating.

I switched characters and nothing changed. The choice applies to the bar, the panel and the tree header immediately; the crab inside the tree rows keeps the same shape and only takes the character's colour.

How it works

The mascot is drawn as SVG rectangles on a 30-wide pixel grid with CSS animation. Animations start mid-cycle so the once-a-second redraw never restarts them. Inside the file tree he is drawn with half-block characters, because the tree is a text grid.

hooks/
  register.tsx   bar, panel hooks, commands, state
  crab.ts        sprites, characters, animation CSS
  classify.ts    what he is doing, and the idle-roam script
  agents.ts      subagents panel drawing
  dig.ts         the SVG digging scene (agents panel)
  ft-*.ts(x)     the file tree (merged from filetree), incl. ft-rows.tsx with the in-row crab
types/           state contract

Develop with the Claude Code CLI from the repo root:

claude plugin validate .
claude plugin test .

Credits and merged mods

This mod folds two existing mods into one so they share a look and one set of hooks. Both are MIT licensed; see NOTICE.md.

Merged modAuthorLink
filetree: file tree pane with git status and Claude activityKurt Buhler / data-goblinhttps://github.com/data-goblin/claude-code-filetree
savvy-progress: progress bar and subagents paneljohnnyvizzhttps://github.com/JohnnyVizz/claude-kit (plugins/savvy-progress)

Reference art and ideas:

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md.

License

MIT for this repository's own code (LICENSE). The merged file tree keeps its upstream MIT license (LICENSE-filetree); see NOTICE.md for savvy-progress.

Source 13 files
hooks/register.tsx 609 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRun, Bar, Character, Outfit, Panel, Run, Sess } from '../types'
5import { DIG_H, DIG_W, digSvg } from './dig'
6import { register as registerTree } from './ft-register'
7import type { Dest } from './crab'
8import { BAR_H, BAR_Y, CHARACTERS, CRAB_H, CRAB_W, ITEM_COLOR, ITEM_DEST, PX, ROW_H, WIN, codeWindow, crabCss, crabSvg } from './crab'
9import { CHANGE_MS, MOVING, autoRatio, isIcedNow, isLong, pickOutfit, roamAt, winPose } from './classify'
10import {
11  HEADER_H,
12  LINE_H,
13  ROW_H as AGENT_ROW_H,
14  agentLine,
15  agentSvg,
16  compactSvg,
17  costOf,
18  taskOutfit,
19  ctxBar,
20  gridSvg,
21  gridTiles,
22  headerSvg,
23  modelName,
24  sameTask,
25  summaryOf,
26  totals,
27  windowOf,
28} from './agents'
29
30const BLANK: Sess = { busy: false, startedAt: 0, lastActive: 0, doneAt: 0, tools: 0, edits: 0, prompt: '', lastTool: '', end: '' }
31const FLAT: Bar = { from: 0, to: 0, at: 0, ref: 0, movedAt: 0, bigAt: 0 }
32
33const run = atom({ plugin: 'crab-progress', key: 'run' } as const, null)
34const sess = atom({ plugin: 'crab-progress', key: 'sess' } as const, BLANK)
35const look = atom({ plugin: 'crab-progress', key: 'look' } as const, { outfit: 'idle', since: 0 })
36const now = atom({ plugin: 'crab-progress', key: 'now' } as const, 0)
37const bar = atom({ plugin: 'crab-progress', key: 'bar' } as const, FLAT)
38const char = atom({ plugin: 'crab-progress', key: 'char' } as const, 'claude')
39const agents = atom({ plugin: 'crab-progress', key: 'agents' } as const, [])
40const panel = atom({ plugin: 'crab-progress', key: 'panel' } as const, { isCompact: false, isDoneCollapsed: false })
41const files = atom({ plugin: 'crab-progress', key: 'files' } as const, { at: 0, names: [], paths: [] })
42
43const TOOL = 'mcp__crab-progress__progress'
44const STEP_TOOL = 'mcp__crab-progress__step'
45const PANE = 'crab-agents'
46const MAX_AGENTS = 200
47const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Glob', 'Grep'])
48const DIG_ACTIVE_MS = 8_000
49const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
50const TASK_OUTFITS = new Set<Outfit>(['coding', 'fixing', 'chef', 'working', 'reading', 'writing'])
51const HOLD_MS = 15_000
52const EASE_MS = 1_200
53// A rise of this much at once sends him sprinting; when it stops he pants.
54const BIG_JUMP = 0.12
55// An even bigger rise and he takes a car instead.
56const CAR_JUMP = 0.3
57const DRIVE_MS = 3_000
58const EXIT_MS = 1_500
59const RUN_MS = 1_800
60const PANT_MS = 3_500
61// The beat between the run (or the bar reaching its end) and the victory pose.
62const TRANS_MS = 1_400
63const SETTLE_MS = 400
64const STALL_MS = 5_000
65const MAX = 200
66const CLAY = '#D97757'
67const DONE = '#5fbf8f'
68const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
69const H = ROW_H
70
71const WORD: Record<Outfit, string> = {
72  idle: 'idle',
73  changing: 'changing',
74  run: 'running',
75  drive: 'driving',
76  exitCar: 'done',
77  medal: 'done',
78  winCode: 'done',
79  winFix: 'done',
80  winRead: 'done',
81  winWrite: 'done',
82  frozen: 'frozen',
83  pant: 'panting',
84  park: 'parking',
85  brace: 'finishing',
86  stroll: 'idle',
87  coding: 'coding',
88  fixing: 'fixing',
89  chef: 'cooking',
90  reading: 'reading',
91  writing: 'writing',
92  sleeping: 'zzz',
93  working: 'working',
94  done: 'done',
95  crown: 'done',
96  error: 'error',
97  greeting: 'hi',
98  walkCool: 'idle',
99  walkWizard: 'idle',
100  walkChef: 'idle',
101  walkCode: 'idle',
102  walkCmd: 'idle',
103  fly: 'idle',
104  sunbathe: 'chilling',
105  propeller: 'idle',
106  cool: 'idle',
107  dance: 'idle',
108}
109
110const clean = (n: unknown): number => (Number.isFinite(n) ? Math.min(MAX, Math.max(0, Math.round(n as number))) : 0)
111
112const targetOf = (r: Run | null, s: Sess, at: number): number =>
113  r && r.total > 0 ? Math.min(1, r.done / r.total) : autoRatio(s, at)
114
115// Where the eased bar is at time `at`.
116const shownAt = (b: Bar, at: number): number => b.from + (b.to - b.from) * Math.min(1, Math.max(0, (at - b.at) / EASE_MS))
117
118const isStalled = (s: Sess, b: Bar, at: number): boolean => s.busy && at - b.movedAt > STALL_MS
119
120// One step of the world: new clock, outfit, and the bar's next ease segment.
121async function refresh($: EngineInterface): Promise<void> {
122  const at = await $.clock.now()
123  const s = await read($, sess)
124  const target = targetOf(await read($, run), s, at)
125  await update($, now, () => at)
126  const b = await update($, bar, prev => {
127    // A drop (new prompt, done window over) snaps; a rise eases.
128    if (target < prev.to - 0.05) return { from: target, to: target, at, ref: target, movedAt: at, bigAt: 0 }
129    const isMoved = Math.abs(target - prev.ref) >= 0.02
130    const isChanged = Math.abs(target - prev.to) > 0.0005
131    return {
132      from: isChanged ? shownAt(prev, at) : prev.from,
133      to: isChanged ? target : prev.to,
134      at: isChanged ? at : prev.at,
135      ref: isMoved ? target : prev.ref,
136      movedAt: isMoved ? at : prev.movedAt,
137      bigAt: isChanged && target - prev.to >= BIG_JUMP ? at : prev.bigAt,
138      // A smaller jump right behind a big one keeps the big one's gear (no car cut short into a pant).
139      bigBy: isChanged && target - prev.to >= BIG_JUMP ? Math.max(target - prev.to, at - prev.bigAt < DRIVE_MS + PANT_MS ? prev.bigBy ?? 0 : 0) : prev.bigBy,
140    }
141  })
142  // A bar that has stopped moving falls back to the idle animations.
143  const want: Outfit = isStalled(s, b ?? FLAT, at) ? 'idle' : pickOutfit(s, at)
144  await update($, look, prev => {
145    if (prev.outfit === want) return prev
146    const isSticky = TASK_OUTFITS.has(prev.outfit) && TASK_OUTFITS.has(want) && at - prev.since < HOLD_MS
147    return isSticky ? prev : { outfit: want, since: at }
148  })
149}
150
151// Opens the agents panel, or closes it when it is up; true when it ends up open.
152async function togglePane($: EngineInterface): Promise<boolean> {
153  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
154  if (isOpen) {
155    await $.ui.close({ id: PANE })
156    return false
157  }
158  await $.ui.open({ id: PANE, title: 'Agents' })
159  return true
160}
161
162async function switchTo($: EngineInterface, name: Character): Promise<{ text: string }> {
163  await update($, char, () => name)
164  await $.store.set('mascot', name)
165  return { text: name === 'claude' ? 'Mascot is back to the crab.' : `Mascot is now the ${name}.` }
166}
167
168// `roam` is set while nothing is happening: he strolls about the empty bar on his own.
169// `change` is set during a costume change: he digs the next one out of a pocket.
170const rowSvg = (b: Bar, at: number, outfit: Outfit, W: number, isWalking: boolean, roam: { from: number; to: number } | null, character: Character, change?: { item: string; age: number; dest?: Dest }, settle?: number): string => {
171  const barW = Math.max(60, W - 58)
172  const px = (r: number): number => {
173    const w = Math.round(barW * r)
174    return w ? Math.max(w, BAR_H) : 0
175  }
176  const crabAt = (r: number): number => Math.max(0, Math.min(barW - CRAB_W, Math.round(barW * r) - CRAB_W / 2))
177  const cur = shownAt(b, at)
178  const rem = roam ? 1000 : Math.max(0, b.at + EASE_MS - at)
179  const travel = (x: number): number => Math.round((barW - CRAB_W) * x)
180  const fromW = px(cur)
181  const toW = px(b.to)
182  const crabX = roam ? travel(roam.to) : crabAt(b.to)
183  const dx = (roam ? travel(roam.from) : crabAt(cur)) - crabX
184  const color = WIN.has(outfit) && outfit !== 'crown' ? DONE : CLAY
185  const winW = Math.round(40 * PX)
186  const codeX = crabX + CRAB_W + winW + 10 > barW ? crabX - winW - 8 : crabX + CRAB_W + 8
187  const slide = rem > 0 ? `@keyframes gw{from{width:${fromW}px}to{width:${toW}px}}@keyframes cm{from{transform:translateX(${dx}px)}to{transform:translateX(0)}}.fill{animation:gw ${rem}ms linear forwards}.cm{animation:cm ${rem}ms linear forwards}` : ''
188  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${crabCss(at)}<style>${slide}</style>
189<rect class="k" x="0" y="${BAR_Y}" width="${barW}" height="${BAR_H}" rx="${BAR_H / 2}"/>
190<rect class="fill" x="0" y="${BAR_Y}" width="${toW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
191${outfit === 'idle' ? codeWindow(Math.round(codeX), BAR_Y - Math.round(19 * PX)) : ''}
192<g class="cm">${crabSvg(outfit, crabX, BAR_Y - CRAB_H, isWalking, character, change, settle)}</g>
193<text class="m" x="${W}" y="${BAR_Y + BAR_H}" text-anchor="end" font-family="${FONT}" font-size="14" font-variant-numeric="tabular-nums">${Math.round(cur * 100)}%</text>
194</svg>`
195}
196
197export const register: Register = (on, options) => {
198  // The file tree (merged from the filetree mod) registers its own hooks beside ours.
199  registerTree(on, options)
200
201  on('session.start', async ($, e, next) => {
202    const started = await next(e)
203    const at = await $.clock.now()
204    await update($, sess, s => ({ ...s, lastActive: at }))
205    await update($, now, () => at)
206    await $.tool.register({
207      name: 'progress',
208      description:
209        "Optional: report the main task's progress to the bar above the prompt. The bar already moves by itself; call with total (planned steps) and done (finished steps) only to show exact step progress.",
210      inputSchema: {
211        type: 'object',
212        properties: {
213          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
214          total: { type: 'integer', minimum: 1, description: 'Steps planned.' },
215          title: { type: 'string', description: 'Few-word name of the task.' },
216        },
217        required: ['done'],
218      },
219      isDeferred: false,
220    })
221    const saved = await $.store.get('mascot')
222    if (typeof saved === 'string' && (CHARACTERS as string[]).includes(saved)) await update($, char, () => saved as Character)
223    await $.command.register({
224      name: 'mascot',
225      description: `Change the mascot: /mascot ${CHARACTERS.join(' | ')}, or /mascot list`,
226    })
227    for (const name of CHARACTERS) {
228      if (name !== 'claude') await $.command.register({ name, description: `Turn the mascot into the ${name}` })
229    }
230    await $.tool.register({
231      name: 'step',
232      description:
233        'For subagents: report progress on your own task to the agents panel. Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; call it again as each step finishes. Cheap and silent: it only draws a bar.',
234      inputSchema: {
235        type: 'object',
236        properties: {
237          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
238          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
239          note: { type: 'string', description: 'The step in progress, a few words.' },
240        },
241        required: ['done'],
242      },
243      isDeferred: false,
244    })
245    await $.command.register({
246      name: 'agents-info',
247      description: 'Show or hide the panel of subagents: running and finished, with model, context, cost and time',
248    })
249    void $.ui.open({ id: PANE, title: 'Agents' })
250    $.clock.every(1000, () => void refresh($))
251    return started
252  })
253
254  on('prompt.submit', async ($, e, next) => {
255    const at = await $.clock.now()
256    const text = String((e as unknown as { text?: unknown }).text ?? '')
257    await update($, sess, () => ({ busy: true, startedAt: at, lastActive: at, doneAt: 0, tools: 0, edits: 0, prompt: text.slice(0, 300), lastTool: '', end: '' }))
258    await update($, run, () => null)
259    await update($, bar, () => ({ from: 0, to: 0, at, ref: 0, movedAt: at, bigAt: 0 }))
260    await refresh($)
261    return next(e)
262  })
263
264  on('tool.call', { tool: TOOL }, async ($, e) => {
265    if (e.agentId) return { result: 'ignored: only the main session reports' }
266    const input = e as unknown as { done?: number; total?: number; title?: string }
267    const next = await update($, run, prev => {
268      const total = clean(input.total ?? prev?.total)
269      return { title: input.title?.trim().slice(0, 120) || prev?.title || 'progress', total, done: Math.min(total || MAX, clean(input.done)) }
270    })
271    await refresh($)
272    return { result: `ok: ${next && next.total ? Math.round((next.done / next.total) * 100) : 0}%` }
273  })
274
275  on('tool.call', async ($, e, next) => {
276    const at = await $.clock.now()
277    if (FILE_TOOLS.has(e.tool)) {
278      const input = e as unknown as { file_path?: string; notebook_path?: string; path?: string; pattern?: string }
279      const target = input.file_path ?? input.notebook_path ?? input.path ?? input.pattern
280      if (typeof target === 'string' && target) {
281        const name = target.split(/[\\/]/).filter(Boolean).pop() ?? target
282        await update($, files, f => ({ at, names: [name, ...f.names.filter(n => n !== name)].slice(0, 3), paths: [target, ...f.paths.filter(p => p !== target)].slice(0, 3) }))
283      }
284    }    // A subagent's tool call changes its own row (and costume), not the main session's state.
285    if (e.agentId) {
286      const agentId = e.agentId
287      await update($, agents, list =>
288        list.map((a): AgentRun => {
289          if (a.agentId !== agentId) return a
290          const grown = { ...a, tools: (a.tools ?? 0) + 1, lastTool: e.tool, edits: (a.edits ?? 0) + (EDIT_TOOLS.has(e.tool) ? 1 : 0) }
291          const want = taskOutfit(grown, at)
292          const held = a.outfit && a.outfitAt !== undefined && at - a.outfitAt < HOLD_MS && TASK_OUTFITS.has(a.outfit) && TASK_OUTFITS.has(want)
293          return held ? grown : { ...grown, outfit: want, outfitAt: at }
294        }),
295      )
296      return next(e)
297    }    if (e.tool === 'TodoWrite' && !e.agentId) {
298      const todos = (e as unknown as { todos?: { status?: string }[] }).todos
299      if (Array.isArray(todos) && todos.length > 0) {
300        const list = todos.slice(0, MAX)
301        await update($, run, () => ({ title: 'todo', total: list.length, done: list.filter(t => t.status === 'completed').length }))
302      }
303    }
304    await update($, sess, s => ({
305      ...s,
306      busy: true,
307      startedAt: s.busy ? s.startedAt : at,
308      lastActive: at,
309      doneAt: 0,
310      tools: s.tools + 1,
311      lastTool: e.tool,
312      edits: s.edits + (EDIT_TOOLS.has(e.tool) ? 1 : 0),
313    }))
314    await refresh($)
315    return next(e)
316  })
317
318  on('turn.complete', async ($, e, next) => {
319    if (!e.agentId) {
320      const at = await $.clock.now()
321      const reason = (e as unknown as { reason?: string }).reason
322      await update($, sess, s => ({ ...s, busy: false, lastActive: at, doneAt: at, end: reason !== 'answer' ? 'fail' : isLong(s, at) ? 'long' : 'ok' }))
323      await update($, run, () => null)
324      await refresh($)
325    } else {
326      const at = await $.clock.now()
327      const agentId = e.agentId
328      const reason = (e as unknown as { reason?: string }).reason
329      const usage = (e as unknown as { usage?: { model?: string; input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number } }).usage
330      await update($, agents, list =>
331        list.map((a): AgentRun => {
332          if (a.agentId !== agentId) return a
333          // A run whose steps went unseen still gets the turn's own sum.
334          const fallback = a.steps === 0 && usage
335          return {
336            ...a,
337            status: reason === 'answer' ? 'done' : 'failed',
338            endedAt: at,
339            ...(fallback && usage
340              ? {
341                  model: usage.model || a.model,
342                  tokens: usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
343                  costUsd: costOf(usage.model || a.model, usage, false),
344                }
345              : {}),
346          }
347        }),
348      )
349    }
350    return next(e)
351  })
352
353  // A worker's own progress: the call runs in the worker's loop, so agentId names it.
354  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
355    const input = e as unknown as { done?: number; total?: number; note?: string }
356    const agentId = e.agentId
357    if (!agentId) return { result: 'ignored: only subagents report steps' }
358    let matched = false
359    await update($, agents, list =>
360      list.map(a => {
361        if (a.agentId !== agentId) return a
362        matched = true
363        const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
364        const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
365        return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
366      }),
367    )
368    return { result: matched ? 'ok' : `ignored: no panel row for agent ${agentId}` }
369  })
370
371  on('agent.spawn', async ($, e, next) => {
372    const started = await next(e)
373    if (started.deny !== undefined) return started
374    const at = await $.clock.now()
375    await update($, agents, list => {
376      const round = 1 + list.filter(a => e.description && sameTask(a.description, e.description)).length
377      const run: AgentRun = {
378        id: started.agentId ?? e.tool_use_id,
379        agentId: started.agentId,
380        type: e.subagentType,
381        description: e.description,
382        model: started.model,
383        status: 'running',
384        startedAt: at,
385        contextTokens: 0,
386        contextMax: windowOf(started.model),
387        tokens: 0,
388        costUsd: 0,
389        steps: 0,
390        round,
391      }
392      return [...list.filter(a => a.id !== run.id), run].slice(-MAX_AGENTS)
393    })
394    return started
395  })
396
397  // Each model request of a subagent: live context, tokens and cost.
398  on('turn.step', async function* ($, e, next) {
399    const result = yield* next(e)
400    const agentId = e.agentId
401    const usage = result.usage
402    if (!agentId || !usage) return result
403    const model = usage.model || e.model
404    await update($, agents, list =>
405      list.map((a): AgentRun => {
406        if (a.agentId !== agentId) return a
407        const prompt = (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0)
408        return {
409          ...a,
410          model,
411          effort: typeof e.effort === 'string' ? e.effort : a.effort,
412          status: 'running',
413          endedAt: undefined,
414          contextTokens: prompt + (usage.output_tokens || 0),
415          contextMax: windowOf(model),
416          tokens: a.tokens + prompt + (usage.output_tokens || 0),
417          costUsd: a.costUsd + costOf(model, usage, true),
418          steps: a.steps + 1,
419        }
420      }),
421    )
422    return result
423  })
424
425  on('command.run', { command: 'agents-info' }, async $ => {
426    const isOpen = await togglePane($)
427    return { text: isOpen ? 'Agents panel opened.' : 'Agents panel closed.' }
428  })
429
430  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
431    const ui = $.ui.resolve(e)
432    const { Box, Text, Button } = ui
433    const list = await read($, agents)
434    const p: Panel = await read($, panel)
435    await read($, now) // redraw on every tick
436    const at = await $.clock.now()
437    const character = await read($, char)
438    const touched = await read($, files)
439    const isDigging = touched.at > 0 && at - touched.at <= DIG_ACTIVE_MS
440    const running = list.filter(a => a.status === 'running').reverse()
441    const finished = list.filter(a => a.status !== 'running').reverse()
442    const t = totals(list, at)
443
444    const toggleCompact = (
445      <Button key="compact" label={p.isCompact ? 'Expand' : 'Collapse'} plain onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))} />
446    )
447    const toggleDone = (
448      <Button
449        key="done"
450        label={`${p.isDoneCollapsed ? '\u25b8' : '\u25be'} Finished \u00b7 ${finished.length}`}
451        plain
452        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
453      />
454    )
455
456    if (e.surface !== 'terminal' && 'Svg' in ui) {
457      const { Svg } = ui
458      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
459      if (list.length > 0 && p.isCompact) {
460        return (
461          <Box flexDirection="column" gap={1}>
462            {isDigging && <Svg source={digSvg(at, touched.names, character)} alt="Claude digging through files" width={DIG_W} height={DIG_H} />}
463            <Svg source={compactSvg(W, list, at, t, character)} alt={`${list.length} agents, ${summaryOf(t)}`} width={W} height={40} />
464            {toggleCompact}
465          </Box>
466        )
467      }
468      const shownDone = p.isDoneCollapsed ? 0 : finished.length
469      const lines = 1 + (list.length === 0 ? 1 : 0) + (running.length > 0 ? 1 : 0) + (finished.length > 0 ? 1 : 0)
470      const gridH = (isDigging ? DIG_H : 0) + HEADER_H + lines * LINE_H + (running.length + shownDone) * AGENT_ROW_H
471      return (
472        <Box flexDirection="column">
473          {gridTiles(gridH).map((tileH, i) => (
474            <Svg key={`grid-${i}`} source={gridSvg(W, tileH)} alt="dotted grid" width={W} height={tileH} />
475          ))}
476          <Box flexDirection="column" position="absolute" top={0} left={0} right={0}>
477            {isDigging && <Svg source={digSvg(at, touched.names, character)} alt="Claude digging through files" width={DIG_W} height={DIG_H} />}
478            <Svg source={headerSvg(W, at, t)} alt={summaryOf(t)} width={W} height={HEADER_H} />
479            {toggleCompact}
480            {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
481            {running.length > 0 && <Text dimColor>{`Running \u00b7 ${running.length}`}</Text>}
482            {running.map(a => (
483              <Svg key={a.id} source={agentSvg(W, a, at, character)} alt={`${a.description}: ${modelName(a.model)}, running`} width={W} height={AGENT_ROW_H} />
484            ))}
485            {finished.length > 0 && toggleDone}
486            {!p.isDoneCollapsed &&
487              finished.map(a => (
488                <Svg key={a.id} source={agentSvg(W, a, at, character)} alt={`${a.description}: ${modelName(a.model)}, finished`} width={W} height={AGENT_ROW_H} />
489              ))}
490          </Box>
491        </Box>
492      )
493    }
494
495    // Terminal: the same rows in text.
496    const cols = Math.max(24, e.props.bodyColumns || 40)
497    const barW = Math.max(6, Math.min(20, cols - 34))
498    const row = (a: AgentRun) => {
499      const l = agentLine(a, at)
500      return (
501        <Box key={a.id} flexDirection="column" marginBottom={1}>
502          <Text bold wrap="truncate-end">{l.title}</Text>
503          <Text dimColor wrap="truncate-end">{l.meta}</Text>
504          <Text wrap="truncate-end">
505            <Text color="#D97757">{ctxBar(l.progress === null ? l.ctx : l.progress * 100, barW)}</Text>
506            <Text dimColor>{` ${l.stats}`}</Text>
507          </Text>
508        </Box>
509      )
510    }
511    return (
512      <Box flexDirection="column">
513        <Box flexDirection="row" justifyContent="space-between">
514          <Text dimColor>{summaryOf(t)}</Text>
515          {toggleCompact}
516        </Box>
517        {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
518        {!p.isCompact && running.map(row)}
519        {finished.length > 0 && toggleDone}
520        {!p.isCompact && !p.isDoneCollapsed && finished.map(row)}
521      </Box>
522    )
523  })
524
525  on('command.run', { command: 'mascot' }, async ($, e) => {
526    const arg = (e.args ?? '').trim().toLowerCase()
527    const list = `Mascots: ${CHARACTERS.join(', ')}. Use /mascot <name>.`
528    if (!arg || arg === 'list') return { text: list }
529    const name = CHARACTERS.find(c => c === arg)
530    return name ? switchTo($, name) : { text: `Unknown mascot "${arg}". ${list}` }
531  })
532
533  for (const name of CHARACTERS) {
534    if (name !== 'claude') on('command.run', { command: name }, async $ => switchTo($, name))
535  }
536
537  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
538    if (e.props.hasSurvey) return next(e)
539    const s = await read($, sess)
540    const b = await read($, bar)
541    await read($, now) // redraw on every tick; the drawing itself uses the real clock
542    const at = await $.clock.now()
543    const lk = await read($, look)
544    const outfit = lk.outfit
545    const character = await read($, char)
546    const cur = shownAt(b, at)
547    const pct = `${Math.round(cur * 100)}%`
548    const drawn = await Promise.resolve(next(e)).catch(() => null)
549    const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
550    // keepwarm's cache-tax line says the prompt cache is cold: he is frozen in a cube of ice.
551    const isIced = !s.busy && isIcedNow(below, s.doneAt, at)
552    const isRoaming = !s.busy && outfit === 'idle' && !isIced
553    const roam = isRoaming ? { now: roamAt(at), next: roamAt(at + 1000) } : null
554    // An explicit progress that hits 100% while the turn still runs is a win too: no panting.
555    const isFull = !isIced && b.to >= 0.999 && outfit !== 'error'
556    const isWin = WIN.has(outfit) || isFull
557    const jumped = b.bigAt > 0 && b.bigAt >= s.startedAt
558    // Victory depends on how the turn went: out of the car, a medal, or the task's own pose.
559    const pose: Outfit = roam ? roam.now.pose : outfit === 'done' || (isFull && !WIN.has(outfit)) ? winPose(s, b.bigBy ?? 0, jumped) : outfit
560    const jumpAge = b.bigAt > 0 ? at - b.bigAt : Infinity
561    const isCar = (b.bigBy ?? 0) >= CAR_JUMP
562    const sprintMs = isCar ? DRIVE_MS : RUN_MS
563    const isRun = !roam && !isIced && jumpAge < sprintMs
564    // Between the run (or the bar reaching its end) and the victory pose: a short beat of its own.
565    const arriveAt = b.to >= 0.999 ? b.at + EASE_MS : lk.since
566    const transEnd = Math.max(arriveAt, jumped ? b.bigAt + sprintMs : 0) + TRANS_MS
567    const isTrans = !roam && !isIced && !isRun && isWin && at < transEnd
568    const age = roam ? roam.now.age : isWin ? at - transEnd : at - lk.since
569    const item = ITEM_COLOR[pose]
570    // After a sprint he pants; after the car he just climbs out. Neither once the turn is won.
571    const isPant = !roam && !isIced && !isRun && !isCar && !isWin && jumpAge < sprintMs + PANT_MS
572    const isExit = !roam && !isIced && !isRun && isCar && !isWin && jumpAge < sprintMs + EXIT_MS
573    const isChanging = !isIced && !isRun && !isTrans && !isPant && !isExit && item !== undefined && age < CHANGE_MS
574    const transPose: Outfit = pose === 'exitCar' ? 'park' : pose === 'medal' ? 'pant' : 'brace'
575    const shown: Outfit = isIced ? 'frozen' : isRun ? (isCar ? 'drive' : 'run') : isTrans ? transPose : isPant ? 'pant' : isExit ? 'exitCar' : isChanging ? 'changing' : pose
576    // Just landed in a new look: a quick squash-and-stretch settle.
577    const settle = isIced || isRun || isTrans || isPant || isExit || isChanging ? undefined : age - (item === undefined ? 0 : CHANGE_MS)
578    const isWalking = isIced || isRun || isTrans || isPant || isExit || isWin ? false : roam ? MOVING.has(pose) && pose !== 'fly' : s.busy && !isStalled(s, b, at) && outfit !== 'idle'
579    const word = isIced ? 'frozen' : WORD[shown]
580
581    const ui = $.ui.resolve(e)
582    const { Box, Text } = ui
583
584    if (e.surface !== 'terminal' && 'Svg' in ui) {
585      const { Svg } = ui
586      const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 40))
587      return (
588        <Box flexDirection="column">
589          <Svg source={rowSvg(b, at, shown, width, isWalking, roam ? { from: roam.now.x, to: roam.next.x } : null, character, isChanging ? { item, age, dest: ITEM_DEST[pose] } : undefined, settle)} alt={`${word}: ${pct}`} width={width} height={H} />
590          {below}
591        </Box>
592      )
593    }
594
595    const width = Math.max(6, Math.min(60, (e.props.bodyColumns || 80) - 24))
596    const filled = Math.round(width * cur)
597    return (
598      <Box flexDirection="column">
599        <Box flexDirection="row" gap={1}>
600          <Text color={outfit === 'done' ? DONE : CLAY}>{'█'.repeat(filled) + '░'.repeat(width - filled)}</Text>
601          <Text dimColor>{pct}</Text>
602          <Text dimColor>{isIced ? 'frozen' : WORD[outfit]}</Text>
603        </Box>
604        {below}
605      </Box>
606    )
607  })
608}
609
hooks/dig.ts 90 lines
1import type { Character } from '../types'
2import { crabCss, crabSvg } from './crab'
3
4// The side-panel scene: while files are being touched, a crab dives into a file, throws things
5// out until he finds something, pops up with it, jumps into another file and peeks out of a
6// third. One 10 s loop of CSS, started mid-cycle so each redraw carries on where it was.
7export const DIG_W = 236
8export const DIG_H = 100
9export const DIG_LOOP = 10
10const G = 60 // ground line
11const FILE_W = 54
12const XS = [8, 90, 172] as const // file left edges: A (dive in), B (jump in), C (peek out)
13const HIDE = 34 // fully behind the file front (its feet stay above the panel's bottom edge)
14const PEEK = 12 // eyes and the top of the head show above the file front
15
16const xml = (s: string): string => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
17const base = (p: string): string => {
18  const name = p.split(/[\\/]/).filter(Boolean).pop() ?? p
19  return name.length > 8 ? `${name.slice(0, 7)}…` : name
20}
21
22const ITEMS = ['#EEEDE8', '#8E929A', '#D9A23A', '#7FC4F2', '#C0705A', '#4FA37A', '#6A50AA', '#F5C542']
23
24// Deterministic spread so each item flies its own way.
25const spread = (i: number): { dx: number; h: number } => ({
26  dx: ((i * 37) % 90) - 45,
27  h: 26 + ((i * 53) % 30),
28})
29
30export const digSvg = (at: number, names: string[], character: Character): string => {
31  const t = (at / 1000) % DIG_LOOP
32  const delay = `-${t.toFixed(3)}s`
33  const cx = (i: number): number => (XS[i] ?? 0) + (FILE_W - 30) / 2
34  const [a, b, c] = [cx(0), cx(1), cx(2)]
35  const dur = `${DIG_LOOP}s`
36
37  const items = ITEMS.map((color, i) => {
38    const s = 17 + i * 5 // percent of the loop each throw starts at
39    const { dx, h } = spread(i)
40    const name = `th${i}`
41    const kf = `@keyframes ${name}{0%,${s}%{opacity:0;transform:translate(0,0)}${s + 0.5}%{opacity:1}${s + 2}%{transform:translate(${dx / 2}px,${-h}px)}${s + 4.5}%{opacity:1;transform:translate(${dx}px,-6px)}${s + 5.5}%,100%{opacity:0;transform:translate(${dx}px,6px)}}`
42    const rect = `<g style="animation:${name} ${dur} linear infinite ${delay}"><rect x="${(XS[0] ?? 0) + FILE_W / 2 - 3}" y="${G - 12}" width="${i % 2 ? 7 : 6}" height="${i % 3 ? 6 : 8}" fill="${color}"/></g>`
43    return { kf, rect }
44  })
45
46  // x/y of the crab over the loop: walk to A, dive, hidden while throwing, pop up, walk to B,
47  // dive, travel unseen to C, peek, climb out, and back to the start.
48  const path = `@keyframes path{
490%{transform:translate(${c}px,0)}
5010%{transform:translate(${a}px,0)}
5114%{transform:translate(${a}px,${HIDE}px)}
5260%{transform:translate(${a}px,${HIDE}px)}
5366%{transform:translate(${a}px,-6px)}
5470%{transform:translate(${a}px,0)}
5578%{transform:translate(${b}px,0)}
5682%{transform:translate(${b}px,${HIDE}px)}
5786%{transform:translate(${c}px,${HIDE}px)}
5890%{transform:translate(${c}px,${PEEK}px)}
5996%{transform:translate(${c}px,${PEEK}px)}
60100%{transform:translate(${c}px,0)}}`
61  const gem = `@keyframes gem{0%,61%{opacity:0}62%,71%{opacity:1}72%,100%{opacity:0}}`
62
63  const files = XS.map((x, i) => {
64    const label = xml(base(names[i] ?? ['index', 'src', 'README'][i] ?? 'file'))
65    return {
66      back: `<rect class="fb" x="${x}" y="${G - 16}" width="${FILE_W}" height="46" rx="4"/>`,
67      front: `<rect class="ff" x="${x}" y="${G + 2}" width="${FILE_W}" height="${DIG_H - (G + 2)}" rx="4"/><text class="ft" x="${x + FILE_W / 2}" y="${G + 20}" text-anchor="middle" font-size="9" font-family="-apple-system,'Segoe UI',sans-serif">${label}</text>`,
68    }
69  })
70
71  return `<svg xmlns="http://www.w3.org/2000/svg" width="${DIG_W}" height="${DIG_H}" viewBox="0 0 ${DIG_W} ${DIG_H}">${crabCss(at)}
72<style>
73.bg{fill:rgba(255,255,255,.78)}.fb{fill:#cfccc4}.ff{fill:#e4e1da}.ft{fill:#4a4a47}
74@media (prefers-color-scheme: dark){.bg{fill:rgba(24,24,23,.82)}.fb{fill:#3a3a38}.ff{fill:#2c2c2b}.ft{fill:#b8b8b4}}
75g{transform-box:fill-box}
76${path}
77${gem}
78${items.map(i => i.kf).join('\n')}
79</style>
80<rect class="bg" x="0" y="0" width="${DIG_W}" height="${DIG_H}" rx="12"/>
81${files.map(f => f.back).join('')}
82${items.map(i => i.rect).join('')}
83<g style="animation:path ${dur} linear infinite ${delay}">
84${crabSvg('working', 0, G - 27, true, character)}
85<rect x="24" y="${G - 36}" width="8" height="8" fill="#F5C542" style="animation:gem ${dur} linear infinite ${delay}"/>
86</g>
87${files.map(f => f.front).join('')}
88</svg>`
89}
90
hooks/ft-register.tsx 1642 lines
1import { type BuiltinToolResults, type EngineInterface, type Register, type Timer, update } from 'claude-code'
2import { LINUX_LAUNCH, openCommand, openWithCommand } from './ft-open'
3import type { ButtonProps } from './ft-button'
4
5import type { Activity, Character, FileNode, FileTree, Files, Outfit, Theme } from '../types'
6import { crabCss, crabSvg } from './crab'
7import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve, TONES } from './ft-git'
8import type { DigSpec, RowSpec, RowsProps, Seg } from './ft-rows'
9import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR } from './ft-icons'
10import {
11  age,
12  ancestorsOf,
13  type Change,
14  DEFAULT_THEME,
15  dirname,
16  emptyTree,
17  formatSize,
18  inside,
19  isAbsolute,
20  join,
21  mapper,
22  parseGit,
23  parseNumstat,
24  parseTheme,
25  posix,
26  relative,
27  dropBelow,
28  useDrives,
29  rollCounts,
30  replaceChildren,
31  rollUp,
32  stamp,
33  toNodes,
34  underAny,
35  visibleRows,
36} from './ft-tree'
37
38const TREE = { plugin: 'crab-progress', key: 'tree' } as const
39const THEME = { plugin: 'crab-progress', key: 'theme' } as const
40const ACTIVITY = { plugin: 'crab-progress', key: 'activity' } as const
41const PANE = 'filetree'
42const LOOK = { plugin: 'crab-progress', key: 'look' } as const
43const CHAR = { plugin: 'crab-progress', key: 'char' } as const
44const FILES = { plugin: 'crab-progress', key: 'files' } as const
45// The digging scene shows while a file was touched this recently.
46const DIG_ACTIVE_MS = 8_000
47const CHAR_COLOR: Record<string, string> = { claude: '#D97757', frog: '#C66B3E', creeper: '#5DAA4E', slime: '#6CC25F', robot: '#9AA4B0', cat: '#E39A4A' }
48
49// The three rows the crab works in: the touched file (or the first file shown), then two others.
50function pickDig(specs: RowSpec[], paths: string[]): [number, number, number] | undefined {
51  const files = specs.flatMap((s, i) => (s.kind === 'file' ? [i] : []))
52  const first = files[0]
53  if (first === undefined) return undefined
54  const base = (p: string): string => p.split('/').pop() ?? p
55  const want = paths.map(p => p.replace(/\\/g, '/').toLowerCase())
56  const hit = files.find(i => {
57    const id = (specs[i]?.id ?? '').replace(/\\/g, '/').toLowerCase()
58    return want.some(w => w === id || w.endsWith(id) || id.endsWith(w) || base(w) === base(id))
59  })
60  const a = hit ?? first
61  const rest = files.filter(i => i !== a)
62  const b = rest.find(i => i > a + 2) ?? rest[0] ?? a
63  const c = [...rest].reverse().find(i => i !== b) ?? b
64  return [a, b, c]
65}
66const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
67const BRANCH_ROW = '#branch'
68const FLASH_MS = 2700
69const RUNNING_MAX_MS = 600_000
70const NO_REPO_DEPTH = 4
71const DOUBLE_MS = 450
72const FIND_LIMIT = 200
73const ROWS_BUDGET = 80_000
74const READ_REVEAL_LIMIT = 12
75const SEARCH_REVEAL_LIMIT = 60
76const ACTIVITY_TTL_MS = 45_000
77const ADD_COLOR = '#98c379'
78const DEL_COLOR = '#e06c75'
79const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
80const THEME_POLL_MS = 2000
81const FONT_SCRIPT =
82  'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
83  'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
84  '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
85  '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 ' +
86  'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
87  '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; ' +
88  'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
89const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
90const HOME_PRUNE = ['Library', 'AppData', '.Trash']
91const SIZE_TIMEOUT_MS = 15_000
92const SIZE_JOBS = 2
93const SIZE_WALK_LIMIT = 50_000
94const AGE_TICK_MS = 60_000
95
96let blink: Timer | null = null
97let themePoll: Timer | null = null
98let ageTick: Timer | null = null
99let themeMtime: number | null = null
100let generation = 0
101let lastPress = { key: '', at: 0 }
102let noNerd = false
103let glyphSetting = 'auto'
104let openWithSetting = ''
105let previewed: { path: string; text: string; note: string } | null = null
106let previewRows = 20
107let follow = true
108let followClaude = true
109let scanning: Promise<void> | null = null
110let scanJobs: Job[] = []
111let gitRun: Promise<void> | null = null
112let gitNext: Promise<void> | null = null
113let queuedReads: string[] = []
114let showReads = true
115let showWrites = true
116let searchIndex: { root: string; paths: Promise<string[]> } | null = null
117let activityId = 0
118let pointer = true
119let hovered = ''
120let view = { from: 0, max: 0 }
121let nativeScroll = false
122let lastSync = 0
123let noDock = false
124let home = ''
125let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
126let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
127let markId = 0
128let listSeq = 0
129let lastRoot = ''
130let sizeDefault = false
131let showAge = false
132let showHints = true
133let sizeEpoch = 0
134let sizeActive = 0
135let sizeQueue: string[] = []
136const sizing = new Set<string>()
137const listLatest = new Map<string, number>()
138const unreadable = new Set<string>()
139const background = new Map<string, Background>()
140
141function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
142  platform ??= (async () => {
143    if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
144    try {
145      return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
146    } catch {
147      return 'linux'
148    }
149  })()
150  return platform
151}
152
153async function cwdOf($: EngineInterface): Promise<string> {
154  return posix(await $.session.cwd())
155}
156
157function clean(segs: Seg[]): Seg[] {
158  for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
159  return segs
160}
161
162function faint(hex: string): string {
163  const v = parseInt(hex.slice(1), 16)
164  const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
165  return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
166}
167
168async function get($: EngineInterface): Promise<FileTree> {
169  return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
170}
171
172async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
173  await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
174}
175
176function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
177  return put($, t => ({ ...t, ...fn(t) }))
178}
179
180async function activities($: EngineInterface): Promise<Activity[]> {
181  return (await $.state.get(ACTIVITY)).value ?? []
182}
183
184async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
185  await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
186}
187
188async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
189  try {
190    const entries = await $.fs.list(dir)
191    const resolved = await Promise.all(
192      entries.map(async e => {
193        if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
194        try {
195          const target = await $.fs.stat(join(dir, e.name))
196          return target.kind === 'dir'
197            ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
198            : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
199        } catch {
200          return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
201        }
202      }),
203    )
204    unreadable.delete(dir)
205    return toNodes(dir, resolved)
206  } catch (err) {
207    if (!(await exists($, dir))) return []
208    if (!unreadable.has(dir)) {
209      unreadable.add(dir)
210      $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
211    }
212    return null
213  }
214}
215
216async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
217  const root = (await get($)).root
218  const seq = ++listSeq
219  for (const dir of dirs) listLatest.set(dir, seq)
220  const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
221  const listed = new Map<string, FileNode[]>()
222  for (const [dir, kids] of results) {
223    if (listLatest.get(dir) !== seq) continue
224    listLatest.delete(dir)
225    if (kids) listed.set(dir, kids)
226  }
227  await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
228  return listed
229}
230
231async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
232  return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
233}
234
235async function detectRepo($: EngineInterface): Promise<void> {
236  const t = await get($)
237  if (!t.root) return
238  let top = ''
239  let prefix = ''
240  try {
241    const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
242    const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
243    prefix = p.trim()
244    top = tl.trim()
245  } catch {
246    top = ''
247  }
248  await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
249}
250
251async function readGit($: EngineInterface): Promise<void> {
252  const t = await get($)
253  if (!t.root || !t.top) return
254  const root = t.root
255  try {
256    const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
257    if (status.exitCode !== 0) return
258    const parsed = parseGit(status.stdout, root, t.prefix)
259    const diff: Record<string, [number, number]> = {}
260    const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
261    if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
262    const files = { ...parsed.files }
263    if (parsed.untrackedDirs.length) {
264      const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
265      if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
266    }
267    for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
268    dirty = { root, files }
269    await patch($, cur =>
270      cur.root === root
271        ? {
272            git: parsed.git,
273            ignored: parsed.ignored,
274            untrackedDirs: parsed.untrackedDirs,
275            branch: parsed.branch,
276            counts: rollCounts(files, root),
277            diff: rollUp(diff, root),
278          }
279        : {},
280    )
281  } catch {
282    return
283  }
284}
285
286function refreshGit($: EngineInterface): Promise<void> {
287  if (!gitRun) {
288    gitRun = readGit($).finally(() => {
289      gitRun = null
290    })
291    return gitRun
292  }
293  const rerun = () => {
294    gitNext = null
295    return refreshGit($)
296  }
297  gitNext ??= gitRun.then(rerun, rerun)
298  return gitNext
299}
300
301async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
302  const prev = await get($)
303  generation += 1
304  blink?.cancel()
305  blink = null
306  lastRoot = root
307  searchIndex = null
308  sizeEpoch += 1
309  sizeQueue = []
310  await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
311  const title = `Files: ${root.split('/').pop() || root}`
312  if (focus) await $.ui.open({ id: PANE, title, focus: true })
313  else if (!noDock) await $.ui.open({ id: PANE, title })
314  await loadDirs($, [root])
315  await detectRepo($)
316  await refreshGit($)
317}
318
319async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
320  const tried = new Set<string>()
321  for (let pass = 0; pass < 32; pass++) {
322    const t = await get($)
323    const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
324    if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
325    const need = new Set<string>()
326    for (const p of paths) {
327      for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
328        if (!loaded.has(dir)) {
329          if (!tried.has(dir)) need.add(dir)
330          break
331        }
332      }
333    }
334    if (need.size === 0) return
335    for (const dir of need) tried.add(dir)
336    await loadDirs($, [...need])
337  }
338}
339
340function pruneArgs(root: string, ignored: string[] = []): string[] {
341  const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
342  return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
343}
344
345async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
346  const stats = await Promise.all(
347    paths.map(async p => {
348      try {
349        return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
350      } catch {
351        return ''
352      }
353    }),
354  )
355  return stats.filter(Boolean)
356}
357
358function broad(root: string): boolean {
359  return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
360}
361
362function rootOfDisk(root: string): boolean {
363  return root === '/' || /^[A-Za-z]:\/$/.test(root)
364}
365
366async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
367  if (rootOfDisk(root)) return []
368  const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
369  try {
370    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 })
371    return run.stdout.split('\0').filter(p => p && p !== root)
372  } catch {
373    return []
374  }
375}
376
377async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
378  if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
379  if (dirty.root !== root) return { hits: [], gone: [] }
380  const entries = Object.entries(dirty.files)
381  const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
382  const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
383  return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
384}
385
386type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
387
388async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
389  const ms = await $.clock.now()
390  const os = await osName($)
391  if (os !== 'darwin' || !writes) return { ms, mark: '', os }
392  const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
393  try {
394    await $.process.run(['touch', mark], { timeoutMs: 3_000 })
395    return { ms, mark, os }
396  } catch {
397    return { ms, mark: '', os }
398  }
399}
400
401async function walkSize($: EngineInterface, dir: string): Promise<number> {
402  let total = 0
403  let seen = 0
404  let level = [dir]
405  while (level.length) {
406    const next: string[] = []
407    for (const d of level) {
408      let entries
409      try {
410        entries = await $.fs.list(d)
411      } catch {
412        continue
413      }
414      for (const e of entries) {
415        if (++seen > SIZE_WALK_LIMIT) return -1
416        if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
417        else if (e.kind === 'file') total += e.size
418      }
419    }
420    level = next
421  }
422  return total
423}
424
425async function dirSize($: EngineInterface, dir: string): Promise<number> {
426  if (rootOfDisk(dir)) return -1
427  if ((await osName($)) === 'win32') return walkSize($, dir)
428  try {
429    const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
430    const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
431    return Number.isFinite(kb) ? kb * 1024 : -1
432  } catch {
433    return -1
434  }
435}
436
437function pumpSizes($: EngineInterface): void {
438  while (sizeActive < SIZE_JOBS && sizeQueue.length) {
439    const dir = sizeQueue.shift() ?? ''
440    const epoch = sizeEpoch
441    sizing.add(dir)
442    sizeActive += 1
443    void dirSize($, dir)
444      .then(async bytes => {
445        if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
446        const cur = await get($)
447        if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
448      })
449      .catch(() => undefined)
450      .finally(() => {
451        sizing.delete(dir)
452        sizeActive -= 1
453        pumpSizes($)
454      })
455  }
456}
457
458function wantSizes($: EngineInterface, dirs: string[]): void {
459  for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
460  pumpSizes($)
461}
462
463async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
464  sizeEpoch += 1
465  sizeQueue = []
466  await patch($, cur => {
467    if (!paths) return { dirSizes: {} }
468    const keep: Record<string, number> = {}
469    for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
470    return { dirSizes: keep }
471  })
472}
473
474function openDirs(t: FileTree): string[] {
475  const open = new Set(t.expanded)
476  return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
477}
478
479function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
480  if (followClaude) return { scroll: null }
481  const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
482  const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
483  return at < 0 ? {} : { scroll: at }
484}
485
486async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
487  const unique = Object.keys(tones)
488  if (unique.length === 0) return
489  const mine = ++generation
490  const root = (await get($)).root
491  blink?.cancel()
492  blink = null
493  await patch($, cur => {
494    if (cur.root !== root) return {}
495    const open = new Set(cur.expanded)
496    const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
497    const dim = new Set<string>()
498    const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
499    if (cur.flashOn) {
500      for (const id of cur.flashDim) dim.add(id)
501    }
502    for (const id of unique) {
503      const tone = tones[id] ?? 'orange'
504      all[id] = tone
505      if (id === BRANCH_ROW) continue
506      const chain = ancestorsOf(id, cur.root)
507      if (!chain.some(a => !open.has(a))) continue
508      for (const a of chain) {
509        if (!open.has(a)) {
510          dim.add(a)
511          all[a] = all[a] ?? tone
512        }
513        open.add(a)
514      }
515    }
516    for (const id of bright) dim.delete(id)
517    return {
518      flash: [...bright],
519      flashDim: [...dim],
520      flashOn: true,
521      flashTones: all,
522      expanded: [...open],
523      ...keepTop(cur, [...open]),
524    }
525  })
526  if (generation !== mine) return
527  blink = $.clock.after(FLASH_MS, () => {
528    if (generation !== mine) return
529    blink = null
530    void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
531  })
532}
533
534async function followCwd($: EngineInterface): Promise<boolean> {
535  if (!follow) return false
536  const cwd = await cwdOf($)
537  const t = await get($)
538  if (t.root === cwd) return false
539  await reset($, cwd)
540  return true
541}
542
543type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
544type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
545type Background = { p: Pending | null; job: Job; reads: string[] }
546type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
547type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
548
549const PROOF: Record<string, (op: GitOperation) => boolean> = {
550  push: op => Boolean(op.push),
551  merge: op => op.branch?.action === 'merged',
552  rebase: op => op.branch?.action === 'rebased',
553  'pr create': op => op.pr?.action === 'created',
554  'pr merge': op => op.pr?.action === 'merged',
555  'pr comment': op => op.pr?.action === 'commented',
556}
557
558async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
559  const now = await $.clock.now()
560  const ids = actions.map(() => ++activityId)
561  await setActivities($, cur => [
562    ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
563    ...actions.map((a, i) => ({
564      id: ids[i] ?? 0,
565      kind: a.kind,
566      label: a.running,
567      state: 'running' as const,
568      detail: '',
569      at: now,
570      tone: a.tone,
571      nerd: a.icon.nerd,
572      plain: a.icon.plain,
573    })),
574  ])
575  return ids
576}
577
578async function headOf($: EngineInterface, cwd: string): Promise<string> {
579  try {
580    const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
581    return run.exitCode === 0 ? run.stdout.trim() : ''
582  } catch {
583    return ''
584  }
585}
586
587async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
588  const chain = chainOf(p.command)
589  const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
590  return p.actions.map(a => {
591    if (a.verb === 'commit') {
592      const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
593      return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
594    }
595    if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
596    if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
597    return { state: 'done', label: `ran ${a.kind}`, detail: '' }
598  })
599}
600
601async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
602  const now = await $.clock.now()
603  await setActivities($, cur =>
604    cur.map(a => {
605      const i = p.ids.indexOf(a.id)
606      const result = i < 0 ? undefined : results[i]
607      return result ? { ...a, ...result, at: now } : a
608    }),
609  )
610  $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
611}
612
613function failAll(p: Pending): Outcome[] {
614  return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
615}
616
617function proven(p: Pending | null, results: Outcome[]): GitAction[] {
618  return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
619}
620
621function taskEnds(text: string): [string, string][] {
622  return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
623    const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
624    const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
625    return id && status ? [[id, status] as [string, string]] : []
626  })
627}
628
629async function settleBackground($: EngineInterface, text: string): Promise<void> {
630  if (background.size === 0 || !text.includes('<task-notification>')) return
631  for (const [id, status] of taskEnds(text)) {
632    const task = background.get(id)
633    if (!task) continue
634    background.delete(id)
635    const ok = status === 'completed'
636    const results = task.p ? await outcomes($, task.p, ok, undefined) : []
637    if (task.p) await finishGit($, task.p, results)
638    if (showReads) queuedReads.push(...task.reads)
639    scheduleScan($, { ...task.job, actions: proven(task.p, results) })
640  }
641}
642
643async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
644  const map = mapper(root, prefix)
645  try {
646    const run = committed
647      ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
648      : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
649    return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
650  } catch {
651    return []
652  }
653}
654
655async function gitDir($: EngineInterface, cwd: string): Promise<string> {
656  for (let dir = cwd, i = 0; i < 64; i++) {
657    try {
658      const dot = join(dir, '.git')
659      const st = await $.fs.stat(dot)
660      if (st.kind === 'dir') return dot
661      const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
662      if (ref) return resolve(dir, ref, home)
663    } catch {
664      const up = dirname(dir)
665      if (up === dir) return ''
666      dir = up
667      continue
668    }
669    return ''
670  }
671  return ''
672}
673
674async function sync($: EngineInterface, force = false): Promise<void> {
675  const now = await $.clock.now()
676  if (!force && now - lastSync < 2_000) return
677  lastSync = now
678  searchIndex = null
679  const t = await get($)
680  if (!t.root) return
681  await loadDirs($, [t.root, ...openDirs(t)])
682  if (t.top) await refreshGit($)
683}
684
685async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
686  const t = await get($)
687  const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
688  const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
689  $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
690}
691
692async function exists($: EngineInterface, path: string): Promise<boolean> {
693  try {
694    await $.fs.stat(path)
695    return true
696  } catch {
697    return false
698  }
699}
700
701async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
702  const reads = queuedReads
703  queuedReads = []
704  if (await followCwd($)) return
705  const t = await get($)
706  if (!t.root) return
707  const actions = jobs.flatMap(j => j.actions)
708  const writers = jobs.filter(j => !j.readOnly)
709  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 })
710  const writes = !jobs.every(j => j.readOnly)
711  const before = dirty.root === t.root ? dirty.files : {}
712  if (jobs.some(j => j.initRepo)) await detectRepo($)
713  else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
714  const probed = await get($)
715  if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
716  if (writes) {
717    searchIndex = null
718    await staleSizes($)
719  }
720  const fresh = await get($)
721  const ignored = new Set(fresh.ignored)
722  const tones: Record<string, string> = {}
723  if (writes) {
724    const found = fresh.top
725      ? await changedInRepo($, t.root, since, before, fresh.ignored)
726      : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
727    const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
728    await revealPaths($, hits)
729    const loaded = await get($)
730    const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
731    const listed = await loadDirs($, dirs)
732    if (!fresh.top && since.os === 'win32')
733      for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
734    await patch($, cur => {
735      const ids = new Set(cur.nodes.map(n => n.id))
736      return { expanded: cur.expanded.filter(id => ids.has(id)) }
737    })
738    const present = new Set((await get($)).nodes.map(n => n.id))
739    const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
740    if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
741  }
742  if (showReads) {
743    const found: string[] = []
744    for (const r of reads) {
745      if (found.length >= READ_REVEAL_LIMIT) break
746      if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
747    }
748    if (found.length) {
749      await revealPaths($, found)
750      const shown = new Set((await get($)).nodes.map(n => n.id))
751      for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
752    }
753  }
754  if (showWrites && actions.length) {
755    const final = await get($)
756    const committed = actions.some(a => a.verb === 'commit')
757    if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
758      const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
759      await revealPaths($, paths)
760      const shown = new Set((await get($)).nodes.map(n => n.id))
761      for (const x of paths) if (shown.has(x)) tones[x] = 'green'
762    }
763    const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
764    if (pushLike) tones[BRANCH_ROW] = pushLike.tone
765    else if (committed) tones[BRANCH_ROW] = 'green'
766  }
767  await flash($, tones)
768}
769
770function scheduleScan($: EngineInterface, job: Job): void {
771  scanJobs.push(job)
772  if (scanning) return
773  scanning = (async () => {
774    while (scanJobs.length) {
775      const jobs = scanJobs
776      scanJobs = []
777      try {
778        await afterBash($, jobs)
779      } finally {
780        const marks = jobs.map(j => j.since.mark).filter(Boolean)
781        if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
782      }
783    }
784  })().finally(() => {
785    scanning = null
786  })
787}
788
789async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
790  if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
791  if (await followCwd($)) return
792  const t = await get($)
793  if (!t.root) return
794  const within = paths.map(posix).filter(p => inside(t.root, p))
795  if (within.length === 0) return
796  if (tone !== 'purple') {
797    searchIndex = null
798    await staleSizes($, within)
799    await revealPaths($, within.map(dirname))
800    await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
801    await refreshGit($)
802  } else await revealPaths($, within)
803  if (!show) return
804  const tones: Record<string, string> = {}
805  for (const p of within) tones[p] = tone
806  await flash($, tones)
807}
808
809async function reveal($: EngineInterface, paths: string[]): Promise<void> {
810  if (await followCwd($)) return
811  await revealPaths($, paths)
812  await patch($, cur => {
813    const open = new Set(cur.expanded)
814    for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
815    return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
816  })
817}
818
819async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
820  const out: string[] = []
821  let level = [root]
822  for (let d = 0; d < depth && level.length && out.length < limit; d++) {
823    const next: string[] = []
824    for (const dir of level) {
825      for (const n of (await list($, dir)) ?? []) {
826        if (n.kind === 'dir') {
827          if (!PRUNE.includes(n.name)) next.push(n.id)
828        } else out.push(n.id)
829      }
830      if (out.length >= limit) break
831    }
832    level = next
833  }
834  return out
835}
836
837async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
838  try {
839    if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
840    const run = t.top
841      ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
842      : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
843    if (t.top && run.exitCode !== 0) return null
844    const paths = run.stdout.split('\0').filter(Boolean)
845    if (run.isStdoutTruncated) paths.pop()
846    return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
847  } catch {
848    return null
849  }
850}
851
852function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
853  if (searchIndex?.root !== t.root) {
854    const index: { root: string; paths: Promise<string[]> } = {
855      root: t.root,
856      paths: listAll($, t).then(paths => {
857        if (paths === null && searchIndex === index) searchIndex = null
858        return paths ?? []
859      }),
860    }
861    searchIndex = index
862  }
863  return searchIndex.paths
864}
865
866async function search($: EngineInterface, query: string): Promise<string[]> {
867  if (!(await get($)).query.trim()) searchIndex = null
868  await patch($, () => ({ query }))
869  const q = query.trim().toLowerCase()
870  if (!q) return []
871  const t = await get($)
872  const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
873  if ((await get($)).query !== query) return []
874  await revealPaths($, hits)
875  const shown = await get($)
876  const ids = new Set(shown.nodes.map(n => n.id))
877  const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
878  if (stale.length) await loadDirs($, stale)
879  return hits
880}
881
882async function jump($: EngineInterface, query: string): Promise<void> {
883  const hits = (await search($, query)).slice(0, 10)
884  await patch($, cur => {
885    const open = new Set(cur.expanded)
886    for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
887    return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
888  })
889}
890
891async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
892  try {
893    const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
894    return out === 'stale' || out === 'missing' ? out : 'ok'
895  } catch {
896    return 'missing'
897  }
898}
899
900async function finePointerOk($: EngineInterface): Promise<boolean> {
901  if (!(await $.env.get('HERDR_ENV'))) return true
902  try {
903    const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
904    const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
905    return major * 1e6 + minor * 1e3 + fix >= 9_001
906  } catch {
907    return false
908  }
909}
910
911const PREVIEW_CHARS = 90_000
912const PREVIEW_LINE = 2_000
913
914function fitSerialized(lines: string[], max: number): string[] {
915  let size = 0
916  for (let i = 0; i < lines.length; i++) {
917    size += JSON.stringify(lines[i]).length
918    if (size > max) return lines.slice(0, i)
919  }
920  return lines
921}
922
923async function loadPreview($: EngineInterface, path: string): Promise<void> {
924  let text = ''
925  let note = ''
926  try {
927    const stat = await $.fs.stat(path)
928    if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
929    else {
930      text = await $.fs.read(path)
931      if (text.slice(0, 8000).includes('\0')) {
932        text = ''
933        note = `binary file, ${formatSize(stat.size)}`
934      }
935    }
936  } catch (err) {
937    note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
938  }
939  const total = text.length
940  let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
941  if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
942  lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
943  const fit = fitSerialized(lines, PREVIEW_CHARS)
944  if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
945  previewed = { path, text: fit.join('\n'), note }
946}
947
948async function showPreview($: EngineInterface, path: string): Promise<void> {
949  await loadPreview($, path)
950  await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
951}
952
953async function closePreview($: EngineInterface): Promise<void> {
954  await patch($, () => ({ preview: '', previewTop: 0 }))
955}
956
957async function scrollPreview($: EngineInterface, by: number): Promise<void> {
958  const t = await get($)
959  const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
960  const top = Math.max(0, Math.min(last, t.previewTop + by))
961  if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
962}
963
964function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
965  const lines = text.split('\n')
966  const from = Math.max(0, Math.min(top, lines.length - 1))
967  let shown = lines.slice(from, from + rows)
968  if (markdown) {
969    // Reopen a code fence the window starts inside, so the rest is not read as prose.
970    let fence = ''
971    let opener = ''
972    for (const line of lines.slice(0, from)) {
973      const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
974      if (!m) continue
975      const mark = m[1] ?? ''
976      if (!fence) {
977        fence = mark
978        opener = line.trimStart()
979      } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
980    }
981    if (fence) shown = [opener, ...shown]
982  }
983  return { text: shown.join('\n'), top: from }
984}
985
986async function openFile($: EngineInterface, path: string, external = false): Promise<void> {
987  if (!external && openWithSetting.trim() === 'preview') return showPreview($, path)
988  const os = await osName($)
989  const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
990  const custom = external ? null : openWithCommand(openWithSetting, native(path), native(dirname(path)))
991  const { argv, init } = custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
992  try {
993    const run = await $.process.run(argv, init)
994    if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
995  } catch {
996    $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}`)
997  }
998}
999
1000async function quickLook($: EngineInterface, path: string): Promise<void> {
1001  try {
1002    await $.process.run(['sh', '-c', '(nohup qlmanage -p "$1" >/dev/null 2>&1 &); sleep 0.4; osascript -e \'tell application "System Events" to set frontmost of first process whose name is "qlmanage" to true\' >/dev/null 2>&1 || true', 'sh', path], { timeoutMs: 10_000 })
1003  } catch {
1004    $.ui.toast(`could not preview ${path} with Quick Look`)
1005  }
1006}
1007
1008async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1009  if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1010  await patch($, t => {
1011    const open = new Set(t.expanded)
1012    if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1013    return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1014  })
1015}
1016
1017async function press($: EngineInterface, n: FileNode): Promise<void> {
1018  const now = await $.clock.now()
1019  const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1020  lastPress = { key: isDouble ? '' : n.id, at: now }
1021  if (!isDouble) {
1022    await toggle($, n)
1023    return
1024  }
1025  await openNode($, n)
1026}
1027
1028async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1029  if (n.kind === 'dir') {
1030    follow = false
1031    await reset($, n.id)
1032  } else await openFile($, n.id)
1033}
1034
1035async function loadTheme($: EngineInterface): Promise<void> {
1036  const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
1037  try {
1038    const stat = await $.fs.stat(path)
1039    if (stat.kind !== 'file') throw new Error('no theme file')
1040    if (stat.mtimeMs === themeMtime) return
1041    await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
1042    themeMtime = stat.mtimeMs
1043  } catch {
1044    if (themeMtime === 0) return
1045    await $.state.set(THEME, DEFAULT_THEME)
1046    themeMtime = 0
1047  }
1048}
1049
1050async function pressHeader($: EngineInterface, id: string): Promise<void> {
1051  const t = await get($)
1052  if (id === 'up') {
1053    follow = false
1054    await reset($, dirname(t.root))
1055  } else if (id === 'cwd') {
1056    follow = true
1057    await reset($, await cwdOf($))
1058  } else if (id === 'refresh') {
1059    searchIndex = null
1060    await staleSizes($)
1061    await loadDirs($, [t.root, ...t.expanded])
1062    await detectRepo($)
1063    await refreshGit($)
1064    if (t.query.trim()) await search($, t.query)
1065  } else if (id === 'hidden') await patch($, cur => ({ showHidden: !cur.showHidden }))
1066  else if (id === 'size') await patch($, cur => ({ showSize: !cur.showSize }))
1067  else if (id === 'collapse') await patch($, cur => ({ expanded: [], nodes: dropBelow(cur.nodes, cur.nodes.filter(x => x.parent === cur.root && x.kind === 'dir').map(x => x.id)) }))
1068  else if (id === 'unselect') await patch($, () => ({ selected: '' }))
1069  else if (id === 'external' && t.preview) await openFile($, t.preview, true)
1070}
1071
1072function shortPath(path: string): string {
1073  return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1074}
1075
1076export const register: Register = (on, options) => {
1077  glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
1078  const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
1079  showReads = activity.includes('reads')
1080  showWrites = activity.includes('writes')
1081  followClaude = options?.follow !== 'off'
1082  sizeDefault = options?.column === 'size'
1083  showAge = options?.column === 'age'
1084  showHints = options?.hints !== 'off'
1085  openWithSetting = typeof options?.open === 'string' ? options.open : ''
1086  noDock = options?.start === 'off'
1087  // Both values of `isInteractive` (the desktop app is not a REPL), so it sits beside the crab's own hook.
1088  for (const isInteractive of [true, false])
1089    on('session.start', { isInteractive }, async ($, e, next) => {
1090    await $.command.register({ name: 'filetree', description: 'Show the file tree; args: [path] (no path = cwd)' })
1091    const windows = (await $.env.get('OS')) === 'Windows_NT'
1092    useDrives(windows)
1093    home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1094    activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1095    void (async () => {
1096      pointer = await finePointerOk($)
1097      try {
1098        const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1099        noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
1100      } catch {
1101        noNerd = true
1102      }
1103      await loadTheme($)
1104      themePoll?.cancel()
1105      themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
1106      ageTick?.cancel()
1107      ageTick = showAge ? $.clock.every(AGE_TICK_MS, () => $.ui.invalidate('ui.render')) : null
1108      const t = await get($)
1109      if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1110      if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1111      await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
1112      const cwd = await cwdOf($)
1113      if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
1114      else if (!noDock) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
1115    })()
1116    return next(e)
1117  })
1118
1119  on('command.run', { command: 'filetree' }, async ($, e) => {
1120    // The layout checks are the terminal's: the desktop app docks the pane itself and
1121    // reports no fullscreen layout, so a session no terminal draws skips them.
1122    const terminal = await $.session.surfaces().then(s => s.includes('terminal'), () => true)
1123    if (terminal && !e.presentation.isFullscreen) return { text: 'filetree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /filetree.' }
1124    if (terminal && e.presentation.columns < 110) return { text: 'filetree shows in the sidebar, which needs a terminal at least 110 columns wide. Widen it, then run /filetree.' }
1125    noDock = false
1126    const arg = (e.args ?? '').trim()
1127    const cwd = await cwdOf($)
1128    follow = !arg
1129    const root = arg ? resolve(cwd, arg, home) : cwd
1130    await reset($, root, true)
1131    return { text: `File tree on ${shortPath(root)}${follow ? ' (follows the cwd)' : ''}.` }
1132  })
1133
1134  // One hook per tool it cares about, so it sits beside the crab's own all-tools hook.
1135  for (const tool of ['Edit', 'Write', 'NotebookEdit', 'Bash', 'PowerShell'] as const)
1136    on('tool.call', { tool }, async ($, e, next) => {
1137    const shell = e.tool === 'Bash' || e.tool === 'PowerShell'
1138    if (e.tool !== 'Edit' && e.tool !== 'Write' && e.tool !== 'NotebookEdit' && !shell) return next(e)
1139    const command = e.tool === 'Bash' || e.tool === 'PowerShell' ? e.command : ''
1140    const cwd = shell ? await cwdOf($) : ''
1141    const actions = gitActions(command)
1142    const quiet = !actions.length && readOnly(command)
1143    const since = await sinceNow($, shell && !quiet)
1144    const before = actions.some(a => a.verb === 'commit') ? await get($) : null
1145    const head = before?.top ? await headOf($, before.root) : ''
1146    const pending: Pending | null = actions.length ? { actions, ids: await startGit($, actions), command, head } : null
1147    let result: Awaited<ReturnType<typeof next>>
1148    try {
1149      result = await next(e)
1150    } catch (err) {
1151      if (pending) await finishGit($, pending, failAll(pending))
1152      if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1153      throw err
1154    }
1155    if (pending && result.deny) {
1156      const ids = pending.ids
1157      void setActivities($, cur => cur.filter(a => !ids.includes(a.id)))
1158    }
1159    if (result.deny || (result.isError && !shell)) {
1160      if (since.mark) void $.process.run(['rm', '-f', since.mark], { timeoutMs: 3_000 }).catch(() => undefined)
1161      return result
1162    }
1163    if (shell) {
1164      const out: Partial<BuiltinToolResults['Bash']> = !result.isError && result.result && typeof result.result === 'object' ? (result.result as Partial<BuiltinToolResults['Bash']>) : {}
1165      const reads = showReads ? readTargets(command, cwd, typeof out.stdout === 'string' ? out.stdout : '', home) : []
1166      const job: Job = { actions: [], since, initRepo: actions.some(a => a.init), readOnly: quiet || result.isReadOnly === true }
1167      if (out.backgroundTaskId) background.set(out.backgroundTaskId, { p: pending, job, reads })
1168      else void (async () => {
1169        const results = pending ? await outcomes($, pending, !result.isError, out.gitOperation) : []
1170        if (pending) await finishGit($, pending, results)
1171        queuedReads.push(...reads)
1172        scheduleScan($, { ...job, actions: proven(pending, results) })
1173      })()
1174      if (follow && /(^|[;&|\s])(cd|pushd|popd)(\s|$)/.test(command)) $.clock.after(400, () => void followCwd($))
1175    } else {
1176      const file =
1177        'file_path' in e && typeof e.file_path === 'string'
1178          ? e.file_path
1179          : 'notebook_path' in e && typeof e.notebook_path === 'string'
1180            ? e.notebook_path
1181            : ''
1182      if (file) void touched($, [file], 'orange', showWrites)
1183    }
1184    return result
1185  })
1186
1187  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
1188    const result = await next(e)
1189    if (result.deny || result.isError || e.tool !== 'Read' || !showReads) return result
1190    void touched($, [posix(e.file_path)], 'purple', true)
1191    return result
1192  })
1193
1194  for (const tool of ['Grep', 'Glob'] as const)
1195    on('tool.call', { tool }, async ($, e, next) => {
1196      const result = await next(e)
1197      if (result.deny || result.isError || !showReads) return result
1198      const names = (result.result as Partial<BuiltinToolResults['Grep']> | undefined)?.filenames
1199      if (!Array.isArray(names) || names.length === 0) return result
1200      const cwd = await cwdOf($)
hooks/crab.ts 658 lines
1import type { Character, Outfit } from '../types'
2
3// Pixel Clawd on a 30-wide grid, matching the Mascot Studio reference sheet: body 16x13,
4// claws 4x4, eyes 2x2, four 2x4 legs. Groups: `la`/`lb` leg pairs, everything else rides
5// inside `.body`; a class names a prop so CSS can move it on its own.
6export const CRAB_SCALE = 1
7export const CRAB_W = 30 * CRAB_SCALE
8// Feet sit at grid y=27; the tallest hat reaches y=-4, a flying crab lifts 5 more.
9export const CRAB_H = 27 * CRAB_SCALE
10export const BAR_H = 10
11export const BAR_Y = Math.round(31 * CRAB_SCALE + 6)
12export const ROW_H = BAR_Y + BAR_H + 3
13// Sizes drawn in pixels (the code window) follow the crab's scale.
14export const PX = CRAB_SCALE / 0.75
15
16const CLAY = '#D97757'
17const INK = '#1F1E1D'
18const GLASS = 'rgba(169,214,245,.55)'
19const GOLD = '#D9A23A'
20const PAPER = '#EEEDE8'
21
22type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
23
24const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
25  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
26
27// What differs between characters: the body, eyes, legs and arms. Outfits are drawn on top.
28type Parts = {
29  legs: (f: Fill) => void
30  torso: (f: Fill) => void
31  eyes: (f: Fill, y: number) => void
32  arm: (f: Fill, x: number, y: number, cls?: string) => void
33}
34
35const solid = (color: string) => (f: Fill, x: number, y: number, cls?: string): void => f(x, y, 4, 4, color, cls)
36// Arms are only drawn when a prop needs holding.
37const propArm = (color: string) => (f: Fill, x: number, y: number, cls?: string): void => {
38  if (cls) f(x, y, 4, 4, color, cls)
39}
40const fourLegs = (color: string) => (f: Fill): void => {
41  f(7, 23, 2, 4, color, 'la')
42  f(17, 23, 2, 4, color, 'la')
43  f(11, 23, 2, 4, color, 'lb')
44  f(21, 23, 2, 4, color, 'lb')
45}
46const pair = (color: string, w = 4) => (f: Fill): void => {
47  f(7, 23, w, 4, color, 'la')
48  f(23 - w, 23, w, 4, color, 'la')
49  f(7 + w + 1, 23, w, 4, color, 'lb')
50  f(22 - 2 * w, 23, w, 4, color, 'lb')
51}
52
53const FROG = '#C66B3E'
54const FROG_LIGHT = '#DA8E5E'
55const CAT = '#E39A4A'
56const CREEPER = '#5DAA4E'
57const SLIME = '#6CC25F'
58const STEEL_BLUE = '#9AA4B0'
59
60const CHARS: Record<Character, Parts> = {
61  // The default: Clawd, the crab.
62  claude: {
63    legs: fourLegs(CLAY),
64    torso: f => f(7, 10, 16, 13, CLAY),
65    eyes: (f, y) => {
66      f(9, y, 2, 2, INK, 'eye')
67      f(19, y, 2, 2, INK, 'eye')
68    },
69    arm: solid(CLAY),
70  },
71  // The Minecraft warm frog: squat, bulging eyes with wide black pupils, tan belly, big back legs.
72  frog: {
73    legs: f => {
74      f(2, 19, 6, 8, FROG, 'la'); f(22, 19, 6, 8, FROG, 'la'); f(0, 26, 3, 1, FROG_LIGHT, 'la'); f(27, 26, 3, 1, FROG_LIGHT, 'la')
75      f(10, 23, 4, 4, FROG, 'lb'); f(16, 23, 4, 4, FROG, 'lb'); f(9, 26, 6, 1, FROG_LIGHT, 'lb'); f(15, 26, 6, 1, FROG_LIGHT, 'lb')
76    },
77    torso: f => {
78      f(7, 12, 16, 11, FROG); f(7, 9, 6, 4, FROG); f(17, 9, 6, 4, FROG)
79      f(8, 9, 4, 1, FROG_LIGHT); f(18, 9, 4, 1, FROG_LIGHT)
80      f(11, 17, 8, 6, '#E2C285'); f(8, 16, 14, 1, '#8E4A2A')
81    },
82    eyes: (f, y) => {
83      f(8, y - 1, 4, 2, INK, 'eye'); f(18, y - 1, 4, 2, INK, 'eye')
84      f(8, y - 2, 4, 1, '#8A8D4B'); f(18, y - 2, 4, 1, '#8A8D4B')
85    },
86    arm: solid(FROG),
87  },
88  // The Minecraft creeper: green block, dark face, stub legs.
89  creeper: {
90    legs: pair('#3E7F36'),
91    torso: f => {
92      f(7, 10, 16, 13, CREEPER)
93      f(8, 11, 2, 2, '#3E7F36'); f(20, 12, 2, 2, '#3E7F36'); f(10, 20, 2, 2, '#3E7F36'); f(18, 19, 3, 2, '#3E7F36')
94      f(13, 15, 4, 2, INK); f(12, 17, 2, 3, INK); f(16, 17, 2, 3, INK)
95    },
96    eyes: (f, y) => {
97      f(9, y, 3, 3, INK, 'eye')
98      f(18, y, 3, 3, INK, 'eye')
99    },
100    arm: propArm(CREEPER),
101  },
102  // The Minecraft slime: a wobbly green cube with a darker core.
103  slime: {
104    legs: () => {},
105    torso: f => {
106      f(7, 11, 16, 12, SLIME); f(10, 14, 10, 7, '#4E9F47'); f(8, 12, 3, 1, '#B8F0A8'); f(9, 13, 1, 2, '#B8F0A8')
107      f(14, 19, 2, 1, INK)
108    },
109    eyes: (f, y) => {
110      f(11, y + 1, 2, 2, INK, 'eye')
111      f(17, y + 1, 2, 2, INK, 'eye')
112    },
113    arm: propArm(SLIME),
114  },
115  // A little robot: steel body, cyan eyes, antenna with a light.
116  robot: {
117    legs: pair('#6B7480', 3),
118    torso: f => {
119      f(7, 10, 16, 13, STEEL_BLUE); f(10, 16, 10, 5, '#5B6570'); f(11, 17, 2, 1, '#E0505A'); f(14, 17, 2, 1, '#5FBF8F'); f(17, 17, 2, 1, '#F5C542')
120      f(14, 6, 2, 4, '#6B7480'); f(13, 4, 4, 2, '#E0505A', 'ant')
121    },
122    eyes: (f, y) => {
123      f(9, y, 3, 2, '#7FD6FF', 'eye')
124      f(18, y, 3, 2, '#7FD6FF', 'eye')
125    },
126    arm: solid('#6B7480'),
127  },
128  // A tabby cat: ears, stripes, whiskers.
129  cat: {
130    legs: fourLegs(CAT),
131    torso: f => {
132      f(7, 10, 16, 13, CAT); f(7, 6, 4, 5, CAT); f(19, 6, 4, 5, CAT); f(8, 7, 2, 3, '#F2B6A0'); f(20, 7, 2, 3, '#F2B6A0')
133      f(10, 10, 2, 3, '#B8702E'); f(14, 10, 2, 2, '#B8702E'); f(18, 10, 2, 3, '#B8702E')
134      f(14, 15, 2, 1, '#E58AA0'); f(3, 15, 5, 1, '#F2F0EA'); f(22, 15, 5, 1, '#F2F0EA')
135      f(23, 20, 6, 2, CAT, 'tail'); f(27, 14, 2, 8, CAT, 'tail')
136    },
137    eyes: (f, y) => {
138      f(9, y, 2, 3, INK, 'eye')
139      f(19, y, 2, 3, INK, 'eye')
140    },
141    arm: solid(CAT),
142  },
143}
144
145export const CHARACTERS = Object.keys(CHARS) as Character[]
146
147// The colour of the thing he pulls out of his pocket when changing into each outfit.
148export const ITEM_COLOR: Partial<Record<Outfit, string>> = {
149  coding: '#2B2B2D',
150  walkCmd: '#2B2B2D',
151  cool: '#2B2B2D',
152  walkCool: '#2B2B2D',
153  fixing: '#6A50AA',
154  walkWizard: '#6A50AA',
155  chef: '#F4F3EE',
156  walkChef: '#F4F3EE',
157  reading: '#EEEDE8',
158  writing: '#EEEDE8',
159  walkCode: '#EEEDE8',
160  sleeping: '#3C79B8',
161  propeller: '#3FA796',
162  fly: '#3FA796',
163  idle: '#A9D6F5',
164  sunbathe: '#D8503F',
165}
166
167// Where the item ends up: on his head, in front of his eyes, in front of his belly, or held up.
168export type Dest = 'head' | 'face' | 'belly' | 'hand'
169export const ITEM_DEST: Partial<Record<Outfit, Dest>> = {
170  fixing: 'head', walkWizard: 'head', chef: 'head', walkChef: 'head', sleeping: 'head', propeller: 'head', fly: 'head', sunbathe: 'head',
171  coding: 'face', walkCmd: 'face', cool: 'face', walkCool: 'face',
172  reading: 'belly', writing: 'belly', walkCode: 'belly',
173  idle: 'hand',
174}
175const DEST_XY: Record<Dest, [number, number]> = { head: [-10, -15], face: [-9, -3], belly: [-12, 4], hand: [-2, -8] }
176
177// The item held up during a change; set by crabSvg.
178let ITEM = '#888888'
179
180// Set by crabSvg for the drawing in progress.
181let P: Parts = CHARS.claude
182const legs = (f: Fill): void => P.legs(f)
183const torso = (f: Fill): void => P.torso(f)
184const eyes = (f: Fill, y = 12): void => P.eyes(f, y)
185const happyEyes = (f: Fill): void => {
186  stamp(f, 9, 12, ['.x.', 'x.x'], { x: INK })
187  stamp(f, 19, 12, ['.x.', 'x.x'], { x: INK })
188}
189const shutEyes = (f: Fill): void => {
190  f(9, 13, 2, 1, INK)
191  f(19, 13, 2, 1, INK)
192}
193const lClaw = (f: Fill, y = 15, cls?: string): void => P.arm(f, 3, y, cls)
194const rClaw = (f: Fill, y = 15, cls?: string): void => P.arm(f, 23, y, cls)
195const shades = (f: Fill): void => {
196  f(8, 11, 5, 3, INK)
197  f(17, 11, 5, 3, INK)
198  f(13, 11, 4, 1, INK)
199  f(9, 12, 2, 1, '#6B6B6B')
200  f(19, 12, 2, 1, '#6B6B6B')
201}
202// An open book held over his belly; the right page turns.
203const book = (f: Fill): void => {
204  f(6, 17, 19, 9, INK)
205  f(7, 18, 8, 7, PAPER)
206  f(16, 18, 8, 7, PAPER, 'pg')
207  f(8, 19, 6, 1, CLAY)
208  f(8, 21, 6, 1, CLAY)
209  f(17, 19, 6, 1, CLAY, 'pg')
210  f(17, 21, 6, 1, CLAY, 'pg')
211}
212
213const WIZ = { p: '#6A50AA', l: '#A992E0', g: GOLD }
214const CAP = { b: '#3C79B8', w: '#F2F0EA' }
215
216const wizHat = (f: Fill): void => {
217  stamp(f, 5, -4, [
218      '..........pp........',
219      '..........pp........',
220      '.........ppp........',
221      '.........ppp........',
222      '.........ppp........',
223      '.......pppppp.......',
224      '.......ppgppp.......',
225      '.....pppggggppp.....',
226      '.....pppppgpppp.....',
227      '.....pppppppppp.....',
228      '...llllllllllllll...',
229      '...pppppppppppppp...',
230      'pppppppppppppppppppp',
231      'pppppppppppppppppppp',
232    ], WIZ)
233}
234const toque = (f: Fill): void => {
235  stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.bbbbbbbbbbbbbbbb.', '.bbbbbbbbbbbbbbbb.', '.bbbbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: '#E6E4DA' })
236}
237const propCap = (f: Fill): void => {
238  f(9, 8, 12, 2, '#3C79B8'); f(11, 6, 8, 2, '#B5432A'); f(13, 4, 4, 2, '#C9A24A'); f(14, 2, 2, 2, INK); f(9, 1, 12, 1, '#3FA796', 'prop')
239}
240// A small floating code panel he reads while walking.
241const codePanel = (f: Fill): void => {
242  f(18, 1, 12, 8, INK); f(19, 2, 10, 6, PAPER)
243  f(20, 3, 6, 1, '#7FC4F2', 'ln1'); f(21, 5, 8, 1, CLAY, 'ln2'); f(20, 7, 4, 1, '#4FA37A', 'ln3')
244}
245
246// Victory poses; the character's own flourish (CSS `vc-<name>`) rides on these.
247export const WIN: ReadonlySet<Outfit> = new Set<Outfit>(['done', 'crown', 'exitCar', 'medal', 'winCode', 'winFix', 'winRead', 'winWrite'])
248
249// A little red convertible; `dx` parks it beside the crab. Drawn in front of his belly.
250const car = (f: Fill, dx = 0): void => {
251  f(dx, 20, 28, 5, '#D9453B', 'car'); f(dx, 20, 28, 1, '#B8352D', 'car'); f(dx + 4, 22, 20, 1, 'rgba(255,255,255,.35)', 'car')
252  f(dx + 27, 21, 3, 2, '#FFE9A8', 'car')
253  f(dx + 3, 24, 6, 4, INK, 'wh'); f(dx + 4, 25, 4, 2, '#8E929A', 'hub'); f(dx + 19, 24, 6, 4, INK, 'wh'); f(dx + 20, 25, 4, 2, '#8E929A', 'hub')
254}
255
256const DRAW: Record<Outfit, (f: Fill) => void> = {
257  // Idle: holds a magnifying glass and looks up at the code.
258  idle: f => {
259    legs(f); torso(f); lClaw(f); rClaw(f, 9, 'it'); eyes(f, 11)
260    stamp(f, 22, 0, ['.kkkk.', 'kllllk', 'klwllk', 'kllllk', 'kllllk', '.kkkk.'], { k: '#3A3A3C', l: GLASS, w: '#fff' }, 'it')
261    f(24, 6, 1, 3, '#7A4A26', 'it')
262  },
263  // Stroll: just walking about, arms swinging.
264  stroll: f => {
265    legs(f); torso(f); lClaw(f, 15, 'ta'); rClaw(f, 15, 'tb'); eyes(f)
266  },
267  // Coding: sunglasses and a terminal window held over his belly; claws at the sides.
268  coding: f => {
269    legs(f); torso(f); lClaw(f, 15, 'ta'); rClaw(f, 15, 'tb'); shades(f)
270    f(6, 18, 19, 7, INK); f(7, 19, 17, 5, PAPER)
271    stamp(f, 9, 20, ['x.', '.x', 'x.'], { x: INK })
272    f(13, 20, 5, 1, CLAY, 'ln1'); f(13, 22, 7, 1, '#4FA37A', 'ln2'); f(21, 20, 1, 1, '#8E929A', 'ln3')
273  },
274  // Fixing: wizard hat and a wand that waves and sparkles.
275  fixing: f => {
276    legs(f); torso(f); lClaw(f); eyes(f); rClaw(f, 9, 'it')
277    wizHat(f)
278    f(26, 8, 1, 1, '#7A4A26', 'it'); f(27, 7, 1, 1, '#7A4A26', 'it'); f(28, 6, 1, 1, '#7A4A26', 'it'); f(29, 5, 1, 1, '#7A4A26', 'it')
279    f(28, 3, 2, 2, '#F5C542', 'it'); f(26, 1, 1, 1, '#fff', 'sp'); f(29, 8, 1, 1, '#fff', 'st'); f(25, 4, 1, 1, '#F5C542', 'st')
280  },
281  // Chef: for the long jobs; tosses an omelette and now and then wipes his forehead.
282  chef: f => {
283    legs(f); torso(f); eyes(f)
284    toque(f)
285    lClaw(f, 15, 'wipe'); rClaw(f, 10, 'pan')
286    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
287    f(2, 7, 2, 3, '#7FC4F2', 'drop')
288  },
289  // Reading: nose in a book while he goes through files.
290  reading: f => {
291    legs(f); torso(f); lClaw(f, 16); rClaw(f, 16); eyes(f, 13); book(f)
292  },
293  // Writing: the same book with a pencil scribbling.
294  writing: f => {
295    legs(f); torso(f); lClaw(f, 16); rClaw(f, 16); eyes(f, 13); book(f)
296    f(24, 16, 1, 1, INK, 'wr'); f(25, 15, 1, 1, '#F5C542', 'wr'); f(26, 14, 1, 1, '#F5C542', 'wr'); f(27, 13, 1, 1, '#E8B04A', 'wr'); f(28, 12, 1, 1, '#C0705A', 'wr')
297  },
298  // Sleeping: nightcap, eyes shut, a pair of Zs floating up.
299  sleeping: f => {
300    legs(f); torso(f); lClaw(f); rClaw(f); shutEyes(f)
301    stamp(f, 7, 4, [
302      '...bbbbbb.......',
303      '.bbbbbbbbbb.....',
304      'bbbbbbbbbbbbbb..',
305      'bbbbbbbbbbbbbbbb',
306      'bbbbbbbbbbbbbbbb',
307      'wwwwwwwwwwwwwwww',
308    ], CAP)
309    f(22, 9, 3, 3, '#F2F0EA')
310    stamp(f, 24, 3, ['zzz', '..z', '.z.', 'z..', 'zzz'], { z: '#C0705A' }, 'z1')
311    stamp(f, 27, -2, ['zz', '.z', 'z.', 'zz'], { z: '#C0705A' }, 'z2')
312  },
313  // Working, no special task: thinking, three dots rising above his head.
314  working: f => {
315    legs(f); torso(f); lClaw(f); rClaw(f); eyes(f)
316    f(13, 5, 2, 2, '#C0705A', 'd1'); f(17, 3, 2, 2, '#C0705A', 'd2'); f(21, 5, 2, 2, '#C0705A', 'd3')
317  },
318  // Just finished: claws up, happy eyes.
319  done: f => {
320    legs(f); torso(f); happyEyes(f)
321    lClaw(f, 8, 'cheer'); rClaw(f, 8, 'cheer')
322    f(1, 3, 1, 1, '#F5C542', 'st'); f(28, 3, 1, 1, '#F5C542', 'st'); f(14, 4, 2, 2, '#F5C542', 'sp')
323  },
324  // Finished a long job: crown.
325  crown: f => {
326    legs(f); torso(f); lClaw(f); rClaw(f); happyEyes(f)
327    f(10, 4, 2, 3, GOLD); f(14, 3, 2, 4, GOLD); f(18, 4, 2, 3, GOLD); f(10, 7, 10, 3, GOLD); f(10, 9, 10, 1, '#B8841F')
328    f(14, 8, 2, 1, '#C0392B', 'gem')
329  },
330  // Something went wrong: a shaken crab with an exclamation mark.
331  error: f => {
332    legs(f); torso(f); lClaw(f); rClaw(f); shutEyes(f)
333    f(14, 0, 2, 6, '#C0392B', 'ex'); f(14, 7, 2, 2, '#C0392B', 'ex')
334  },
335  // Greeting: waves with the right claw.
336  greeting: f => {
337    legs(f); torso(f); lClaw(f); happyEyes(f); rClaw(f, 7, 'wv')
338  },
339  // Propeller cap: the blades spin.
340  propeller: f => {
341    legs(f); torso(f); lClaw(f); rClaw(f); eyes(f)
342    propCap(f)
343  },
344  // Cool: just the sunglasses.
345  cool: f => {
346    legs(f); torso(f); lClaw(f); rClaw(f); shades(f)
347  },
348  // Sunbathe: stretched out on a deck chair under a striped umbrella, sunglasses on.
349  sunbathe: f => {
350    f(14, 0, 2, 11, '#CDBB93', 'um')
351    const span = (y: number, x0: number, x1: number): void => {
352      for (let x = x0; x <= x1; x++) f(x, y, 1, 1, Math.floor((x - 5) / 3) % 2 === 0 ? '#D8503F' : '#F4F1EA', 'um')
353    }
354    span(-4, 13, 21); span(-3, 10, 24); span(-2, 7, 27); span(-1, 5, 29)
355    f(0, -3, 4, 4, '#F5C542', 'sun'); f(5, -3, 1, 1, '#F5C542', 'sun')
356    legs(f); torso(f); lClaw(f, 16); rClaw(f, 16); shades(f)
357    f(3, 22, 24, 2, '#4FA3C7'); f(5, 22, 2, 2, '#F4F1EA'); f(13, 22, 2, 2, '#F4F1EA'); f(21, 22, 2, 2, '#F4F1EA')
358    f(4, 24, 2, 3, '#8E929A'); f(24, 24, 2, 3, '#8E929A')
359  },
360  // On the move: a plain stroll with sunglasses on.
361  walkCool: f => {
362    legs(f); torso(f); lClaw(f, 15, 'ta'); rClaw(f, 15, 'tb'); shades(f)
363  },
364  // On the move in a wizard hat.
365  walkWizard: f => {
366    legs(f); torso(f); lClaw(f, 15, 'ta'); rClaw(f, 15, 'tb'); eyes(f); wizHat(f)
367  },
368  // On the move in a chef's toque.
369  walkChef: f => {
370    legs(f); torso(f); lClaw(f, 15, 'ta'); rClaw(f, 15, 'tb'); eyes(f); toque(f)
371  },
372  // Flying: the propeller cap lifts him off the bar, claws flapping, legs dangling.
373  fly: f => {
374    legs(f); torso(f); lClaw(f, 12, 'fa'); rClaw(f, 12, 'fb'); eyes(f); propCap(f)
375  },
376  // Looking up at a code panel while he walks.
377  walkCode: f => {
378    legs(f); torso(f); lClaw(f, 15, 'ta'); rClaw(f, 15, 'tb')
379    f(10, 11, 2, 2, INK, 'eye'); f(20, 11, 2, 2, INK, 'eye')
380    codePanel(f)
381  },
382  // Running commands on a terminal while he walks.
383  walkCmd: f => {
384    legs(f); torso(f); lClaw(f, 15, 'ta'); rClaw(f, 15, 'tb'); shades(f)
385    f(6, 18, 19, 7, INK); f(7, 19, 17, 5, PAPER)
386    stamp(f, 9, 20, ['x.', '.x', 'x.'], { x: INK })
387    f(13, 20, 5, 1, CLAY, 'ln1'); f(13, 22, 7, 1, '#4FA37A', 'ln2'); f(21, 20, 1, 1, '#8E929A', 'ln3')
388  },
389  // The bar just jumped: sprinting, leaning forward, speed lines streaming behind.
390  run: f => {
391    legs(f); torso(f); lClaw(f, 14, 'ra'); rClaw(f, 14, 'rb'); eyes(f, 11)
392    f(-4, 13, 7, 1, 'rgba(140,140,140,.65)', 'sl'); f(-6, 17, 8, 1, 'rgba(140,140,140,.5)', 'sl'); f(-3, 21, 6, 1, 'rgba(140,140,140,.65)', 'sl')
393    f(3, 26, 3, 2, '#C9C5BC', 'dust'); f(0, 25, 2, 2, '#D8D4CB', 'dust')
394  },
395  // An even bigger leap: behind the wheel of a little red convertible, shades on, wheels spinning.
396  drive: f => {
397    torso(f); lClaw(f, 14, 'ra'); rClaw(f, 14, 'rb'); shades(f)
398    car(f)
399    f(-9, 14, 9, 1, 'rgba(140,140,140,.65)', 'sl'); f(-12, 19, 11, 1, 'rgba(140,140,140,.5)', 'sl'); f(-8, 23, 8, 1, 'rgba(140,140,140,.65)', 'sl')
400    f(-3, 22, 3, 3, '#C9C5BC', 'dust'); f(-6, 25, 3, 3, '#D8D4CB', 'dust')
401  },
402  // ...and then stopped: bent over, gasping, sweating, little clouds of breath.
403  pant: f => {
404    legs(f); torso(f); lClaw(f, 17); rClaw(f, 17); shutEyes(f)
405    f(13, 16, 4, 3, INK, 'mouth')
406    f(5, 9, 2, 3, '#7FC4F2', 'sw'); f(24, 8, 2, 3, '#7FC4F2', 'sw')
407    f(26, 15, 3, 2, '#FFFFFF', 'hf'); f(28, 18, 2, 2, '#FFFFFF', 'hf'); f(1, 15, 3, 2, '#FFFFFF', 'hf')
408  },
409  // Between the drive and the victory: the car brakes, wheels still, smoke and brake lights.
410  park: f => {
411    torso(f); lClaw(f, 14); rClaw(f, 14); eyes(f, 11)
412    car(f)
413    f(0, 21, 2, 2, '#FF3B30', 'bl')
414    f(-3, 25, 3, 3, '#C9C5BC', 'dust'); f(-7, 23, 3, 3, '#D8D4CB', 'dust'); f(-10, 28, 10, 1, '#8E929A')
415    f(24, 14, 2, 3, '#7FC4F2', 'sw')
416  },
417  // Between a finished task and its victory: a crouch, claws coming up, sparks drawn in.
418  brace: f => {
419    legs(f); torso(f); lClaw(f, 17, 'wd'); rClaw(f, 17, 'wd'); eyes(f, 11)
420    f(1, 5, 2, 2, GOLD, 'g1'); f(27, 5, 2, 2, GOLD, 'g2'); f(14, 0, 2, 2, '#fff', 'g3')
421  },
422  // Victory after the car: he has climbed out, the car is parked beside him, claws up.
423  exitCar: f => {
424    legs(f); torso(f); happyEyes(f); lClaw(f, 8, 'cheer'); rClaw(f, 8, 'cheer')
425    car(f, -26)
426    f(1, 3, 1, 1, '#F5C542', 'st'); f(28, 3, 1, 1, '#F5C542', 'st'); f(14, 4, 2, 2, '#F5C542', 'sp')
427  },
428  // Victory after the sprint: a gold medal on a red and blue ribbon.
429  medal: f => {
430    legs(f); torso(f); happyEyes(f); lClaw(f, 8, 'cheer'); rClaw(f, 8, 'cheer')
431    f(12, 14, 2, 2, '#D9453B'); f(14, 16, 2, 2, '#D9453B'); f(18, 14, 2, 2, '#3B6FD9'); f(16, 16, 2, 2, '#3B6FD9')
432    f(13, 18, 6, 5, GOLD); f(14, 19, 4, 3, '#F2C14E'); f(15, 20, 2, 1, '#FFF3C4', 'gem')
433    f(1, 3, 1, 1, '#F5C542', 'st'); f(28, 3, 1, 1, '#F5C542', 'st'); f(14, 4, 2, 2, '#F5C542', 'sp')
434  },
435  // Victory after coding: shades on, the terminal shows a green check.
436  winCode: f => {
437    legs(f); torso(f); shades(f); lClaw(f, 8, 'cheer'); rClaw(f, 8, 'cheer')
438    f(6, 18, 19, 7, INK); f(7, 19, 17, 5, PAPER)
439    stamp(f, 12, 19, ['......x', '.....x.', 'x...x..', '.x.x...', '..x....'], { x: '#4FA37A' }, 'gem')
440    f(1, 3, 1, 1, '#F5C542', 'st'); f(28, 3, 1, 1, '#F5C542', 'sp')
441  },
442  // Victory after fixing: the wizard throws a firework from the wand.
443  winFix: f => {
444    legs(f); torso(f); lClaw(f, 15); happyEyes(f); wizHat(f); rClaw(f, 8, 'cheer')
445    f(26, 5, 2, 2, GOLD, 'sp'); f(29, 8, 1, 1, '#fff', 'st'); f(23, 3, 1, 1, '#F5C542', 'st'); f(1, 4, 2, 2, '#A992E0', 'sp'); f(2, 10, 1, 1, '#fff', 'st')
446  },
447  // Victory after reading: the book is shut and held over his head.
448  winRead: f => {
449    legs(f); torso(f); happyEyes(f); lClaw(f, 8, 'cheer'); rClaw(f, 8, 'cheer')
450    f(9, 1, 12, 8, '#3C79B8'); f(9, 1, 2, 8, '#2B5C93'); f(13, 3, 5, 1, PAPER); f(13, 5, 3, 1, PAPER); f(22, 1, 1, 1, '#F5C542', 'sp')
451  },
452  // Victory after writing: the finished page, ticked off, held over his head.
453  winWrite: f => {
454    legs(f); torso(f); happyEyes(f); lClaw(f, 8, 'cheer'); rClaw(f, 8, 'cheer')
455    f(10, 0, 10, 10, INK); f(11, 1, 8, 8, PAPER); f(12, 2, 5, 1, CLAY); f(12, 4, 6, 1, CLAY)
456    stamp(f, 14, 6, ['..x', 'x.x', '.x.'], { x: '#4FA37A' }, 'gem')
457  },
458  // The prompt cache went cold: shivering inside a cube of ice.
459  frozen: f => {
460    legs(f); torso(f); lClaw(f); rClaw(f); eyes(f, 11)
461    f(1, 2, 28, 27, 'rgba(150,215,245,.3)', 'ice'); f(3, 0, 28, 2, 'rgba(215,242,253,.9)', 'ice'); f(29, 2, 3, 27, 'rgba(100,170,212,.75)', 'ice')
462    f(1, 2, 28, 1, '#D9F2FD', 'ice'); f(1, 2, 1, 27, '#D9F2FD', 'ice'); f(1, 28, 28, 1, '#6FB6DB', 'ice')
463    f(3, 4, 5, 1, '#FFFFFF', 'ice'); f(3, 5, 1, 4, '#FFFFFF', 'ice')
464    f(22, 24, 2, 2, '#FFFFFF', 'gl'); f(9, 8, 1, 1, '#FFFFFF', 'gl'); f(25, 10, 1, 1, '#FFFFFF', 'gl')
465  },
466  // Changing costume: digs the next one out of a belly pocket, holds it over his head, puffs.
467  changing: f => {
468    legs(f); torso(f); lClaw(f); eyes(f)
469    rClaw(f, 15, 'pk')
470    f(24, 8, 8, 5, ITEM, 'itm'); f(25, 9, 3, 1, 'rgba(255,255,255,.65)', 'itm')
471    f(11, 4, 2, 2, '#fff', 'puff'); f(18, 3, 2, 2, '#fff', 'puff'); f(14, 0, 2, 2, '#fff', 'puff'); f(8, 7, 2, 2, '#fff', 'puff'); f(21, 7, 2, 2, '#fff', 'puff')
472  },
473  // Dance: claws pumping, hopping.
474  dance: f => {
475    legs(f); torso(f); lClaw(f, 15, 'da'); rClaw(f, 15, 'db'); happyEyes(f)
476  },
477}
478
479export const crabSvg = (
480  outfit: Outfit,
481  x: number,
482  y: number,
483  isWalking = false,
484  character: Character = 'claude',
485  change?: { item: string; age: number; dest?: Dest },
486  settle?: number,
487): string => {
488  P = CHARS[character] ?? CHARS.claude
489  ITEM = change?.item ?? '#888888'
490  const groups = new Map<string, string[]>()
491  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
492    let g = groups.get(cls)
493    if (!g) groups.set(cls, (g = []))
494    g.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
495  }
496  DRAW[outfit](f)
497  const [tx, ty] = DEST_XY[change?.dest ?? 'head']
498  const phase = outfit === 'changing' && change ? `style="animation-delay:-${Math.round(change.age)}ms;--tx:${tx}px;--ty:${ty}px"` : ''
499  const pop = settle !== undefined && settle >= 0 && settle < 400
500  const grp = (k: string): string => `<g class="${k}" ${phase}>${groups.get(k)?.join('') ?? ''}</g>`
501  // Legs go behind the body; the car goes in front of it and stays put while he bounces.
502  const BEHIND = ['la', 'lb']
503  const FRONT = ['car', 'wh', 'hub']
504  const legPairs = BEHIND.filter(k => groups.has(k)).map(grp).join('')
505  const front = FRONT.filter(k => groups.has(k)).map(grp).join('')
506  const rest = [...groups.keys()].filter(k => !BEHIND.includes(k) && !FRONT.includes(k)).map(grp).join('')
507  const cls = `c-${outfit}${WIN.has(outfit) ? ` win vc-${character}` : ''}${isWalking ? ' walk' : ''}${outfit === 'stroll' || outfit.startsWith('walk') ? ' amble' : ''}`
508  return `<g transform="translate(${x},${y}) scale(${CRAB_SCALE})" shape-rendering="crispEdges">${pop ? `<g class="pop" style="animation-delay:-${Math.round(settle ?? 0)}ms">` : ''}<g class="${cls}">${legPairs}<g class="body">${rest}</g>${front}</g>${pop ? '</g>' : ''}</g>`
509}
510
511// The code the idle crab studies, beside him.
512export const codeWindow = (x: number, y: number): string => `<g class="win" transform="translate(${x},${y}) scale(${PX})">
513<rect class="k" x="0" y="0" width="40" height="18" rx="2"/>
514<rect class="ln1" x="4" y="3" width="14" height="2" fill="#7FC4F2"/>
515<rect class="ln2" x="8" y="7" width="22" height="2" fill="${CLAY}"/>
516<rect class="ln3" x="4" y="11" width="10" height="2" fill="#8FD694"/>
517</g>`
518
519// Every loop is started `at` into its cycle, so the drawing that replaces this one each
520// second carries on from where the last left off instead of restarting.
521export const crabCss = (at: number): string => {
522  const s = at / 1000
523  const d = (period: number, offset = 0): string => `-${((s + offset) % period).toFixed(3)}s`
524  return `<style>
525g{transform-box:fill-box}
526.k{fill:#e4e4e2}.m{fill:#8a8a8a}
527@media (prefers-color-scheme: dark){.k{fill:#2c2c2c}.m{fill:#9a9a9a}}
528.body{animation:bob 1s ease-in-out infinite ${d(1)}}
529@keyframes bob{50%{transform:translateY(1px)}}
530.eye{animation:eb 5s steps(1) infinite ${d(5)}}
531@keyframes eb{0%,94%,100%{transform:scaleY(1)}96%{transform:scaleY(.1)}}
532.walk .la{animation:st .5s steps(1) infinite ${d(0.5)}}.walk .lb{animation:st .5s steps(1) infinite ${d(0.5, 0.25)}}
533.amble.walk .la{animation:st .8s steps(1) infinite ${d(0.8)}}.amble.walk .lb{animation:st .8s steps(1) infinite ${d(0.8, 0.4)}}
534.walk .ta{animation:sw .5s steps(1) infinite ${d(0.5, 0.25)}}.walk .tb{animation:sw .5s steps(1) infinite ${d(0.5)}}
535@keyframes st{50%{transform:translateY(-3px)}}
536@keyframes sw{50%{transform:translateY(2px)}}
537.c-working .d1,.c-working .d2,.c-working .d3{animation:dot 1.5s steps(1) infinite ${d(1.5)}}
538.c-working .d2{animation-delay:${d(1.5, 1)}}.c-working .d3{animation-delay:${d(1.5, 0.5)}}
539@keyframes dot{0%{opacity:0}33%{opacity:1}100%{opacity:1}}
540.c-idle .it{transform-origin:50% 100%;animation:sweep 3s ease-in-out infinite ${d(3)}}
541@keyframes sweep{0%,100%{transform:rotate(-8deg) translateX(-1px)}50%{transform:rotate(10deg) translateX(1px)}}
542.c-idle .eye{animation:lookup 4s steps(1) infinite ${d(4)}}
543@keyframes lookup{0%,100%{transform:translate(0,0)}30%{transform:translate(1px,-1px)}60%{transform:translate(0,-1px)}}
544.ln1{animation:cur 1.4s steps(1) infinite ${d(1.4)}}.ln2{animation:cur 1.4s steps(1) infinite ${d(1.4, 0.5)}}.ln3{animation:cur 1.4s steps(1) infinite ${d(1.4, 0.9)}}
545@keyframes cur{50%{opacity:.35}}
546.c-coding .ta{animation:tap .3s steps(1) infinite ${d(0.3)}}.c-coding .tb{animation:tap .3s steps(1) infinite ${d(0.3, 0.15)}}
547@keyframes tap{50%{transform:translateY(2px)}}
548.c-fixing .it{transform-origin:0 100%;animation:wave 1.2s ease-in-out infinite ${d(1.2)}}
549@keyframes wave{50%{transform:rotate(-25deg)}}
550.c-fixing .sp,.c-done .sp{animation:blink .6s steps(1) infinite ${d(0.6)}}.c-fixing .st,.c-done .st{animation:blink .6s steps(1) infinite ${d(0.6, 0.3)}}
551@keyframes blink{50%{opacity:.1}}
552.c-chef .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite ${d(1)}}
553.c-chef .egg{animation:flip 1s ease-in-out infinite ${d(1)}}
554@keyframes tilt{20%,40%{transform:rotate(-12deg)}}
555@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
556.c-chef .wipe{animation:wipe 11.3s ease-in-out infinite ${d(11.3)}}
557@keyframes wipe{0%,72%{transform:translate(0,0)}78%,84%{transform:translate(2px,-7px)}88%{transform:translate(0,-7px)}92%{transform:translate(2px,-7px)}100%{transform:translate(0,0)}}
558.c-chef .drop{animation:drop 11.3s linear infinite ${d(11.3)}}
559@keyframes drop{0%,76%{opacity:0;transform:translateY(0)}80%{opacity:1}96%{opacity:0;transform:translateY(5px)}100%{opacity:0}}
560.c-reading .pg,.c-writing .pg{transform-origin:0 50%;animation:page 4s ease-in-out infinite ${d(4)}}
561@keyframes page{0%,80%,100%{transform:scaleX(1)}88%{transform:scaleX(.1)}}
562.c-writing .wr{animation:scribble .4s steps(1) infinite ${d(0.4)}}
563@keyframes scribble{50%{transform:translate(-1px,1px)}}
564.c-greeting .wv{transform-origin:0 100%;animation:wave2 .6s ease-in-out infinite ${d(0.6)}}
565@keyframes wave2{50%{transform:rotate(-22deg)}}
566.c-propeller .prop{animation:spin .25s steps(1) infinite ${d(0.25)}}
567@keyframes spin{50%{transform:scaleX(.3)}}
568.c-fly{animation:fly 1.6s ease-in-out infinite ${d(1.6)}}
569@keyframes fly{0%,100%{transform:translateY(-3px)}50%{transform:translateY(-5px)}}
570.c-fly .prop{animation:spin .15s steps(1) infinite ${d(0.15)}}
571.c-fly .fa{animation:flap .3s steps(1) infinite ${d(0.3)}}.c-fly .fb{animation:flap .3s steps(1) infinite ${d(0.3, 0.15)}}
572@keyframes flap{50%{transform:translateY(-3px)}}
573.c-fly .la{animation:dangle 1.6s ease-in-out infinite ${d(1.6)}}.c-fly .lb{animation:dangle 1.6s ease-in-out infinite ${d(1.6, 0.8)}}
574@keyframes dangle{50%{transform:translateY(1px)}}
575.c-propeller .body{animation:jump 1.2s ease-out infinite ${d(1.2)}}
576@keyframes jump{0%,60%,100%{transform:translateY(0)}15%{transform:translateY(-7px)}30%{transform:translateY(0)}45%{transform:translateY(-4px)}}
577.c-sunbathe .um{transform-origin:50% 100%;animation:sway 4s ease-in-out infinite ${d(4)}}
578@keyframes sway{50%{transform:rotate(1.5deg)}}
579.c-sunbathe .sun{animation:blink 2s steps(1) infinite ${d(2)}}
580.c-sunbathe .body{animation:breathe 4s ease-in-out infinite ${d(4)}}
581.c-error .body{animation:shake .15s steps(1) infinite ${d(0.15)}}
582@keyframes shake{50%{transform:translateX(1px)}}
583.c-error .ex{animation:blink .5s steps(1) infinite ${d(0.5)}}
584.c-changing .pk,.c-changing .itm{animation:pkmove 1.4s ease-in-out forwards}
585.c-changing .itm{animation:pkmove 1.4s ease-in-out forwards,show 1.4s steps(1) forwards}
586@keyframes pkmove{0%{transform:translate(0,0)}20%,45%{transform:translate(-12px,4px)}80%,100%{transform:translate(var(--tx,-10px),var(--ty,-15px))}}
587.pop{transform-origin:50% 100%;animation:pop .4s ease-out both}
588@keyframes pop{0%{transform:scale(.82,1.18)}35%{transform:scale(1.12,.88)}65%{transform:scale(.97,1.04)}100%{transform:scale(1,1)}}
589.c-run .la{animation:sprint .16s steps(1) infinite ${d(0.16)}}.c-run .lb{animation:sprint .16s steps(1) infinite ${d(0.16, 0.08)}}
590@keyframes sprint{50%{transform:translateY(-5px)}}
591.c-run .body{transform-origin:50% 100%;animation:lean .32s ease-in-out infinite ${d(0.32)}}
592@keyframes lean{0%,100%{transform:rotate(7deg) translateY(0)}50%{transform:rotate(7deg) translateY(-2px)}}
593.c-run .ra{animation:pumpa .16s steps(1) infinite ${d(0.16)}}.c-run .rb{animation:pumpa .16s steps(1) infinite ${d(0.16, 0.08)}}
594@keyframes pumpa{50%{transform:translate(2px,-5px)}}
595.c-drive .body{animation:rumble .2s steps(1) infinite ${d(0.2)}}
596@keyframes rumble{50%{transform:translateY(1px)}}
597.c-drive .hub{animation:roll .12s steps(1) infinite ${d(0.12)}}
598@keyframes roll{50%{transform:translate(1px,1px)}}
599.c-drive .ra{animation:pumpa .3s steps(1) infinite ${d(0.3)}}.c-drive .rb{animation:pumpa .3s steps(1) infinite ${d(0.3, 0.15)}}
600.c-run .sl,.c-drive .sl{animation:rush .24s linear infinite ${d(0.24)}}
601@keyframes rush{0%{opacity:.9;transform:translateX(0)}100%{opacity:0;transform:translateX(-8px)}}
602.c-run .dust,.c-drive .dust{animation:dust .32s linear infinite ${d(0.32)}}
603@keyframes dust{0%{opacity:.9;transform:translate(0,0) scale(.6)}100%{opacity:0;transform:translate(-6px,-4px) scale(1.4)}}
604.c-pant .body{transform-origin:50% 100%;animation:pant .75s ease-in-out infinite ${d(0.75)}}
605@keyframes pant{0%,100%{transform:scale(1,1)}50%{transform:scale(1.06,.91)}}
606.c-pant .mouth{transform-origin:50% 0;animation:gasp .75s ease-in-out infinite ${d(0.75)}}
607@keyframes gasp{50%{transform:scaleY(1.7)}}
608.c-pant .sw{animation:drip 1.5s linear infinite ${d(1.5)}}
609@keyframes drip{0%{opacity:0;transform:translateY(0)}20%{opacity:1}100%{opacity:0;transform:translateY(7px)}}
610.c-pant .hf{animation:huff .75s ease-out infinite ${d(0.75)}}
611@keyframes huff{0%{opacity:.95;transform:translate(0,0) scale(.6)}100%{opacity:0;transform:translate(6px,-4px) scale(1.5)}}
612.c-park .body{animation:lurch .5s ease-out infinite ${d(0.5)}}
613@keyframes lurch{0%{transform:translateX(3px)}100%{transform:translateX(0)}}
614.c-park .bl{animation:blink .4s steps(1) infinite ${d(0.4)}}
615.c-park .dust{animation:dust .5s linear infinite ${d(0.5)}}
616.c-park .sw{animation:drip 1.5s linear infinite ${d(1.5)}}
617.c-brace .body{transform-origin:50% 100%;animation:crouch .7s ease-in infinite ${d(0.7)}}
618@keyframes crouch{0%{transform:scale(1,1)}80%,100%{transform:scale(1.1,.84)}}
619.c-brace .wd{animation:raise .7s ease-in infinite ${d(0.7)}}
620@keyframes raise{0%{transform:translateY(0)}100%{transform:translateY(-6px)}}
621.c-brace .g1,.c-brace .g2,.c-brace .g3{animation:gather .7s ease-in infinite ${d(0.7)}}
622.c-brace .g1{--gx:11px;--gy:9px}.c-brace .g2{--gx:-11px;--gy:9px}.c-brace .g3{--gx:0px;--gy:11px}
623@keyframes gather{0%{opacity:0;transform:translate(0,0)}25%{opacity:1}100%{opacity:0;transform:translate(var(--gx),var(--gy))}}
624@keyframes show{0%,44%{opacity:0}45%,100%{opacity:1}}
625.c-changing .puff{animation:puff 1.4s ease-out forwards}
626@keyframes puff{0%,80%{opacity:0;transform:scale(.3)}90%{opacity:1;transform:scale(1.3)}100%{opacity:0;transform:scale(1.6)}}
627.c-dance .da{animation:up .5s steps(1) infinite ${d(0.5)}}.c-dance .db{animation:up .5s steps(1) infinite ${d(0.5, 0.25)}}
628@keyframes up{50%{transform:translateY(-6px)}}
629.c-dance .body{animation:hop .5s ease-in-out infinite ${d(0.5)}}
630@keyframes hop{50%{transform:translateY(-2px)}}
631.c-crown .gem,.win .gem{animation:blink 1s steps(1) infinite ${d(1)}}
632.win .sp,.win .st{animation:blink .6s steps(1) infinite ${d(0.6)}}
633.win .cheer{animation:cheer .4s steps(1) infinite ${d(0.4)}}
634.win.vc-claude .body{transform-origin:50% 100%;animation:vclaude .5s ease-out infinite ${d(0.5)}}
635@keyframes vclaude{50%{transform:translateY(-3px)}}
636.win.vc-frog .body{transform-origin:50% 100%;animation:vfrog .6s ease-out infinite ${d(0.6)}}
637@keyframes vfrog{0%,100%{transform:translateY(0) scale(1.06,.9)}50%{transform:translateY(-9px) scale(.95,1.1)}}
638.win.vc-creeper .body{transform-origin:50% 100%;animation:vcreep .5s ease-in-out infinite ${d(0.5)}}
639@keyframes vcreep{0%,100%{transform:scale(1,1)}50%{transform:scale(1.14,1.14)}}
640.win.vc-slime .body{transform-origin:50% 100%;animation:vslime .5s ease-in-out infinite ${d(0.5)}}
641@keyframes vslime{0%,100%{transform:scale(1.12,.86)}50%{transform:translateY(-4px) scale(.9,1.14)}}
642.win.vc-robot .body{transform-origin:50% 100%;animation:vrobot .3s steps(1) infinite ${d(0.3)}}
643@keyframes vrobot{0%{transform:rotate(-4deg)}50%{transform:rotate(4deg)}}
644.win.vc-cat .body{transform-origin:50% 100%;animation:vcat 1s ease-in-out infinite ${d(1)}}
645@keyframes vcat{0%,100%{transform:rotate(-5deg)}50%{transform:rotate(5deg) translateY(-2px)}}
646.c-frozen .body,.c-frozen .eye{animation:none}
647.c-frozen .bd{animation:shiver .14s steps(1) infinite ${d(0.14)}}
648@keyframes shiver{50%{transform:translateX(1px)}}
649.c-frozen .gl{animation:blink 1.6s steps(1) infinite ${d(1.6)}}
650.c-sleeping .body{animation:breathe 3s ease-in-out infinite ${d(3)}}
651@keyframes breathe{50%{transform:translateY(1px)}}
652.c-sleeping .z1{animation:zf 2.4s ease-in infinite ${d(2.4)}}.c-sleeping .z2{animation:zf 2.4s ease-in infinite ${d(2.4, 1.2)}}
653@keyframes zf{0%{opacity:0;transform:translate(0,3px)}30%{opacity:1}100%{opacity:0;transform:translate(2px,-5px)}}
654.c-done .cheer{animation:cheer .4s steps(1) infinite ${d(0.4)}}
655@keyframes cheer{50%{transform:translateY(-2px)}}
656</style>`
657}
658
hooks/classify.ts 129 lines
1import type { Outfit, Sess } from '../types'
2
3export const SLEEP_MS = 5 * 60_000
4export const DONE_MS = 9_000
5export const LONG_MS = 2 * 60_000
6export const LONG_TOOLS = 25
7
8// Tiny on-device classifier: keyword weights over the prompt plus the tools used.
9// No network, no model call, no tokens.
10const FIX = /\b(fix|fixes|fixed|bug|bugs|broken|error|errors|fail|fails|failed|failing|crash|crashes|debug|issue|wrong|repair|patch|regression|not working|isn'?t working|doesn'?t work)\b/i
11const CODE = /\b(build|implement|add|create|write|refactor|code|function|component|feature|script|develop|make|mod|plugin)\b/i
12const DOCS = /\b(readme|docs?|documentation|essay|email|notes?|summary|summarize|blog|post|article|letter|draft|report|proposal)\b/i
13const CODEY = /\b(function|component|script|feature|plugin|code|api|class|refactor|implement)\b/i
14const REVIEW = /\b(review|audit|verify|check|test|lint|inspect|critique)\b/i
15const PLAN = /\b(plan|design|architect|outline|strategy|roadmap|brainstorm)\b/i
16const READING = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch'])
17
18export const isLong = (s: Sess, at: number): boolean => at - s.startedAt > LONG_MS || s.tools > LONG_TOOLS
19
20export function pickOutfit(s: Sess, at: number): Outfit {
21  if (!s.busy) {
22    if (s.doneAt > 0 && at - s.doneAt < DONE_MS) return s.end === 'fail' ? 'error' : s.end === 'long' ? 'crown' : 'done'
23    return at - s.lastActive > SLEEP_MS ? 'sleeping' : 'idle'
24  }
25  if (isLong(s, at)) return 'chef'
26  if (FIX.test(s.prompt)) return 'fixing'
27  if (s.edits === 0 && READING.has(s.lastTool)) return 'reading'
28  if (s.edits === 0 && REVIEW.test(s.prompt)) return 'cool'
29  if (s.edits === 0 && PLAN.test(s.prompt)) return 'idle'
30  if (DOCS.test(s.prompt) && !CODEY.test(s.prompt)) return 'writing'
31  if (s.edits > 0 || CODE.test(s.prompt)) return 'coding'
32  return 'working'
33}
34
35// Is the cache-tax / keepwarm status line, drawn below the bar by another plugin, saying "cold"?
36// It renders ' cache-tax · ' followed by a bold state word, so read the element's text in order.
37// The prompt cache lives 60 minutes after the last request.
38export const CACHE_TTL_MS = 60 * 60 * 1000
39
40// The state word of cache-tax's band ('cold', 'warm', ...), or null when the band is not in the tree.
41export function cacheState(node: unknown): string | null {
42  const out: string[] = []
43  const walk = (n: unknown, depth: number): void => {
44    if (depth > 12 || n === null || n === undefined) return
45    if (typeof n === 'string') out.push(n)
46    else if (Array.isArray(n)) n.forEach(c => walk(c, depth + 1))
47    else if (typeof n === 'object') {
48      const o = n as { props?: { children?: unknown }; children?: unknown }
49      walk(o.props?.children ?? o.children, depth + 1)
50    }
51  }
52  walk(node, 0)
53  const i = out.findIndex(t => t.includes('cache-tax'))
54  return i >= 0 ? (out[i + 1] ?? null) : null
55}
56
57export function isCacheCold(node: unknown): boolean {
58  return cacheState(node) === 'cold'
59}
60
61// Cold as the band would say it, or, when the band is out of sight (hook order), by our own clock.
62export function isIcedNow(node: unknown, doneAt: number, at: number): boolean {
63  const st = cacheState(node)
64  return st !== null ? st === 'cold' : doneAt > 0 && at - doneAt >= CACHE_TTL_MS
65}
66
67// What he was doing when the turn ended, and the victory pose that goes with it.
68export function winPose(s: Sess, jumpBy: number, jumpedThisTurn: boolean): Outfit {
69  if (jumpedThisTurn && jumpBy >= 0.3) return 'exitCar'
70  if (jumpedThisTurn && jumpBy >= 0.12) return 'medal'
71  const task = pickOutfit({ ...s, busy: true }, s.doneAt || s.lastActive)
72  return task === 'coding' ? 'winCode' : task === 'fixing' ? 'winFix' : task === 'reading' ? 'winRead' : task === 'writing' ? 'winWrite' : 'done'
73}
74
75// Bar value for a turn with no explicit progress: creeps toward 95% with tools and time.
76export function autoRatio(s: Sess, at: number): number {
77  if (s.busy) return Math.min(0.95, 1 - Math.exp(-(s.tools * 0.12 + (at - s.startedAt) / 90_000)))
78  return s.doneAt > 0 && at - s.doneAt < DONE_MS ? 1 : 0
79}
80
81// What he does when nothing is happening: a loop of steps. A stroll walks to the given
82// spot (0..1 of the bar) slowly; every other step stands still where the last stroll ended.
83// Poses that travel along the bar (the others stand still).
84export const MOVING = new Set<Outfit>(['stroll', 'walkCool', 'walkWizard', 'walkChef', 'walkCode', 'walkCmd', 'fly'])
85export const POSE_MS = 8_000
86export const STROLL_MS = 18_000
87export const IDLE_SCRIPT: readonly [Outfit, number | null][] = [
88  ['stroll', 0.55],
89  ['idle', null],
90  ['walkCool', 0.15],
91  ['reading', null],
92  ['fly', 0.8],
93  ['greeting', null],
94  ['walkWizard', 0.35],
95  ['propeller', null],
96  ['walkCode', 0.65],
97  ['cool', null],
98  ['walkChef', 0.1],
99  ['dance', null],
100  ['walkCmd', 0.5],
101  ['sunbathe', null],
102]
103
104const LENGTH: number[] = IDLE_SCRIPT.map(([, target]) => (target === null ? POSE_MS : STROLL_MS))
105export const IDLE_LOOP_MS = LENGTH.reduce((a, b) => a + b, 0)
106
107// Where each step starts: the end of the nearest stroll before it (the script wraps).
108const START: number[] = IDLE_SCRIPT.map((_, k) => {
109  for (let j = 1; j <= IDLE_SCRIPT.length; j++) {
110    const x = IDLE_SCRIPT[(k - j + IDLE_SCRIPT.length * 2) % IDLE_SCRIPT.length]?.[1]
111    if (x !== null && x !== undefined) return x
112  }
113  return 0
114})
115
116// A costume change (pulling the next one from a pocket) takes this long.
117export const CHANGE_MS = 1_400
118
119export function roamAt(at: number): { pose: Outfit; x: number; age: number } {
120  let t = ((at % IDLE_LOOP_MS) + IDLE_LOOP_MS) % IDLE_LOOP_MS
121  let k = 0
122  while (k < LENGTH.length - 1 && t >= (LENGTH[k] ?? 0)) t -= LENGTH[k++] ?? 0
123  const [pose, target] = IDLE_SCRIPT[k] ?? ['idle', null]
124  const from = START[k] ?? 0
125  if (target === null) return { pose, x: from, age: t }
126  const f = t / (LENGTH[k] ?? STROLL_MS)
127  const eased = f < 0.5 ? 2 * f * f : 1 - 2 * (1 - f) * (1 - f)
128  return { pose, x: from + (target - from) * eased, age: t }
129}
hooks/agents.ts 272 lines
1import type { AgentRun, Character, Outfit } from '../types'
2import { ITEM_COLOR, ITEM_DEST, crabCss, crabSvg } from './crab'
3import { CHANGE_MS, pickOutfit } from './classify'
4
5// Subagent panel drawing, ported from savvy-progress (MIT, JohnnyVizz/claude-kit) into this
6// mod. Pure functions only: the hooks that feed it live in register.tsx.
7
8const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
9export const ROW_H = 66
10export const LINE_H = 24
11const GRID_STEP = 16
12const GRID_TILE = 4096
13const DOT = '·'
14const ELLIPSIS = '…'
15const APPROX = '≈'
16
17// --- model facts -----------------------------------------------------------
18
19type Price = [input: number, output: number, cacheRead: number, cacheWrite: number]
20
21// USD per million tokens; the first row that matches the model id wins. Cache writes are
22// priced at the 1-hour rate (2x input), which is what Claude Code writes on a subscription.
23const PRICES: [RegExp, Price][] = [
24  [/(fable|mythos)-5-1/, [10, 50, 0.25, 20]],
25  [/fable|mythos/, [10, 50, 1, 20]],
26  [/opus-5-5/, [4, 20, 0.2, 8]],
27  [/opus/, [5, 25, 0.5, 10]],
28  [/sonnet-5-5/, [2, 10, 0.1, 4]],
29  [/sonnet-5/, [2, 10, 0.2, 4]],
30  [/sonnet/, [3, 15, 0.3, 6]],
31  [/haiku-5-5/, [0.1, 0.5, 0.01, 0.2]],
32  [/haiku/, [1, 5, 0.1, 2]],
33]
34const HAIKU_LONG_PROMPT = 100_000
35const HAIKU_LONG: Price = [0.5, 2.5, 0.05, 1]
36
37export type Usage = {
38  input_tokens: number
39  output_tokens: number
40  cache_read_input_tokens: number
41  cache_creation_input_tokens: number
42}
43
44const priceOf = (model: string, prompt: number): Price => {
45  const id = model.toLowerCase()
46  if (prompt > HAIKU_LONG_PROMPT && /haiku-5-5/.test(id)) return HAIKU_LONG
47  return PRICES.find(([re]) => re.test(id))?.[1] ?? [4, 20, 0.2, 8]
48}
49
50// `isRequest` says the usage is one model request, which is what a prompt-length price is
51// set by; a turn's sum is priced at the model's ordinary prices.
52export const costOf = (model: string, u: Usage, isRequest: boolean): number => {
53  const prompt = isRequest ? (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : 0
54  const [i, o, r, w] = priceOf(model, prompt)
55  return (
56    ((u.input_tokens || 0) * i + (u.output_tokens || 0) * o + (u.cache_read_input_tokens || 0) * r + (u.cache_creation_input_tokens || 0) * w) /
57    1e6
58  )
59}
60
61export const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000)
62
63export const modelName = (id: string): string => {
64  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
65  const [, family = '', major = '', minor] = m ?? []
66  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
67  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
68}
69
70const FAMILY_COLOR: Record<string, string> = { fable: '#7F77DD', mythos: '#7F77DD', opus: '#D85A30', sonnet: '#378ADD', haiku: '#1D9E75' }
71const familyOf = (model: string): string => /(fable|mythos|opus|sonnet|haiku)/i.exec(model)?.[1]?.toLowerCase() ?? 'other'
72const colorOf = (a: AgentRun): string => FAMILY_COLOR[familyOf(a.model)] ?? '#888780'
73
74// What a running agent looks like follows the task: its brief and the tools it is using.
75export const taskOutfit = (a: AgentRun, at: number): Outfit =>
76  pickOutfit(
77    {
78      busy: true,
79      startedAt: a.startedAt,
80      lastActive: at,
81      doneAt: 0,
82      tools: a.tools ?? 0,
83      edits: a.edits ?? 0,
84      prompt: `${a.description} ${a.type}`,
85      lastTool: a.lastTool ?? (/explore/i.test(a.type) ? 'Read' : ''),
86      end: '',
87    },
88    at,
89  )
90
91const ACTIVITY: Partial<Record<Outfit, string>> = {
92  coding: 'coding',
93  fixing: 'fixing',
94  reading: 'reading',
95  writing: 'writing',
96  cool: 'reviewing',
97  idle: 'planning',
98  chef: 'cooking',
99  working: 'thinking',
100  error: 'error',
101}
102
103// What a finished agent does while it waits, a new pose every 8 s (offset per agent).
104const IDLE_POSES: Outfit[] = ['idle', 'greeting', 'cool', 'dance', 'propeller', 'sunbathe', 'reading']
105const hash = (s: string): number => [...s].reduce((h, ch) => (h * 31 + (ch.codePointAt(0) ?? 0)) % 100003, 7)
106
107// The costume a working agent wears follows its model; the character under it follows /mascot.
108// A finished agent cheers for a few seconds, then idles through a loop of small animations.
109export const outfitFor = (a: AgentRun, at: number): Outfit => {
110  if (a.status === 'done') {
111    if (at - (a.endedAt ?? at) < 6_000) return 'done'
112    return IDLE_POSES[(hash(a.id) + Math.floor(at / 8_000)) % IDLE_POSES.length] ?? 'idle'
113  }  if (a.status === 'failed') return 'error'
114  if (a.outfit) return a.outfit
115  return taskOutfit(a, at)
116}
117
118// --- formatting ------------------------------------------------------------
119
120const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
121export const sameTask = (a: string, b: string): boolean => norm(a) === norm(b)
122
123const fmtTokens = (n: number): string => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`)
124const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
125export const fmtTime = (ms: number): string => {
126  const s = Math.max(0, Math.round(ms / 1000))
127  const h = Math.floor(s / 3600)
128  const m = Math.floor((s % 3600) / 60)
129  const ss = String(s % 60).padStart(2, '0')
130  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
131}
132
133const xml = (s: string): string => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
134
135// CJK and fullwidth text advances about 1em; the rest is a rough proportional estimate.
136const charEm = (ch: string): number =>
137  (ch.codePointAt(0) ?? 0) >= 0x2e80 ? 1 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%mw]/.test(ch) ? 0.72 : 0.56
138const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
139const fitText = (s: string, size: number, maxW: number): string => {
140  if (textWidth(s, size) <= maxW) return s
141  let out = ''
142  for (const ch of s) {
143    if (textWidth(out + ch + ELLIPSIS, size) > maxW) break
144    out += ch
145  }
146  return out + ELLIPSIS
147}
148
149// --- numbers ---------------------------------------------------------------
150
151export const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
152
153export const totals = (list: AgentRun[], at: number) => {
154  const cost = list.reduce((s, a) => s + a.costUsd, 0)
155  const tokens = list.reduce((s, a) => s + a.tokens, 0)
156  const start = Math.min(...list.map(a => a.startedAt))
157  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
158  return { cost, tokens, time: list.length ? end - start : 0 }
159}
160export type Totals = ReturnType<typeof totals>
161
162const progressOf = (a: AgentRun): number | null => {
163  if (a.status === 'done') return 1
164  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
165  return null
166}
167const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
168
169export const summaryOf = (t: Totals): string => `${APPROX}${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} tokens, ${fmtTime(t.time)}`
170
171// --- drawings --------------------------------------------------------------
172
173const PANE_CSS = `<style>
174.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
175@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
176.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
177</style>`
178
179const svg = (W: number, H: number, at: number, body: string): string =>
180  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${crabCss(at)}${body}</svg>`
181
182// The pane's backdrop: a dotted grid as tall as the content laid over it.
183export const gridTiles = (H: number): number[] => Array.from({ length: Math.ceil(H / GRID_TILE) }, (_, i) => Math.min(GRID_TILE, H - i * GRID_TILE))
184export const gridSvg = (W: number, H: number): string => `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
185<style>.d{fill:#d9d8d4}@media (prefers-color-scheme: dark){.d{fill:#3a3a38}}</style>
186<defs><pattern id="g" width="${GRID_STEP}" height="${GRID_STEP}" patternUnits="userSpaceOnUse"><circle class="d" cx="${GRID_STEP / 2}" cy="${GRID_STEP / 2}" r="1"/></pattern></defs>
187<rect width="${W}" height="${H}" fill="url(#g)"/>
188</svg>`
189
190export const HEADER_H = 44
191
192export const headerSvg = (W: number, at: number, t: Totals): string => {
193  const gap = 6
194  const tw = (W - gap * 2) / 3
195  const tile = (i: number, k: string, v: string): string =>
196    `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>
197<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${k}</text>
198<text class="t" x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
199  return svg(W, HEADER_H, at, `${tile(0, 'Cost', APPROX + fmtCost(t.cost))}${tile(1, 'Tokens', fmtTokens(t.tokens))}${tile(2, 'Time', fmtTime(t.time))}`)
200}
201
202export const agentSvg = (W: number, a: AgentRun, at: number, character: Character): string => {
203  const color = colorOf(a)
204  const ctx = ctxOf(a)
205  const textW = W - 42 - 22
206  const meta = [a.effort ? `${modelName(a.model)} ${DOT} ${a.effort}` : modelName(a.model)]
207  if (a.round > 1) meta.push(`round ${a.round}`)
208  if (a.status === 'failed') meta.push('error')
209  const doing = a.status === 'running' ? ACTIVITY[outfitFor(a, at)] : undefined
210  if (doing) meta.push(doing)
211  const progress = progressOf(a)
212  const o = outfitFor(a, at)
213  const item = ITEM_COLOR[o]
214  const age = a.outfitAt === undefined ? Infinity : at - a.outfitAt
215  const isChanging = a.status === 'running' && item !== undefined && age < CHANGE_MS
216  const shown: Outfit = isChanging ? 'changing' : o
217  const change = isChanging ? { item, age, dest: ITEM_DEST[o] } : undefined
218  const settle = a.status === 'running' && !isChanging ? age - (item === undefined ? 0 : CHANGE_MS) : undefined
219  const stats = `ctx ${ctx}% ${DOT} ${fmtTokens(a.contextTokens)}  ${APPROX}${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
220  const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ` ${DOT} ${a.stepNote}` : ''}` : ''
221  const stepsW = Math.max(0, textW - textWidth(stats, 11) - 12)
222  const fillW = Math.round(textW * (progress ?? ctx / 100))
223  const mark =
224    a.status === 'running'
225      ? `<circle class="live" cx="${W - 8}" cy="16" r="3.5" fill="${color}"/>`
226      : a.status === 'done'
227        ? `<path d="M${W - 13} 16l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
228        : `<path d="M${W - 12} 12l8 8M${W - 4} 12l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
229  return svg(
230    W,
231    ROW_H,
232    at,
233    `${crabSvg(shown, 0, 22, a.status === 'running', character, change, settle)}
234<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
235<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(a.type)}</tspan><tspan class="s">  ${xml(meta.join(`  ${DOT}  `))}</tspan></text>
236${steps && stepsW > 30 ? `<text class="t" x="42" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(steps, 11, stepsW))}</text>` : ''}
237<text class="s" x="${42 + textW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
238<rect class="k" x="42" y="55" width="${textW}" height="4" rx="2"/><rect${progress === null ? ' class="m"' : ''} x="42" y="55" width="${fillW}" height="4" rx="2"${progress === null ? '' : ` fill="${color}"`}/>
239${mark}
240<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
241  )
242}
243
244export const compactSvg = (W: number, list: AgentRun[], at: number, t: Totals, character: Character): string => {
245  const ordered = [...list.filter(a => a.status === 'running'), ...list.filter(a => a.status !== 'running')]
246  const fit = Math.max(1, Math.floor((W - 150) / 40))
247  const shown = ordered.slice(0, fit)
248  const more = ordered.length - shown.length
249  const body = shown.map((a, i) => crabSvg(outfitFor(a, at), i * 40, 6, a.status === 'running', character)).join('')
250  return svg(
251    W,
252    40,
253    at,
254    `${body}${more ? `<text class="s" x="${shown.length * 40 + 4}" y="26" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
255<text class="s" x="${W}" y="26" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${APPROX}${fmtCost(t.cost)} ${DOT} ${fmtTokens(t.tokens)} ${DOT} ${fmtTime(t.time)}</text>`,
256  )
257}
258
259// --- terminal rows ---------------------------------------------------------
260
261export const ctxBar = (pct: number, width: number): string => {
262  const filled = Math.round((width * pct) / 100)
263  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
264}
265export const agentLine = (a: AgentRun, at: number): { title: string; meta: string; stats: string; progress: number | null; ctx: number } => ({
266  title: a.description || a.type,
267  meta: `${a.type} ${DOT} ${a.effort ? `${modelName(a.model)} ${DOT} ${a.effort}` : modelName(a.model)}`,
268  stats: `${a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal} ${DOT} ` : ''}ctx ${ctxOf(a)}% ${DOT} ${fmtTokens(a.contextTokens)} ${APPROX}${fmtCost(a.costUsd)} ${fmtTime(elapsed(a, at))}`,
269  progress: progressOf(a),
270  ctx: ctxOf(a),
271})
272
hooks/ft-open.ts 68 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
42function words(command: string): string[] {
43  const out: string[] = []
44  let word: string | null = null
45  let quote = ''
46  for (const ch of command) {
47    if (quote) {
48      if (ch === quote) quote = ''
49      else word = (word ?? '') + ch
50    } else if (ch === '"' || ch === "'") {
51      quote = ch
52      word ??= ''
53    } else if (/\s/.test(ch)) {
54      if (word !== null) out.push(word)
55      word = null
56    } else word = (word ?? '') + ch
57  }
58  if (word !== null) out.push(word)
59  return out
60}
61
62export function openWithCommand(command: string, path: string, dir: string): string[] | null {
63  const argv = words(command)
64  if (!argv.length) return null
65  const filled = argv.map(w => w.replace(/\{(path|dir)\}/g, (_, key) => (key === 'path' ? path : dir)))
66  return argv.some(w => w.includes('{path}')) ? filled : [...filled, path]
67}
68
hooks/ft-button.tsx 19 lines
1import type { ClientModule } from 'claude-code'
2
3export type ButtonProps = { id: string; label: string; dim: boolean }
4
5// A header button that also tells the hooks module when the pointer is over it, which a Button
6// alone doesn't, so the header can say what the button does.
7const HeaderButton: ClientModule<ButtonProps, { over: boolean }> = (props, surface) => {
8  const { Button } = surface.elements
9  surface.onPointer(e => {
10    const over = e.type !== 'leave'
11    if (over === Boolean(surface.state?.over)) return
12    surface.setState({ over })
13    surface.post({ over })
14  })
15  return <Button key={props.id} plain dimColor={props.dim} label={props.label} onPress={() => surface.post({ press: true })} />
16}
17
18export default HeaderButton
19
hooks/ft-git.ts 262 lines
1import { drivesOn, isAbsolute, posix } from './ft-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: ['#D97757', '#E48B6D', '#EDA487', '#FAD9C9'], dim: ['#7d3f2a', '#92503a', '#aa6a52', '#bf8872'], solid: '#E48B6D' },
29  green: { bright: ['#22c55e', '#4ade80', '#86efac', '#dcfce7'], dim: ['#14532d', '#166534', '#2f7a47', '#4f9a66'], solid: '#4ade80' },
30  teal: { bright: ['#14b8a6', '#2dd4bf', '#5eead4', '#ccfbf1'], dim: ['#0f5e57', '#16786f', '#2a9488', '#4fb3a8'], solid: '#2dd4bf' },
31  blue: { bright: ['#3b82f6', '#60a5fa', '#93c5fd', '#dbeafe'], dim: ['#1e3a8a', '#1d4ed8', '#3b6fd1', '#6b93dc'], solid: '#60a5fa' },
32  purple: { bright: ['#a855f7', '#c084fc', '#d8b4fe', '#f3e8ff'], dim: ['#581c87', '#6b21a8', '#8b47c4', '#a874d6'], solid: '#c084fc' },
33  cyan: { bright: ['#06b6d4', '#22d3ee', '#67e8f9', '#cffafe'], dim: ['#155e75', '#0e7490', '#2b8ea3', '#5aa9b8'], solid: '#22d3ee' },
34  red: { bright: ['#ef4444', '#f87171', '#fca5a5', '#fee2e2'], dim: ['#7f1d1d', '#991b1b', '#b54040', '#c96a6a'], solid: '#f87171' },
35}
36
37const GIT_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
38  commit: { verb: 'commit', running: 'committing', done: 'committed', tone: 'green', icon: ICON.commit },
39  push: { verb: 'push', running: 'pushing', done: 'pushed', tone: 'teal', icon: ICON.push },
40  pull: { verb: 'pull', running: 'pulling', done: 'pulled', tone: 'blue', icon: ICON.pull },
41  fetch: { verb: 'fetch', running: 'fetching', done: 'fetched', tone: 'blue', icon: ICON.pull },
42  checkout: { verb: 'checkout', running: 'checking out', done: 'checked out', tone: 'blue', icon: ICON.branch },
43  switch: { verb: 'switch', running: 'switching', done: 'switched', tone: 'blue', icon: ICON.branch },
44  branch: { verb: 'branch', running: 'branching', done: 'branched', tone: 'blue', icon: ICON.branch },
45  merge: { verb: 'merge', running: 'merging', done: 'merged', tone: 'purple', icon: ICON.merge },
46  rebase: { verb: 'rebase', running: 'rebasing', done: 'rebased', tone: 'purple', icon: ICON.merge },
47  'cherry-pick': { verb: 'cherry-pick', running: 'cherry-picking', done: 'cherry-picked', tone: 'purple', icon: ICON.merge },
48  stash: { verb: 'stash', running: 'stashing', done: 'stashed', tone: 'orange', icon: ICON.stash },
49  reset: { verb: 'reset', running: 'resetting', done: 'reset', tone: 'red', icon: ICON.undo },
50  restore: { verb: 'restore', running: 'restoring', done: 'restored', tone: 'red', icon: ICON.undo },
51  revert: { verb: 'revert', running: 'reverting', done: 'reverted', tone: 'red', icon: ICON.undo },
52  add: { verb: 'add', running: 'staging', done: 'staged', tone: 'green', icon: ICON.git },
53  rm: { verb: 'rm', running: 'removing', done: 'removed', tone: 'red', icon: ICON.git },
54  mv: { verb: 'mv', running: 'moving', done: 'moved', tone: 'orange', icon: ICON.git },
55  tag: { verb: 'tag', running: 'tagging', done: 'tagged', tone: 'purple', icon: ICON.tag },
56  init: { verb: 'init', running: 'initialising', done: 'initialised', tone: 'green', icon: ICON.git, init: true },
57  clone: { verb: 'clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.pull, init: true },
58}
59
60const GH_VERBS: Record<string, Omit<GitAction, 'kind'>> = {
61  'pr create': { verb: 'pr create', running: 'opening PR', done: 'opened PR', tone: 'purple', icon: ICON.pr },
62  'pr merge': { verb: 'pr merge', running: 'merging PR', done: 'merged PR', tone: 'purple', icon: ICON.merge },
63  'pr checkout': { verb: 'pr checkout', running: 'checking out PR', done: 'checked out PR', tone: 'blue', icon: ICON.pr },
64  'pr comment': { verb: 'pr comment', running: 'commenting on PR', done: 'commented on PR', tone: 'purple', icon: ICON.pr },
65  'pr review': { verb: 'pr review', running: 'reviewing PR', done: 'reviewed PR', tone: 'purple', icon: ICON.pr },
66  'repo clone': { verb: 'repo clone', running: 'cloning', done: 'cloned', tone: 'blue', icon: ICON.github, init: true },
67  'release create': { verb: 'release create', running: 'releasing', done: 'released', tone: 'purple', icon: ICON.tag },
68  'issue create': { verb: 'issue create', running: 'opening issue', done: 'opened issue', tone: 'purple', icon: ICON.github },
69}
70
71const READ_ONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'blame', 'rev-parse', 'ls-files', 'grep', 'describe', 'config', 'remote', 'reflog', 'shortlog', 'help', 'version', 'check-ignore'])
72
73function segments(command: string, seps: string[] = []): string[][] {
74  const out: string[][] = []
75  let cur: string[] = []
76  let tok = ''
77  let quote = ''
78  let has = false
79  const endTok = () => {
80    if (has) cur.push(tok)
81    tok = ''
82    has = false
83  }
84  const endSeg = () => {
85    endTok()
86    if (cur.length) out.push(cur)
87    cur = []
88  }
89  for (let i = 0; i < command.length; i++) {
90    const ch = command[i] ?? ''
91    if (quote) {
92      if (ch === quote) quote = ''
93      else if (ch === '\\' && quote === '"' && i + 1 < command.length) tok += command[++i]
94      else tok += ch
95    } else if (ch === '"' || ch === "'") {
96      quote = ch
97      has = true
98    } else if (ch === '\\' && i + 1 < command.length) {
99      tok += command[++i]
100      has = true
101    } else if (ch === '#' && !has) {
102      while (i + 1 < command.length && command[i + 1] !== '\n') i++
103    } else if (ch === ' ' || ch === '\t') endTok()
104    else if (ch === '\n' || ch === ';') {
105      seps.push(';')
106      endSeg()
107    } else if (ch === '&' && (command[i - 1] === '>' || command[i + 1] === '>')) {
108      tok += ch
109      has = true
110    } else if (ch === '&' || ch === '|') {
111      const twice = command[i + 1] === ch
112      if (twice) i++
113      seps.push(twice ? ch + ch : ch)
114      endSeg()
115    } else {
116      tok += ch
117      has = true
118    }
119  }
120  endSeg()
121  if (!quote) return out
122  seps.push(';')
123  return command
124    .split(/&&|\|\||;|\||\n/)
125    .map(s => s.trim().split(/\s+/).filter(Boolean))
126    .filter(s => s.length > 0)
127}
128
129function stripGlobals(tokens: string[]): string[] {
130  const out = [...tokens]
131  if (out[0] === 'env') out.shift()
132  while (out.length && /^[A-Z_][A-Z0-9_]*=/.test(out[0] ?? '')) out.shift()
133  return out
134}
135
136function gitVerb(tokens: string[]): number {
137  let i = 1
138  while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
139    if (tokens[i] === '-C' || tokens[i] === '-c') i++
140    i++
141  }
142  return i
143}
144
145function gitReadOnly(verb: string, rest: string[]): boolean {
146  if (!verb || READ_ONLY_GIT.has(verb)) return true
147  if (verb === 'stash') return ['list', 'show'].includes(rest[0] ?? '')
148  if (verb === 'branch') return rest.every(t => t.startsWith('-'))
149  if (verb === 'tag') return rest.length === 0 || rest.some(t => /^(-l|--list|-n\d*|--contains|--points-at|--merged|--no-merged|-v|--verify)$/.test(t))
150  return false
151}
152
153export function chainOf(command: string): { size: number; and: boolean } {
154  const seps: string[] = []
155  const size = segments(command, seps).length
156  return { size, and: seps.every(s => s === '&&') }
157}
158
159export function gitActions(command: string): GitAction[] {
160  const out: GitAction[] = []
161  for (const raw of segments(command)) {
162    const tokens = stripGlobals(raw)
163    const head = tokens[0]?.split('/').pop()
164    if (head === 'git') {
165      const i = gitVerb(tokens)
166      const verb = tokens[i] ?? ''
167      const rest = tokens.slice(i + 1)
168      if (gitReadOnly(verb, rest)) continue
169      const spec = verb === 'checkout' && rest.includes('--') ? GIT_VERBS.restore : GIT_VERBS[verb]
170      if (spec) out.push({ kind: `git ${verb}`, ...spec })
171    } else if (head === 'gh') {
172      const pair = `${tokens[1] ?? ''} ${tokens[2] ?? ''}`
173      const spec = GH_VERBS[pair]
174      if (spec) out.push({ kind: `gh ${pair}`, ...spec })
175    }
176  }
177  return out
178}
179
180export const BRANCH_ICON = ICON.branch
181
182const READERS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind', 'cat', 'head', 'tail', 'bat', 'less', 'more', 'ls', 'eza', 'tree', 'wc', 'sed', 'awk', 'jq'])
183const PATTERN_FIRST = new Set(['rg', 'grep', 'egrep', 'fgrep', 'sed', 'awk', 'jq'])
184const OUTPUT_PATHS = new Set(['rg', 'grep', 'egrep', 'fgrep', 'find', 'fd', 'fdfind'])
185
186function tidy(path: string): string {
187  const s = path.replace(/\/+$/, '')
188  return s === '' ? '/' : /^[A-Za-z]:$/.test(s) && isAbsolute(`${s}/`) ? `${s}/` : s
189}
190
191export function resolve(cwd: string, p: string, home = ''): string {
192  let path = posix(p)
193  if (home && (path === '~' || path.startsWith('~/'))) path = home + path.slice(1)
194  const drive = isAbsolute(path) && !path.startsWith('/') ? path.slice(0, 2) : ''
195  const parts = isAbsolute(path) ? [drive] : posix(cwd).replace(/\/+$/, '').split('/')
196  for (const seg of path.slice(drive.length).split('/')) {
197    if (!seg || seg === '.') continue
198    if (seg === '..') {
199      if (parts.length > 1) parts.pop()
200    } else parts.push(seg)
201  }
202  return tidy(parts.join('/'))
203}
204
205const WRITE_FLAGS = /^-(delete|exec|execdir|ok|okdir|fprint\w*|fls|x|X|-exec|-exec-batch)$/
206
207export function readOnly(command: string): boolean {
208  const bare = ` ${command}`.replace(/\d*>&\d/g, ' ').replace(/(\d*|&)>>?\s*\/dev\/null/g, ' ')
209  if (/[^>]>[^>&]|>>/.test(bare)) return false
210  return segments(command).every(raw => {
211    const tokens = stripGlobals(raw)
212    const head = tokens[0]?.split('/').pop() ?? ''
213    if (head === 'cd' || head === 'echo' || head === 'printf' || head === 'true' || head === 'pwd') return true
214    if (head === 'git') {
215      const i = gitVerb(tokens)
216      return gitReadOnly(tokens[i] ?? '', tokens.slice(i + 1))
217    }
218    if (head === 'sed') return !tokens.some(t => /^-i/.test(t))
219    if (head === 'find' || head === 'fd' || head === 'fdfind') return !tokens.some(t => WRITE_FLAGS.test(t))
220    return READERS.has(head)
221  })
222}
223
224export function readTargets(command: string, sessionCwd: string, stdout: string, home = ''): string[] {
225  let cwd = sessionCwd
226  const out = new Set<string>()
227  let listsPaths = false
228  for (const raw of segments(command)) {
229    const tokens = stripGlobals(raw).map(t => posix(t.replace(/^["']|["']$/g, '')))
230    const head = tokens[0]?.split('/').pop() ?? ''
231    if (head === 'cd' && tokens[1]) {
232      cwd = resolve(cwd, tokens[1], home)
233      continue
234    }
235    if (!READERS.has(head)) continue
236    if (head === 'sed' && tokens.some(t => /^-i/.test(t))) continue
237    if (OUTPUT_PATHS.has(head)) listsPaths = true
238    const args = tokens.slice(1)
239    let skipPattern = PATTERN_FIRST.has(head) && !args.some(t => t === '-e' || t === '-f' || t === '--files')
240    for (let i = 0; i < args.length; i++) {
241      const a = args[i] ?? ''
242      if (a.startsWith('-')) {
243        if (/^-(e|f|g|t|T|m|A|B|C|-glob|-type|-max-count|name|iname|maxdepth|mindepth)$/.test(a)) i++
244        continue
245      }
246      if (skipPattern) {
247        skipPattern = false
248        continue
249      }
250      if (/[*?<>|]/.test(a)) continue
251      out.add(resolve(cwd, a, home))
252    }
253  }
254  if (listsPaths) {
255    for (const line of stdout.split('\n').slice(0, 400)) {
256      const path = posix(line).match(drivesOn() ? /^((?:[A-Za-z]:)?[^:\0]+?)(?::\d+[:-]|$|:)/ : /^([^:\0]+?)(?::\d+[:-]|$|:)/)?.[1]?.trim()
257      if (path && !path.includes(' ')) out.add(resolve(cwd, path, home))
258    }
259  }
260  return [...out].slice(0, 60)
261}
262
hooks/ft-rows.tsx 361 lines
1import type { ClientModule, RenderElement } from 'claude-code'
2
3export type Seg = { t: string; c?: string; b?: boolean; s?: boolean; i?: boolean; sh?: string; dim?: boolean; one?: boolean; spin?: boolean; bg?: string; tab?: string }
4export type RowSpec = { id: string; left: Seg[]; right: Seg[]; kind?: 'dir' | 'file' | 'link' }
5
6// The crab digs in three of the real rows: it dives into rows[0], throws things out until it
7// finds one, jumps into rows[1] and pokes its head out of rows[2]. Each of those rows gets a
8// burrow strip under it, drawn here, in the same grid as the rows.
9export type DigSpec = {
10  rows: [number, number, number]
11  color: string
12  dark: string
13  stage: string
14  glow: string
15  // Ticks into the loop to start from (a frame of the animation, for tests and screenshots).
16  start?: number
17}
18
19export type RowsProps = {
20  rows: RowSpec[]
21  active: string
22  activeBg: string
23  hoverBg: string
24  tones: Record<string, { bright: string[]; dim: string[] }>
25  spinner?: string[]
26  pointer?: boolean
27  bar?: { pos: number; size: number; thumb: string; track: string }
28  preview?: { text: string; path: string; startLine: number; markdown: boolean }
29  // Rows two cells high, so the tree has room to breathe (and for the crab to work in).
30  tall?: boolean
31  dig?: DigSpec
32}
33type Local = { hover: number; phase: number; drag: boolean; ref: { stop?: () => void; unpoint?: () => void } }
34
35const TICK_MS = 90
36const TREE_BUDGET = 70_000
37const MAX_ROWS = 1_500
38const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
39
40// --- the crab, drawn from half blocks: a cell is one pixel wide and two pixel rows tall ---
41const STAGE_LINES = 4 // three lines of sprite space and the ground
42const PIX_ROWS = 6
43const LOOP = 112 // ticks (about 10 s)
44const BODY = ['..cccc..', '.ceccec.', 'cccccccc', '.cccccc.']
45const LOOK_L = '.eccecc.'
46const LOOK_R = '.ccecce.'
47const LEGS: [string, string][] = [
48  ['.l.ll.l.', '.l.ll.l.'],
49  ['l.l..l.l', 'l.l..l.l'],
50]
51const CRAB_W = 8
52const ITEMS = ['#EEEDE8', '#8E929A', '#D9A23A', '#7FC4F2', '#C0705A', '#4FA37A', '#A992E0', '#F5C542']
53const GEM = '#F5C542'
54const FLOOR = '#3a3a38'
55
56type Canvas = (string | undefined)[][]
57
58const blank = (w: number): Canvas => Array.from({ length: PIX_ROWS }, () => Array<string | undefined>(w).fill(undefined))
59
60function put(c: Canvas, x: number, y: number, color: string): void {
61  const row = c[y]
62  if (row && x >= 0 && x < row.length) row[x] = color
63}
64
65function crab(c: Canvas, x: number, y: number, frame: number, color: string, dark: string, look: -1 | 0 | 1): void {
66  const rows = [...BODY]
67  if (look < 0) rows[1] = LOOK_L
68  if (look > 0) rows[1] = LOOK_R
69  const [legA, legB] = LEGS[frame % 2] ?? LEGS[0] ?? ['', '']
70  const all = [...rows, legA, legB]
71  all.forEach((line, r) => {
72    for (let k = 0; k < CRAB_W; k++) {
73      const ch = line[k]
74      if (ch === 'c') put(c, x + k, y + r, color)
75      else if (ch === 'e') put(c, x + k, y + r, dark)
76      else if (ch === 'l') put(c, x + k, y + r, color)
77    }
78  })
79}
80
81type StageScene = { crab?: { x: number; y: number; frame: number; look: -1 | 0 | 1; gem: boolean } }
82
83const ease = (t: number, a: number, b: number, from: number, to: number): number => from + (to - from) * Math.min(1, Math.max(0, (t - a) / (b - a)))
84
85// Where the crab is, per burrow, for a tick of the loop.
86function script(t: number, w: number): [StageScene, StageScene, StageScene] {
87  const xA = 3
88  const xB = Math.max(xA + CRAB_W + 2, Math.floor(w / 2) - 4)
89  const xC = Math.max(xB + CRAB_W + 2, w - CRAB_W - 4)
90  const a: StageScene = {}
91  const b: StageScene = {}
92  const c: StageScene = {}
93  const frame = Math.floor(t / 2)
94  if (t < 11) a.crab = { x: Math.round(ease(t, 0, 11, -CRAB_W, xA)), y: 0, frame, look: 1, gem: false }
95  else if (t < 16) a.crab = { x: xA, y: Math.round(ease(t, 11, 16, 0, PIX_ROWS)), frame: 0, look: 0, gem: false }
96  else if (t < 66) a.crab = undefined
97  else if (t < 73) a.crab = { x: xA, y: Math.round(ease(t, 66, 73, PIX_ROWS, 0)), frame: 0, look: 0, gem: t > 70 }
98  else if (t < 80) a.crab = { x: xA, y: 0, frame: 0, look: 0, gem: true }
99  else if (t < 85) a.crab = { x: xA, y: Math.round(ease(t, 80, 85, 0, -PIX_ROWS - 2)), frame: 0, look: 0, gem: true }
100  if (t >= 84 && t < 90) b.crab = { x: xB, y: Math.round(ease(t, 84, 90, -PIX_ROWS - 2, 0)), frame: 0, look: 0, gem: true }
101  else if (t >= 90 && t < 95) b.crab = { x: xB, y: Math.round(ease(t, 90, 95, 0, PIX_ROWS)), frame: 0, look: 0, gem: false }
102  if (t >= 100 && t < 103) c.crab = { x: xC, y: Math.round(ease(t, 100, 103, PIX_ROWS, 4)), frame: 0, look: 0, gem: false }
103  else if (t >= 103 && t < 109) c.crab = { x: xC, y: 4, frame: 0, look: t % 12 < 6 ? -1 : 1, gem: false }
104  else if (t >= 109) c.crab = { x: xC, y: Math.round(ease(t, 109, 112, 4, PIX_ROWS)), frame: 0, look: 0, gem: false }
105  return [a, b, c]
106}
107
108const inside = (t: number, k: number): boolean => (k === 0 ? t >= 11 && t < 80 : k === 1 ? t >= 84 && t < 100 : t >= 100)
109
110type Cell = { ch: string; fg?: string; bg?: string }
111
112function composeStage(scene: StageScene, k: number, t: number, w: number, spec: DigSpec): RenderElement[] {
113  const canvas = blank(w)
114  const xs = [3, 0, 0]
115  const sc = scene.crab
116  const holeX = (sc?.x ?? xs[k] ?? 3) + CRAB_W / 2
117  if (k === 0) {
118    ITEMS.forEach((color, i) => {
119      const dt = t - (18 + i * 6)
120      if (dt < 0 || dt >= 15) return
121      const vy = 1.1 + (i % 3) * 0.15
122      const vx = (((i * 37) % 7) - 3) * 0.45
123      put(canvas, Math.round(3 + CRAB_W / 2 + vx * dt), Math.round(PIX_ROWS - 1 - (vy * dt - 0.08 * dt * dt)), color)
124    })
125  }
126  if (sc) {
127    crab(canvas, sc.x, sc.y, sc.frame, spec.color, spec.dark, sc.look)
128    if (sc.gem) put(canvas, sc.x + CRAB_W, Math.max(0, sc.y - 1), GEM)
129  }
130  const lines: RenderElement[] = []
131  for (let l = 0; l < 3; l++) {
132    const cells: Cell[] = []
133    for (let x = 0; x < w; x++) {
134      const top = canvas[2 * l]?.[x]
135      const bot = canvas[2 * l + 1]?.[x]
136      if (top && bot) cells.push(top === bot ? { ch: '█', fg: top } : { ch: '▀', fg: top, bg: bot })
137      else if (top) cells.push({ ch: '▀', fg: top, bg: spec.stage })
138      else if (bot) cells.push({ ch: '▄', fg: bot, bg: spec.stage })
139      else cells.push({ ch: ' ', bg: spec.stage })
140    }
141    lines.push(runs(cells))
142  }
143  // The ground, with the hole he goes into.
144  const floor: Cell[] = []
145  const hx = Math.round(holeX)
146  for (let x = 0; x < w; x++) {
147    const d = Math.abs(x - hx)
148    floor.push(d <= 4 ? { ch: d >= 4 ? '▂' : '▄', fg: FLOOR, bg: spec.stage } : { ch: ' ', bg: spec.stage })
149  }
150  lines.push(runs(floor))
151  return lines
152}
153
154function runs(cells: Cell[]): RenderElement {
155  const out: RenderElement[] = []
156  let cur: { ch: string; fg?: string; bg?: string } | null = null
157  let text = ''
158  const flush = () => {
159    if (cur) out.push(<Text color={cur.fg} backgroundColor={cur.bg}>{text}</Text>)
160  }
161  for (const c of cells) {
162    if (cur && c.fg === cur.fg && c.bg === cur.bg) {
163      text += c.ch
164    } else {
165      flush()
166      cur = c
167      text = c.ch
168    }
169  }
170  flush()
171  return <Box flexDirection="row" height={1}>{out}</Box>
172}
173
174// `Box`/`Text` come from the surface; set once per render so the helpers above can use them.
175// eslint-disable-next-line @typescript-eslint/no-explicit-any
176let Box: any
177// eslint-disable-next-line @typescript-eslint/no-explicit-any
178let Text: any
179
180function shimmer(i: number, phase: number, len: number, palette: string[]): string {
181  const band = ((phase * 1.6) % (len + 8)) - 4
182  const d = Math.abs(i - band)
183  return palette[d < 0.8 ? 3 : d < 1.8 ? 2 : d < 2.8 ? 1 : 0] ?? palette[0] ?? '#f97316'
184}
185
186const Rows: ClientModule<RowsProps, Local> = (props, surface) => {
187  const els = surface.elements
188  Box = els.Box
189  Text = els.Text
190  let state = surface.state
191  if (state === undefined) {
192    state = { hover: -1, phase: 0, drag: false, ref: {} }
193    surface.setState(state)
194  }
195  const H = props.tall ? 2 : 1
196  // Where every row (and burrow) sits, in cells, so pointer positions find their row.
197  const stageAfter = new Map<number, number>()
198  props.dig?.rows.forEach((r, k) => {
199    if (r >= 0 && !stageAfter.has(r)) stageAfter.set(r, k)
200  })
201  const layout: { top: number; h: number; row: number }[] = []
202  let y = 0
203  for (let i = 0; i < props.rows.length; i++) {
204    layout.push({ top: y, h: H, row: i })
205    y += H
206    if (stageAfter.has(i)) {
207      layout.push({ top: y, h: STAGE_LINES, row: -1 })
208      y += STAGE_LINES
209    }
210  }
211  const total = y
212  const rowAtY = (yy: number): number => layout.find(l => yy >= l.top && yy < l.top + l.h)?.row ?? -1
213
214  let visible = props.rows.length
215  const lit = props.rows.some(r => r.left.some(s => s.sh || s.spin) || r.right.some(s => s.sh || s.spin))
216  const animated = lit || Boolean(props.dig)
217  if (animated && !state.ref.stop) {
218    state.ref.stop = surface.every(TICK_MS, () => {
219      const cur = surface.state
220      if (cur) surface.setState({ ...cur, phase: cur.phase + 1 })
221    })
222  } else if (!animated && state.ref.stop) {
223    state.ref.stop()
224    state.ref.stop = undefined
225  }
226  if (props.pointer === false) {
227    state.ref.unpoint?.()
228    state.ref.unpoint = undefined
229  } else state.ref.unpoint = surface.onPointer(e => {
230    const cur = surface.state ?? state
231    const span = Math.max(1, total - 1)
232    const onBar = Boolean(props.bar) && e.x >= surface.columns - 1
233    if (props.bar && (cur.drag || (onBar && e.type === 'down' && (e.button ?? 'left') === 'left'))) {
234      if (e.type === 'up' || e.type === 'leave') {
235        surface.setState({ ...cur, drag: false })
236        return
237      }
238      if (e.type === 'down' || e.type === 'move') {
239        if (!cur.drag) surface.setState({ ...cur, drag: true, hover: -1 })
240        surface.post({ scrollTo: Math.max(0, Math.min(1, e.y / span)) })
241        return
242      }
243    }
244    const at = e.type === 'leave' || e.y < 0 ? -1 : rowAtY(e.y)
245    if (at < 0 || at >= visible) {
246      if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
247      return
248    }
249    if (e.type === 'move' && !e.button && cur.hover !== at) surface.setState({ ...cur, hover: at })
250    const row = props.rows[at]
251    if (e.type === 'down' && e.button === 'right' && row?.id) {
252      surface.post({ copy: row.id, shift: Boolean(e.shift) })
253      return
254    }
255    if (e.type !== 'down' || (e.button ?? 'left') !== 'left' || !row) return
256    let x = 0
257    for (const seg of row.left) {
258      const w = [...seg.t].length
259      if (seg.tab && e.x >= x && e.x < x + w) {
260        surface.post({ tab: seg.tab })
261        return
262      }
263      x += w
264    }
265    if (row.id) surface.post({ press: row.id, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) })
266  })
267  surface.onKey(e => surface.post({ key: e.key, ctrl: Boolean(e.ctrl), shift: Boolean(e.shift) }))
268  // The preview draws in this same element, so the focus a click gave the tree stays put.
269  if (props.preview) {
270    const { Markdown, Code } = surface.elements
271    const p = props.preview
272    return p.markdown ? <Markdown text={p.text} /> : <Code source={p.text} path={p.path} startLine={p.startLine} wrap="truncate-end" />
273  }
274  const frames = props.spinner?.length ? props.spinner : FRAMES
275  const draw = (s: Seg) => {
276    if (s.spin) {
277      return (
278        <Text color={s.c} bold={s.b}>
279          {s.t + (frames[state.phase % frames.length] ?? '')}
280        </Text>
281      )
282    }
283    const palette = s.sh ? (s.dim ? props.tones[s.sh]?.dim : props.tones[s.sh]?.bright) : undefined
284    if (!palette) {
285      return (
286        <Text color={s.c} backgroundColor={s.bg} bold={s.b} strikethrough={s.s} italic={s.i}>
287          {s.t}
288        </Text>
289      )
290    }
291    if (s.one) {
292      return (
293        <Text color={shimmer(-1, state.phase, s.t.length, palette)}>
294          {s.t}
295        </Text>
296      )
297    }
298    const chars = [...s.t]
299    return (
300      <Text bold={s.b}>
301        {chars.map((ch, i) => (
302          <Text color={shimmer(i, state.phase, chars.length, palette)}>{ch}</Text>
303        ))}
304      </Text>
305    )
306  }
307  const t = ((props.dig?.start ?? 0) + state.phase) % LOOP
308  const dig = props.dig
309  const inRow = (i: number): boolean => Boolean(dig) && dig!.rows.some((r, k) => r === i && inside(t, k))
310  const rowEl = (r: RowSpec, i: number) => {
311    const onThumb = props.bar && i >= props.bar.pos && i < props.bar.pos + props.bar.size
312    return (
313      <Box
314        flexDirection="row"
315        height={H}
316        alignItems={props.tall ? 'center' : undefined}
317        overflow="hidden"
318        backgroundColor={r.id && r.id === props.active ? props.activeBg : inRow(i) ? dig?.glow : r.id && i === state.hover ? props.hoverBg : undefined}
319      >
320        <Box flexShrink={1} overflow="hidden">
321          {r.left.map(draw)}
322        </Box>
323        <Box flexGrow={1} />
324        {r.right.map(draw)}
325        {props.bar && (
326          <Box flexDirection="column">
327            {Array.from({ length: H }, () => (
328              <Text color={onThumb ? props.bar?.thumb : props.bar?.track}>{onThumb ? '┃' : '│'}</Text>
329            ))}
330          </Box>
331        )}
332      </Box>
333    )
334  }
335  const drawn: RenderElement[] = []
336  const w = Math.max(24, surface.columns || 40)
337  const scenes = dig ? script(t, w) : undefined
338  let size = 0
339  for (let i = 0; i < props.rows.length; i++) {
340    const el = rowEl(props.rows[i] as RowSpec, i)
341    let cost = 1_000
342    try {
343      cost = JSON.stringify(el).length
344    } catch {}
345    if (size + cost > TREE_BUDGET || drawn.length >= MAX_ROWS) {
346      visible = i
347      drawn.push(<Text dimColor>{`  … ${props.rows.length - i} more rows, collapse folders or use search`}</Text>)
348      break
349    }
350    size += cost
351    drawn.push(el)
352    const k = stageAfter.get(i)
353    if (dig && scenes && k !== undefined) {
354      drawn.push(<Box flexDirection="column" height={STAGE_LINES} overflow="hidden">{composeStage(scenes[k] ?? {}, k, t, w, dig)}</Box>)
355    }
356  }
357  return <Box flexDirection="column">{drawn}</Box>
358}
359
360export default Rows
361
hooks/ft-icons.ts 132 lines
1import type { FileNode } from '../types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5export const CHEVRON_OPEN = cp(0xf47c)
6export const CHEVRON_CLOSED = cp(0xf460)
7
8const NAMES: Record<string, number> = {
9  'agents.md': 0xf0354,
10  'changelog.md': 0xf0354,
11  'contributing.md': 0xf0354,
12  'todo.md': 0xf0354,
13  'readme.md': 0xf0354,
14  license: 0xe60a,
15  dockerfile: 0xf0868,
16  makefile: 0xf1064,
17  'cmakelists.txt': 0xf1064,
18  justfile: 0xf05b7,
19  gemfile: 0xf0d2d,
20  rakefile: 0xf0d2d,
21  'cargo.toml': 0xe6b2,
22  'cargo.lock': 0xe6b2,
23  'pyproject.toml': 0xe6b2,
24  'requirements.txt': 0xf160e,
25  'go.mod': 0xf0afa,
26  'go.sum': 0xf07d3,
27  'package.json': 0xf0626,
28  'package-lock.json': 0xf0626,
29  'tsconfig.json': 0xf0626,
30  '.prettierrc': 0xf0626,
31  '.eslintrc': 0xf0626,
32  'pnpm-lock.yaml': 0xe6a8,
33  'yarn.lock': 0xe6a8,
34  '.gitignore': 0xf02a2,
35  '.gitmodules': 0xf0493,
36  '.editorconfig': 0xe652,
37}
38
39const EXTS: Record<string, number> = {
40  lua: 0xe620,
41  py: 0xe606,
42  pyi: 0xe606,
43  js: 0xe60c,
44  mjs: 0xe60c,
45  cjs: 0xe60c,
46  ts: 0xe628,
47  jsx: 0xe625,
48  tsx: 0xe7ba,
49  json: 0xe60b,
50  jsonc: 0xe60b,
51  yaml: 0xe6a8,
52  yml: 0xe6a8,
53  sh: 0xe795,
54  bash: 0xe795,
55  zsh: 0xe795,
56  fish: 0xe795,
57  md: 0xf0354,
58  markdown: 0xf0354,
59  css: 0xe6b8,
60  scss: 0xf031c,
61  html: 0xe736,
62  htm: 0xe736,
63  go: 0xe627,
64  rs: 0xe68b,
65  rb: 0xe791,
66  php: 0xe608,
67  java: 0xe738,
68  cs: 0xf031b,
69  sql: 0xe706,
70  graphql: 0xf20e,
71  gql: 0xf20e,
72  xml: 0xf05c0,
73  toml: 0xe6b2,
74  ini: 0xf0bc2,
75  conf: 0xf0493,
76  pdf: 0xeaeb,
77  svg: 0xf0721,
78  jpg: 0xf0225,
79  jpeg: 0xf0225,
80  png: 0xe60d,
81  gif: 0xf0d78,
82  webp: 0xf021f,
83  mp4: 0xf022b,
84  mkv: 0xf022b,
85  mov: 0xf022b,
86  mp3: 0xf0223,
87  wav: 0xf0223,
88  flac: 0xf0223,
89  zip: 0xf05c4,
90  gz: 0xf05c4,
91  xz: 0xf05c4,
92  tar: 0xf05c4,
93  qml: 0xf375,
94  cpp: 0xe61d,
95  cc: 0xe61d,
96  cxx: 0xe61d,
97  c: 0xe61e,
98  h: 0xf0af5,
99  hpp: 0xf0af5,
100  vue: 0xe6a0,
101  svelte: 0xe697,
102  csv: 0xe64a,
103  txt: 0xf09aa,
104}
105
106export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
107  if (n.kind === 'link') return cp(0xf481)
108  if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
109  const lower = n.name.toLowerCase()
110  const named = NAMES[lower]
111  if (named) return cp(named)
112  const dot = lower.lastIndexOf('.')
113  const ext = dot >= 0 ? lower.slice(dot + 1) : ''
114  return cp(EXTS[ext] ?? 0xf0214)
115}
116
117export const GIT_COLOR: Record<string, string> = {
118  A: '#98c379',
119  '?': '#98c379',
120  R: '#61afef',
121  C: '#61afef',
122  M: '#e5c07b',
123  T: '#e5c07b',
124}
125
126const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
127
128export function stronger(a: string | undefined, b: string): string {
129  if (!a) return b
130  return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
131}
132
hooks/ft-tree.ts 346 lines
1import type { Branch, FileNode, FileTree, Theme } from '../types'
2import { stronger } from './ft-icons'
3
4export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; mtimeMs: number; size: number; isLink: boolean }
5export type Row = { node: FileNode; depth: number; open: boolean }
6
7const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8
9export const DEFAULT_THEME: Theme = {
10  fg: '',
11  accent: '#D97757',
12  muted: '#8c8a84',
13  urgent: '#D0453F',
14  selection: '#8a5a47',
15  bg: '',
16}
17
18export function emptyTree(root: string): FileTree {
19  return {
20    root,
21    nodes: [],
22    expanded: [],
23    cursor: '',
24    selected: '',
25    query: '',
26    showHidden: true,
27    showSize: false,
28    dirSizes: {},
29    git: {},
30    diff: {},
31    ignored: [],
32    untrackedDirs: [],
33    top: '',
34    prefix: '',
35    branch: null,
36    counts: {},
37    flash: [],
38    flashDim: [],
39    flashOn: false,
40    flashTones: {},
41    scroll: null,
42    preview: '',
43    previewTop: 0,
44  }
45}
46
47export function parseTheme(toml: string): Theme {
48  const get = (k: string) => toml.match(new RegExp(`^${k}\\s*=\\s*"(#[0-9a-fA-F]{6})"`, 'm'))?.[1]
49  return {
50    fg: get('foreground') ?? get('color7') ?? DEFAULT_THEME.fg,
51    accent: get('accent') ?? get('color4') ?? DEFAULT_THEME.accent,
52    muted: get('muted') ?? get('color8') ?? DEFAULT_THEME.muted,
53    urgent: get('red') ?? get('color1') ?? DEFAULT_THEME.urgent,
54    selection: get('selection') ?? DEFAULT_THEME.selection,
55    bg: get('dark_background') ?? get('background') ?? DEFAULT_THEME.bg,
56  }
57}
58
59export function join(dir: string, name: string): string {
60  return dir.endsWith('/') ? dir + name : `${dir}/${name}`
61}
62
63let drives = false
64
65export function dirname(path: string): string {
66  if (drives && /^[A-Za-z]:\/[^/]*$/.test(path)) return path.slice(0, 3)
67  const i = path.lastIndexOf('/')
68  return i <= 0 ? '/' : path.slice(0, i)
69}
70
71export function useDrives(on: boolean): void {
72  drives = on
73}
74
75export function drivesOn(): boolean {
76  return drives
77}
78
79export function posix(path: string): string {
80  if (!drives) return path
81  return path.replace(/\\/g, '/').replace(/^\/([A-Za-z])(\/|$)/, (_, d: string) => `${d.toUpperCase()}:/`)
82}
83
84export function dropBelow(nodes: FileNode[], dirs: string[]): FileNode[] {
85  if (dirs.length === 0) return nodes
86  const under = (id: string) => dirs.some(d => id !== d && inside(d, id))
87  const gone = new Set(dirs)
88  return nodes.filter(n => !under(n.id)).map(n => (gone.has(n.id) && n.loaded ? { ...n, loaded: false } : n))
89}
90
91export function isAbsolute(path: string): boolean {
92  return path.startsWith('/') || (drives && /^[A-Za-z]:\//.test(path))
93}
94
95export function inside(root: string, path: string): boolean {
96  return path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`)
97}
98
99export function toNodes(dir: string, list: Entry[]): FileNode[] {
100  return list
101    .map(e => ({
102      id: join(dir, e.name),
103      parent: dir,
104      name: e.name,
105      kind: e.isLink ? ('link' as const) : e.kind === 'dir' ? ('dir' as const) : ('file' as const),
106      hidden: e.name.startsWith('.'),
107      mtime: e.mtimeMs,
108      size: e.size,
109      loaded: false,
110    }))
111    .sort((a, b) => (a.kind === 'dir' ? 0 : 1) - (b.kind === 'dir' ? 0 : 1) || collator.compare(a.name, b.name))
112}
113
114export function replaceChildren(nodes: FileNode[], listed: Map<string, FileNode[]>): FileNode[] {
115  if (listed.size === 0) return nodes
116  const keep = new Map<string, FileNode>()
117  for (const n of nodes) if (listed.has(n.parent)) keep.set(n.id, n)
118  const freshIds = new Set<string>()
119  for (const kids of listed.values()) for (const k of kids) freshIds.add(k.id)
120  const gone = new Set([...keep.keys()].filter(id => !freshIds.has(id)))
121  const under = (id: string) => {
122    if (gone.size === 0) return false
123    for (let dir = dirname(id), prev = id; dir !== prev; prev = dir, dir = dirname(dir)) if (gone.has(dir)) return true
124    return false
125  }
126  const fresh: FileNode[] = []
127  for (const kids of listed.values()) {
128    for (const k of kids) {
129      if (under(k.id)) continue
130      const old = keep.get(k.id)
131      fresh.push(old && old.kind === 'dir' && k.kind === 'dir' ? { ...k, loaded: old.loaded } : k)
132    }
133  }
134  const out = nodes.filter(n => !listed.has(n.parent) && !under(n.id))
135  return [...out.map(n => (listed.has(n.id) ? { ...n, loaded: true } : n)), ...fresh]
136}
137
138export type GitStatus = {
139  git: Record<string, string>
140  ignored: string[]
141  untrackedDirs: string[]
142  branch: Branch | null
143  files: Record<string, Change>
144}
145
146export type Change = 'new' | 'mod' | 'del'
147
148export function parseBranch(record: string): Branch {
149  const body = record.replace(/^## /, '')
150  if (body.startsWith('HEAD (no branch)')) return { head: 'detached', upstream: '', ahead: 0, behind: 0 }
151  const head = body.match(/^(?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.|\s|$)/)?.[1] ?? body
152  const upstream = body.match(/\.\.\.(\S+)/)?.[1] ?? ''
153  const ahead = Number(body.match(/ahead (\d+)/)?.[1] ?? 0)
154  const behind = Number(body.match(/behind (\d+)/)?.[1] ?? 0)
155  return { head, upstream, ahead, behind }
156}
157
158export function mapper(root: string, prefix: string): (rel: string) => string | null {
159  return rel => (rel.startsWith(prefix) ? join(root, rel.slice(prefix.length).replace(/\/$/, '')).replace(/\/$/, '') : null)
160}
161
162export function parseGit(stdout: string, root: string, prefix: string): GitStatus {
163  const map = mapper(root, prefix)
164  const git: Record<string, string> = {}
165  const ignored: string[] = []
166  const untrackedDirs: string[] = []
167  const files: Record<string, Change> = {}
168  let branch: Branch | null = null
169  const parts = stdout.split('\0')
170  for (let i = 0; i < parts.length; i++) {
171    const rec = parts[i] ?? ''
172    if (rec.startsWith('## ')) {
173      branch = parseBranch(rec)
174      continue
175    }
176    if (rec.length < 4) continue
177    const xy = rec.slice(0, 2)
178    const raw = rec.slice(3)
179    if (xy[0] === 'R' || xy[0] === 'C') i++
180    const path = map(raw)
181    if (!path) continue
182    if (xy === '!!') {
183      ignored.push(path)
184      continue
185    }
186    if (xy === '??' && raw.endsWith('/')) untrackedDirs.push(path)
187    else files[path] = xy === '??' || (xy.includes('A') && !xy.includes('D')) ? 'new' : xy.includes('D') && !/U/.test(xy) ? 'del' : 'mod'
188    const letter =
189      xy === '??'
190        ? '?'
191        : /U/.test(xy) || xy === 'AA' || xy === 'DD'
192          ? 'U'
193          : (['D', 'M', 'R', 'C', 'A', 'T'].find(c => xy.includes(c)) ?? '')
194    if (!letter) continue
195    git[path] = stronger(git[path], letter)
196    let dir = dirname(path)
197    while (dir.length >= root.length && dir !== '/') {
198      git[dir] = stronger(git[dir], letter)
199      if (dir === root) break
200      dir = dirname(dir)
201    }
202  }
203  return { git, ignored, untrackedDirs, branch, files }
204}
205
206export function parseNumstat(stdout: string, root: string, prefix: string, into: Record<string, [number, number]>): void {
207  const map = mapper(root, prefix)
208  const parts = stdout.split('\0')
209  for (let i = 0; i < parts.length; i++) {
210    const rec = parts[i] ?? ''
211    if (!rec) continue
212    const m = rec.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s)
213    if (!m) continue
214    let path = m[3] ?? ''
215    if (path === '') {
216      i += 2
217      path = parts[i] ?? ''
218    }
219    if (!path) continue
220    const add = m[1] === '-' ? 0 : Number(m[1])
221    const del = m[2] === '-' ? 0 : Number(m[2])
222    const abs = map(path)
223    if (!abs) continue
224    const prev = into[abs] ?? [0, 0]
225    into[abs] = [prev[0] + add, prev[1] + del]
226  }
227}
228
229export function rollUp(diff: Record<string, [number, number]>, root: string): Record<string, [number, number]> {
230  const out: Record<string, [number, number]> = { ...diff }
231  for (const [path, [add, del]] of Object.entries(diff)) {
232    let dir = dirname(path)
233    while (dir.length >= root.length && dir !== '/') {
234      const prev = out[dir] ?? [0, 0]
235      out[dir] = [prev[0] + add, prev[1] + del]
236      if (dir === root) break
237      dir = dirname(dir)
238    }
239  }
240  return out
241}
242
243export function rollCounts(files: Record<string, Change>, root: string): Record<string, [number, number, number]> {
244  const out: Record<string, [number, number, number]> = {}
245  const slot = { new: 0, mod: 1, del: 2 } as const
246  for (const [path, change] of Object.entries(files)) {
247    let dir = dirname(path)
248    while (dir.length >= root.length && dir !== '/') {
249      const cur = out[dir] ?? [0, 0, 0]
250      cur[slot[change]] += 1
251      out[dir] = cur
252      if (dir === root) break
253      dir = dirname(dir)
254    }
255  }
256  return out
257}
258
259export function underAny(id: string, set: Set<string>, root: string): boolean {
260  if (set.size === 0) return false
261  let cur = id
262  while (cur.length >= root.length) {
263    if (set.has(cur)) return true
264    const up = dirname(cur)
265    if (up === cur) break
266    cur = up
267  }
268  return false
269}
270
271export function ancestorsOf(id: string, root: string): string[] {
272  const out: string[] = []
273  if (!root) return out
274  for (let dir = dirname(id), prev = id; dir !== prev && dir !== root && inside(root, dir); prev = dir, dir = dirname(dir)) out.push(dir)
275  return out
276}
277
278export function relative(root: string, path: string): string {
279  return path === root ? '' : path.slice(root.endsWith('/') ? root.length : root.length + 1)
280}
281
282export function visibleRows(t: FileTree): Row[] {
283  const kids = new Map<string, FileNode[]>()
284  for (const n of t.nodes) {
285    if (!t.showHidden && n.hidden) continue
286    const list = kids.get(n.parent)
287    if (list) list.push(n)
288    else kids.set(n.parent, [n])
289  }
290  const q = t.query.trim().toLowerCase()
291  let keep: Set<string> | null = null
292  if (q) {
293    keep = new Set()
294    for (const n of t.nodes) {
295      if (!relative(t.root, n.id).toLowerCase().includes(q)) continue
296      keep.add(n.id)
297      for (const a of ancestorsOf(n.id, t.root)) keep.add(a)
298    }
299  }
300  const open = new Set(t.expanded)
301  const rows: Row[] = []
302  const walk = (dir: string, depth: number) => {
303    for (const n of kids.get(dir) ?? []) {
304      if (keep && !keep.has(n.id)) continue
305      const isOpen = n.kind === 'dir' && (keep ? kids.has(n.id) : open.has(n.id))
306      rows.push({ node: n, depth, open: isOpen })
307      if (isOpen) walk(n.id, depth + 1)
308    }
309  }
310  walk(t.root, 0)
311  return rows
312}
313
314export function stamp(ms: number): string {
315  if (!ms) return ''
316  const d = new Date(ms)
317  const p = (v: number) => String(v).padStart(2, '0')
318  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
319}
320
321const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
322
323export function age(ms: number, now: number): string {
324  if (!ms) return ''
325  const s = (now - ms) / 1000
326  if (s < 60 && s > -60) return 'now'
327  if (s > 0 && s < 3600) return `${Math.floor(s / 60)}m`
328  if (s > 0 && s < 86_400) return `${Math.floor(s / 3600)}h`
329  if (s > 0 && s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`
330  const d = new Date(ms)
331  return Math.abs(s) < 365 * 86_400 ? `${MONTHS[d.getMonth()]} ${d.getDate()}` : String(d.getFullYear())
332}
333
334export function formatSize(bytes: number): string {
335  if (bytes < 0) return '?'
336  if (bytes < 1024) return `${bytes} B`
337  const units = ['K', 'M', 'G', 'T']
338  let v = bytes / 1024
339  let i = 0
340  while (v >= 1024 && i < units.length - 1) {
341    v /= 1024
342    i++
343  }
344  return `${v < 100 ? v.toFixed(1) : Math.round(v)} ${units[i]}`
345}
346