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

<img src="docs/logo.svg" alt="crab-progress logo" width="128">
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.
<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.
progress tool or TodoWrite to set exact done/total instead./agents-info)/filetree)/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.
You need Claude Code with plugin support (built and tested on 2.1.295) and git. The steps are the same on every system.
/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
# 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
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
claude plugin uninstall crab-progress@crab-progress
| Command | What it does |
|---|---|
/mascot <name> | Switch character: claude, frog, creeper, slime, robot, cat. /mascot list shows them. |
/frog /creeper /slime /robot /cat | Shortcuts for /mascot <name>. |
/agents-info | Show or hide the subagents panel. |
/filetree [path] | Show the file tree (no path: the current directory). |
The file tree keeps its upstream options, set in Claude Code's plugin settings:
| Option | Values | Default | Does |
|---|---|---|---|
activity | reads and writes, writes, reads, none | reads and writes | Which file activity shimmers in the tree |
glyphs | auto, nerd, plain | auto | Icon set (Nerd Font icons need a Nerd Font) |
follow | on, off | on | Tree scrolls to what Claude touches |
start | on, off | on | Open the pane when a session starts |
column | date, age, size | date | Right-hand column |
hints | on, off | on | "git status starts after git init" line |
open | text | empty | What double-click/Enter runs (preview shows it in the pane) |
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.
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 .
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 mod | Author | Link |
|---|---|---|
| filetree: file tree pane with git status and Claude activity | Kurt Buhler / data-goblin | https://github.com/data-goblin/claude-code-filetree |
| savvy-progress: progress bar and subagents panel | johnnyvizz | https://github.com/JohnnyVizz/claude-kit (plugins/savvy-progress) |
Reference art and ideas:
Issues and pull requests are welcome. See CONTRIBUTING.md.
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.
hooks/register.tsx 609 lines1import { 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}
609hooks/dig.ts 90 lines1import 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
90hooks/ft-register.tsx 1642 lines1import { 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 lines1import 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}
658hooks/classify.ts 129 lines1import 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 lines1import 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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})
272hooks/ft-open.ts 68 lines1export const LINUX_LAUNCH = 'setsid -f -w "$@" </dev/null >/dev/null 2>&1 & p=$!; sleep 1; kill -0 "$p" 2>/dev/null && exit 0; wait "$p"'
2
3export const LINUX_OPEN = [
4 'set -f',
5 'f=$1',
6 'if command -v gio >/dev/null; then',
7 ' if [ -f "$f" ] && command -v file >/dev/null && encoding=$(file -b --mime-encoding -- "$f") && [ -n "$encoding" ] && [ "$encoding" != binary ]; then',
8 ' case $(gio info -a standard::content-type -- "$f" | sed -n \'s/.*standard::content-type: //p\') in',
9 ' video/* | audio/*)',
10 ' app=$(xdg-mime query default text/plain)',
11 ' IFS=:',
12 ' for dir in "${XDG_DATA_HOME:-$HOME/.local/share}" ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do',
13 ' [ -n "$app" ] && [ -f "$dir/applications/$app" ] && exec gio launch "$dir/applications/$app" "$f"',
14 ' done',
15 ' echo "No text/plain desktop application found for $f" >&2',
16 ' exit 1',
17 ' ;;',
18 ' esac',
19 ' fi',
20 ' if (exec gio open "$f"); then exit 0; fi',
21 'fi',
22 'exec xdg-open "$f"',
23].join('\n')
24
25const WIN_OPEN = "$ErrorActionPreference = 'Stop'; [Diagnostics.Process]::Start([Diagnostics.ProcessStartInfo]@{ FileName = $env:PANE_OPEN_TARGET; UseShellExecute = $true }) | Out-Null"
26
27export function openCommand(os: 'linux' | 'darwin' | 'win32', target: string) {
28 const argv = os === 'linux'
29 ? ['sh', '-c', LINUX_LAUNCH, 'sh', 'sh', '-c', LINUX_OPEN, 'sh', target]
30 : os === 'darwin'
31 ? ['open', '--', target]
32 : ['powershell', '-NoProfile', '-NonInteractive', '-Command', WIN_OPEN]
33 return {
34 argv,
35 init: {
36 timeoutMs: 10_000,
37 ...(os === 'win32' ? { env: { PANE_OPEN_TARGET: /^[a-z][a-z0-9+.-]*:\/\//i.test(target) ? target : target.replace(/\//g, '\\') } } : {}),
38 },
39 }
40}
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}
68hooks/ft-button.tsx 19 lines1import 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
19hooks/ft-git.ts 262 lines1import { 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}
262hooks/ft-rows.tsx 361 lines1import 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
361hooks/ft-icons.ts 132 lines1import type { FileNode } from '../types'
2
3const cp = (n: number) => String.fromCodePoint(n)
4
5export const CHEVRON_OPEN = cp(0xf47c)
6export const CHEVRON_CLOSED = cp(0xf460)
7
8const NAMES: Record<string, number> = {
9 'agents.md': 0xf0354,
10 'changelog.md': 0xf0354,
11 'contributing.md': 0xf0354,
12 'todo.md': 0xf0354,
13 'readme.md': 0xf0354,
14 license: 0xe60a,
15 dockerfile: 0xf0868,
16 makefile: 0xf1064,
17 'cmakelists.txt': 0xf1064,
18 justfile: 0xf05b7,
19 gemfile: 0xf0d2d,
20 rakefile: 0xf0d2d,
21 'cargo.toml': 0xe6b2,
22 'cargo.lock': 0xe6b2,
23 'pyproject.toml': 0xe6b2,
24 'requirements.txt': 0xf160e,
25 'go.mod': 0xf0afa,
26 'go.sum': 0xf07d3,
27 'package.json': 0xf0626,
28 'package-lock.json': 0xf0626,
29 'tsconfig.json': 0xf0626,
30 '.prettierrc': 0xf0626,
31 '.eslintrc': 0xf0626,
32 'pnpm-lock.yaml': 0xe6a8,
33 'yarn.lock': 0xe6a8,
34 '.gitignore': 0xf02a2,
35 '.gitmodules': 0xf0493,
36 '.editorconfig': 0xe652,
37}
38
39const EXTS: Record<string, number> = {
40 lua: 0xe620,
41 py: 0xe606,
42 pyi: 0xe606,
43 js: 0xe60c,
44 mjs: 0xe60c,
45 cjs: 0xe60c,
46 ts: 0xe628,
47 jsx: 0xe625,
48 tsx: 0xe7ba,
49 json: 0xe60b,
50 jsonc: 0xe60b,
51 yaml: 0xe6a8,
52 yml: 0xe6a8,
53 sh: 0xe795,
54 bash: 0xe795,
55 zsh: 0xe795,
56 fish: 0xe795,
57 md: 0xf0354,
58 markdown: 0xf0354,
59 css: 0xe6b8,
60 scss: 0xf031c,
61 html: 0xe736,
62 htm: 0xe736,
63 go: 0xe627,
64 rs: 0xe68b,
65 rb: 0xe791,
66 php: 0xe608,
67 java: 0xe738,
68 cs: 0xf031b,
69 sql: 0xe706,
70 graphql: 0xf20e,
71 gql: 0xf20e,
72 xml: 0xf05c0,
73 toml: 0xe6b2,
74 ini: 0xf0bc2,
75 conf: 0xf0493,
76 pdf: 0xeaeb,
77 svg: 0xf0721,
78 jpg: 0xf0225,
79 jpeg: 0xf0225,
80 png: 0xe60d,
81 gif: 0xf0d78,
82 webp: 0xf021f,
83 mp4: 0xf022b,
84 mkv: 0xf022b,
85 mov: 0xf022b,
86 mp3: 0xf0223,
87 wav: 0xf0223,
88 flac: 0xf0223,
89 zip: 0xf05c4,
90 gz: 0xf05c4,
91 xz: 0xf05c4,
92 tar: 0xf05c4,
93 qml: 0xf375,
94 cpp: 0xe61d,
95 cc: 0xe61d,
96 cxx: 0xe61d,
97 c: 0xe61e,
98 h: 0xf0af5,
99 hpp: 0xf0af5,
100 vue: 0xe6a0,
101 svelte: 0xe697,
102 csv: 0xe64a,
103 txt: 0xf09aa,
104}
105
106export function fileIcon(n: FileNode, open: boolean, isRepo: boolean): string {
107 if (n.kind === 'link') return cp(0xf481)
108 if (n.kind === 'dir') return cp(isRepo ? 0xf02a2 : open ? 0xe5fe : 0xe5ff)
109 const lower = n.name.toLowerCase()
110 const named = NAMES[lower]
111 if (named) return cp(named)
112 const dot = lower.lastIndexOf('.')
113 const ext = dot >= 0 ? lower.slice(dot + 1) : ''
114 return cp(EXTS[ext] ?? 0xf0214)
115}
116
117export const GIT_COLOR: Record<string, string> = {
118 A: '#98c379',
119 '?': '#98c379',
120 R: '#61afef',
121 C: '#61afef',
122 M: '#e5c07b',
123 T: '#e5c07b',
124}
125
126const PRIORITY = ['U', 'D', 'M', 'T', 'R', 'C', 'A', '?']
127
128export function stronger(a: string | undefined, b: string): string {
129 if (!a) return b
130 return PRIORITY.indexOf(b) < PRIORITY.indexOf(a) ? b : a
131}
132hooks/ft-tree.ts 346 lines1import 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