A session lens pane (/lens): Claude's to-do list, its crew of subagents as animated crabs with a progress band above the prompt, and the skills and tools this…

A pane that shows what this session is doing and has used: Claude's to-do list, its crew of subagents as animated crabs racing on a beach, and the skills and tools it used. A crew band above the prompt follows the subagents while they work. Open it with /lens; in the desktop app it opens by itself the first time a session uses a tool or a skill.
It works with the standard agents and tools and with any MCP server or connector: no skills, prompts or tool calls are needed from the model, as the mod watches the session itself.

The pane has four sections, each folding on its own with its arrow or title:
The counts are for this session only: only tools and skills that were actually used appear. Calls made by subagents count too. The to-do list follows the main conversation only. Your layout (which sections and groups are open, grouped or flat, the sort) is kept for your next session.
In the desktop app the rows are animated drawings, in light and dark. Each animation plays once, when its row changes, and the rest of the pane stays still. The terminal draws the same sections in text.

TodoWrite, TaskCreate / TaskUpdate and the desktop app's own task list.TodoWrite, TaskCreate and TaskUpdate rows in the transcript are hidden, as the pane shows the same list beside it. Fold To-do, close the pane or switch to another tab, and they come back. A row that failed stays, and the ctrl+o transcript shows them all. Turn this off with the hidePlanRows setting./name with a use count. Skills a memory file pulls in at start (a CLAUDE.md import) are tagged always loaded. A skill used for the first time flies into the list from the bottom.
Haiku 4.5 · searching loadConfig · 4 calls · 61k tok, then ≈25%.· between each: how many, how many went overboard or were called back, their calls and tokens, and the time. At work · 4 · 37 calls · 715k tok · 2:20.showQuips setting off, the crabs say nothing: no bubbles on the beach, and no last line under the crabs that reported back (their rows are shorter). The rows' details, the walk and the poses stay.Progress comes from the agent's own to-do list when it keeps one (3/5), else an estimate that rises with its calls toward 92% at its character's pace (≈45%): only a finished crab reaches the flag. Tokens are summed over each of the agent's model requests (input, output and cache reads and writes), as crab-crew counted them.
| Agent type | Character | Names | Mark |
|---|---|---|---|
| main thread | Captain | Clawd | captain's hat |
general-purpose, claude | Deckhand | Barnacle, Rusty, Sal, Dory, Mack | bandana |
Explore | Scout | Pip, Wren, Kit, Marlo, Juno | cap |
Plan | Navigator | Ada, Vela, Orion, Mags | tricorn |
claude-code-guide | Professor | Kelp, Sage, Quill, Tomes | glasses |
statusline-setup | Signaller | Sparky, Dot, Morse | antenna |
fork | Understudy | Echo, Mirror, Twin | halo |
| any other type | Cook, Sleuth, Wizard or Diver | dealt by the type's name, so the same type always gets the same one | toque, deerstalker, wizard's hat, goggles |
A second crab of the same character takes the next name; after the last one the names start again with II (Pip II).

| Pose | When | Motion |
|---|---|---|
| work | at work, its last call a few seconds ago | legs step, claws snip, body bobs, eyes blink |
| scurry | a call in the last 3 seconds | the same, at twice the pace |
| think | waiting on the model | still, with a dashed thought bubble and blinking dots |
| wait | no call for 20 seconds | taps a foot; its row says how long it has waited |
| done | finished | walks past its flag, hops with claws up in a burst of sparkles, then rests |
| failed | ended on an error | sees stars, eyes crossed |
| stopped | stopped by you | eyes closed, z z rising |
Waves roll along the top of the beach. A crab's walk is pinned to when its progress moved, so a redraw never replays it. All motion is CSS and stops under prefers-reduced-motion, and the sparkles and sheens are not drawn then. The terminal draws the ASCII crab (\/)(°,,°)(\/) for the captain and a progress bar per crab.

While a crew is out, a crew band sits just above the prompt: its crabs, who is at work, and one bar of the crew's progress, so you can follow it without opening the pane. Crew ×n opens the lens at its crew, and ✕ hides the band until the next crew sets out. A new prompt sent with everyone back clears it. Other mods' rows in that band stay, under the crew's. Turn it off with the crewBand setting.

/mcp lists it by, not its id.The first time, To-do, Crew and Skills are open and Tools is closed, showing only its summary ("8 tools · 56 calls"). Open it to see the tools grouped by server, sorted by Most used, with every server collapsed. A section with nothing in it yet shows only its header.
At the prompt of a Claude Code terminal session:
/plugin install session-lens --marketplace marwansaab/Claude-Mods
Answer y to add the marketplace, then choose a scope.
session-lens replaces crab-crew. It has its own crew of crabs and its own crew band above the prompt, so with both installed you get two crews and two bands. Don't install crab-crew alongside it, and if you already have it, disable it:
claude plugin disable crab-crew@claude-mods
Then run /reload-plugins in any session that is already open: a disabled plugin keeps running there until the plugins are reloaded or a new session starts. Inside a session, /plugin does the same from its Installed tab and reloads when you close it.
| Command | Does |
|---|---|
/lens | Opens the pane, or closes it when it is open |
In the desktop app the pane also opens by itself the first time a session uses a tool or a skill, unless you closed it in your last session (see Settings).
Set them in /config, or for a mod loaded with --plugin-dir, in ~/.claude/settings.json:
{ "pluginConfigs": { "session-lens": { "options": { "autoOpen": true, "crewBand": true, "showQuips": true, "hidePlanRows": true } } } }
| Setting | Default | Effect |
|---|---|---|
crewBand | true | Show the crew band above the prompt while subagents are out. |
showQuips | true | Show the crabs' speech bubbles on the beach and their last lines in the crew lists. Off, the captain's line in the terminal says only whether he is at the helm. |
hidePlanRows | true | Hide the main conversation's TodoWrite, TaskCreate and TaskUpdate rows in the transcript while the pane is open in front with To-do unfolded. |
autoOpen | true | In the desktop app, open the pane the first time a session uses a tool or a skill, unless you closed it (with its close mark or /lens) in your last session. In a terminal, the pane opens only with /lens. |
tool.call hook lets each call run, then counts it unless it was denied. It never changes the call, and a failure inside it is swallowed.TodoWrite, TaskCreate and TaskUpdate, and the desktop app's own task list (update_plan, with advance, done and start). The desktop app's closing wrap_up call ticks every step off.agent.spawn casts a crab by agent type: general-purpose is a Deckhand, Explore a Scout, Plan a Navigator, claude-code-guide a Professor, statusline-setup a Signaller and fork an Understudy. Any other type is always dealt the same one of the Cook, Sleuth, Wizard and Diver. agent.spawn also notes the subagent's model. A subagent's turn.step sets its crab thinking, and once the request is answered adds its tokens (input, output and cache reads and writes, as crab-crew counts them) to the crab and updates its model; a run whose requests were not seen one by one takes its totals from turn.complete. Its tool.calls say what it is doing and feed its to-do progress, and its turn.complete says how the run ended. A crab that is done stays on the beach for 3 s, and a timer then notes the crew's new order so it flies to the list. The main conversation's own turn.step, tool.call and turn.complete do the same for the captain. While a crab works, the pane is redrawn every 5 s so a crab left without a call is drawn waiting. The band above the prompt is a ui.render hook on AbovePrompt: it shows the crabs that set out or came back since the last prompt sent with no one out, and draws what Claude Code and other mods put in the band under its own, as the band is shared; it leaves the band to them when there are none, or when a survey is up. The cast, its lines and the progress estimate are adapted from this repository's crab-crew mod. A crab's walk along its lane is pinned to when its progress moved, as every animation here is.ui.render hooks on ToolUse and ToolResult draw nothing for a plan call's row while the pane is shown ($.ui.panes()) and To-do is unfolded. They hide only the calls the main conversation made, noted by id in tool.call and from a resumed session's transcript, and pass every other row, an errored one and the expanded ctrl+o view to the engine. Opening or closing the pane asks for a redraw so the rows follow.skill.prompt hook counts each skill as it is expanded for the model, whether typed as /name, called through the Skill tool, or preloaded.$.session.usage({ breakdown: 'summary' })). It gives the name /mcp lists for each server, so a claude.ai connector shows its name rather than its id. The same breakdown names the skills loaded through memory files. It is read when the session starts, when /lens opens the pane, and when a tool from a new server is first called./clear starts the counts over and keeps the pane's layout.$.state, so a reload of the mod keeps them. The layout is also written to the mod's $.store on each change and read back when a session starts. So is whether you closed the pane last. A ui.close hook notes a close you make with the pane's close mark.prefers-reduced-motion every drawing shows its final state with no motion, and the sheens and sparkles are not drawn. Each has an alt text, and colours follow the light or dark theme.Connector plus the first characters of its id./name in a resumed session's earlier turns is not counted; skills called through the Skill tool are.hidePlanRows hides the transcript rows of the plan tools only. Switching the pane's tab is no event a mod hears, so after one the rows follow at the session's next redraw.MIT, see the repository's LICENSE.
hooks/register.tsx 521 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { LensActivity, LensCaptain, LensCrab, LensSection, LensSort, LensView } from '../types'
5import { LINGER_MS, activityOf, characterKeyOf, crewNameOf, detailOf, outcomeOf, todoPatch, usageTokens, withChange } from './crew'
6import {
7 DEFAULT_VIEW,
8 EMPTY_PLAN,
9 PLAN_TOOLS,
10 countSkill,
11 countTool,
12 layoutOf,
13 listOrders,
14 markAlways,
15 movesAfter,
16 parseLayout,
17 planAfter,
18 skillOfPath,
19 splitTool,
20 viewOf,
21} from './track'
22import { bandView, paneView } from './view'
23
24const PLUGIN = 'session-lens'
25const PANE = PLUGIN
26const TITLE = 'Session lens'
27const COMMAND = 'lens'
28/** Store keys: the layout kept across sessions, and whether the person closed the pane last. */
29const LAYOUT_KEY = 'layout'
30const CLOSED_KEY = 'paneClosed'
31
32const plan = atom({ plugin: 'session-lens', key: 'plan' } as const, EMPTY_PLAN)
33const tools = atom({ plugin: 'session-lens', key: 'tools' } as const, {})
34const skills = atom({ plugin: 'session-lens', key: 'skills' } as const, {})
35const servers = atom({ plugin: 'session-lens', key: 'servers' } as const, {})
36const view = atom({ plugin: 'session-lens', key: 'view' } as const, DEFAULT_VIEW)
37const isSeeded = atom({ plugin: 'session-lens', key: 'isSeeded' } as const, false)
38const isAutoOpened = atom({ plugin: 'session-lens', key: 'isAutoOpened' } as const, false)
39const isLayoutLoaded = atom({ plugin: 'session-lens', key: 'isLayoutLoaded' } as const, false)
40const moves = atom({ plugin: 'session-lens', key: 'moves' } as const, {})
41const crew = atom({ plugin: 'session-lens', key: 'crew' } as const, {} as Record<string, LensCrab>)
42const crewTick = atom({ plugin: 'session-lens', key: 'crewTick' } as const, 0)
43const crewBandSince = atom({ plugin: 'session-lens', key: 'crewBandSince' } as const, 0)
44const IDLE: LensCaptain = { isBusy: false, activity: 'other', detail: '', lastActiveAt: 0 }
45const captain = atom({ plugin: 'session-lens', key: 'captain' } as const, IDLE)
46const raceSince = atom({ plugin: 'session-lens', key: 'raceSince' } as const, 0)
47const planRows = atom({ plugin: 'session-lens', key: 'planRows' } as const, [] as string[])
48
49/** The plan tools whose rows the transcript draws: the lens's To-do shows the same list. */
50const ROW_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate'])
51const MAX_PLAN_ROWS = 300
52
53/** Whether this call's list is in the lens in front of the person: one of the main conversation's plan calls, with the pane shown and To-do unfolded. */
54async function isPlanInLens($: EngineInterface, tool: string, id: string): Promise<boolean> {
55 if (!ROW_TOOLS.has(tool) || !(await read($, planRows)).includes(id)) return false
56 if (viewOf(await read($, view)).folded.includes('todo')) return false
57 return (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced && p.isShown)
58}
59
60/** Notes a row the lens may hide: one of the main conversation's plan calls. */
61async function notePlanRow($: EngineInterface, tool: string, id: string | undefined): Promise<void> {
62 if (!id || !ROW_TOOLS.has(tool)) return
63 await update($, planRows, cur => (cur.includes(id) ? cur : [...cur, id].slice(-MAX_PLAN_ROWS)))
64}
65
66/** The captain is the main thread: what it is doing now, or that it is waiting for you. */
67async function setCaptain($: EngineInterface, isBusy: boolean, activity: LensActivity, detail: string): Promise<void> {
68 const at = await $.clock.now()
69 await update($, captain, () => ({ isBusy, activity, detail, lastActiveAt: at }))
70}
71
72/** How often the pane is redrawn while a crab works, so one left without a call is drawn waiting. */
73const CREW_TICK_MS = 5000
74const MAX_CREW = 40
75
76/** A little past the longest animation a change sets off: a staggered bar's growth, or a flight and its glow. */
77const SETTLE_MS = 1600
78let settleTimer: Timer | undefined
79
80/**
81 * Asks for one more drawing once the animations a change set off have ended. The app keeps the pane's
82 * last drawing and may show it again (a new message, a re-layout) without asking the mod; were that
83 * drawing made mid-animation, the animation would play again. The drawing made here has none left.
84 */
85function settle($: EngineInterface): void {
86 settleTimer?.cancel()
87 settleTimer = $.clock.after(SETTLE_MS, () => {
88 settleTimer = undefined
89 $.ui.invalidate('ui.render')
90 })
91}
92
93/** The lens only watches: nothing it does may fail the call, the turn or the session. */
94async function safely(work: () => Promise<unknown>): Promise<void> {
95 try {
96 await work()
97 } catch {
98 // Dropped: a missed update costs one count, never the call.
99 }
100}
101
102/**
103 * Notes every list whose order changed since it was last noted, and when: the pane flies those rows to
104 * their new places for a moment after. Called after each change that can reorder a list.
105 */
106async function noteMoves($: EngineInterface): Promise<void> {
107 const at = await $.clock.now()
108 const v = viewOf(await read($, view))
109 const orders = listOrders(await read($, tools), await read($, skills), await read($, servers), v.sort, await read($, crew), at, await read($, raceSince))
110 await update($, moves, cur => movesAfter(cur, orders, at) ?? cur)
111}
112
113/** Changes one crab by its agent id; its progress bar then grows from where it was. */
114async function touchCrab($: EngineInterface, agentId: string, patch: (m: LensCrab) => Partial<LensCrab>): Promise<boolean> {
115 const at = await $.clock.now()
116 let found = false
117 await update($, crew, all => {
118 const m = Object.values(all).find(c => c.agentId === agentId)
119 if (!m) return all
120 found = true
121 return { ...all, [m.id]: withChange(m, patch(m), at) }
122 })
123 return found
124}
125
126/**
127 * Reads what the context says about this session: the names /mcp lists each server under
128 * (a claude.ai connector's wire name is an id), and the skills a memory file pulls in at start.
129 */
130async function readContext($: EngineInterface): Promise<void> {
131 const usage = await $.session.usage({ breakdown: 'summary' })
132 const b = usage.context.breakdown
133 if (!b) return
134 const names: Record<string, string> = {}
135 for (const t of b.mcpTools ?? []) {
136 const { server } = splitTool(t.name)
137 if (server && t.serverName) names[server] = t.serverName
138 }
139 if (Object.keys(names).length) await update($, servers, cur => ({ ...cur, ...names }))
140 const always = (b.memoryFiles ?? []).map(m => skillOfPath(m.path)).filter(Boolean)
141 if (always.length) await update($, skills, cur => markAlways(cur, always))
142 await noteMoves($)
143}
144
145/** Opens the pane, its rows coming in once as it does. */
146async function openPane($: EngineInterface): Promise<void> {
147 const at = await $.clock.now()
148 await update($, view, v => {
149 const full = viewOf(v)
150 return { ...full, toggledAt: { ...full.toggledAt, pane: at } }
151 })
152 await $.ui.open({ id: PANE, title: TITLE })
153 // The plan's rows in the transcript go as the To-do shows beside them.
154 $.ui.invalidate('ui.render')
155 settle($)
156 await safely(() => $.store.set(CLOSED_KEY, false))
157}
158
159/**
160 * Opens the pane the first time the session uses something, where it would sit beside the chat,
161 * unless the person closed it last time.
162 */
163async function autoOpen($: EngineInterface, isOn: boolean): Promise<void> {
164 if (!isOn || (await read($, isAutoOpened))) return
165 const inApp = (await $.session.surfaces()).some(s => s !== 'terminal')
166 if (!inApp) return
167 await update($, isAutoOpened, () => true)
168 if ((await $.store.get(CLOSED_KEY)) === true) return
169 await openPane($)
170}
171
172/** Reads the kept layout into this session's view, once: a reload keeps what the person changed since. */
173async function loadLayout($: EngineInterface): Promise<void> {
174 if (await read($, isLayoutLoaded)) return
175 const kept = await $.store.get(LAYOUT_KEY)
176 await update($, view, v => (kept === undefined ? viewOf(v) : { ...viewOf(v), ...parseLayout(kept) }))
177 await update($, isLayoutLoaded, () => true)
178}
179
180/** A resumed session's earlier calls, counted once; a reload keeps what was counted. */
181async function seed($: EngineInterface): Promise<void> {
182 if (await read($, isSeeded)) return
183 await update($, isSeeded, () => true)
184 const messages = await $.session.messages()
185 let at = 1
186 for (const m of messages) {
187 for (const u of m.toolUses) {
188 at += 1
189 const stamp = at
190 await update($, tools, cur => countTool(cur, u.tool, u.isError === true, stamp))
191 if (u.tool === 'Skill' && typeof u.input.skill === 'string' && u.input.skill)
192 await update($, skills, cur => countSkill(cur, String(u.input.skill).replace(/^\//, ''), stamp))
193 if (PLAN_TOOLS.has(u.tool) && u.isError !== true) {
194 await update($, plan, cur => planAfter(cur, u.tool, u.input, u.result, stamp) ?? cur)
195 await notePlanRow($, u.tool, u.tool_use_id)
196 }
197 }
198 } // The order the history left, noted so the next change flies from it.
199 await noteMoves($)
200}
201
202/** A /clear starts a new conversation: its counts start over, the layout stays. */
203async function startOver($: EngineInterface): Promise<void> {
204 await update($, plan, () => EMPTY_PLAN)
205 await update($, tools, () => ({}))
206 await update($, skills, () => ({}))
207 await update($, crew, () => ({}))
208 await update($, isSeeded, () => true)
209 await readContext($)
210}
211
212/** Opens the lens at its crew: the pane, with the Crew section unfolded if it was folded. */
213async function openCrew($: EngineInterface): Promise<void> {
214 const at = await $.clock.now()
215 await update($, view, v => {
216 const full = viewOf(v)
217 return full.folded.includes('crew') ? { ...full, folded: full.folded.filter(s => s !== 'crew'), toggledAt: { ...full.toggledAt, crew: at } } : full
218 })
219 await openPane($)
220}
221
222export const register: Register = (on, options) => {
223 const isAutoOpenOn = options.autoOpen !== false
224 const isBandOn = options.crewBand !== false
225 const isHideRowsOn = options.hidePlanRows !== false
226 const isQuipsOn = options.showQuips !== false
227
228 on('session.start', async ($, e, next) => {
229 const started = await next(e)
230 await safely(() =>
231 $.command.register({
232 name: COMMAND,
233 description: 'Show or hide the session lens: the to-do list, skills and tools this session used',
234 }),
235 )
236 await safely(() => loadLayout($))
237 void safely(() => seed($))
238 void safely(() => readContext($))
239 // While a crab works, a redraw now and then: one left without a call for a while is drawn waiting.
240 $.clock.every(CREW_TICK_MS, () => {
241 void safely(async () => {
242 if (!Object.values(await read($, crew)).some(m => m.status === 'running')) return
243 const at = await $.clock.now()
244 await update($, crewTick, () => at)
245 })
246 })
247 return started
248 })
249
250 // A subagent sets out: a crab joins the crew, cast by its agent type, and flies in at the foot of the working list.
251 on('agent.spawn', async ($, e, next) => {
252 const started = await next(e)
253 await safely(async () => {
254 if (started.deny !== undefined) return
255 const at = await $.clock.now()
256 const character = characterKeyOf(e.subagentType)
257 // No one at work: this crab starts a new race, and the crabs of the last one leave the lists.
258 if (!Object.values(await read($, crew)).some(m => m.status === 'running')) await update($, raceSince, () => at)
259 await update($, crew, all => {
260 const list = Object.values(all)
261 const m: LensCrab = {
262 id: started.agentId ?? e.tool_use_id,
263 agentId: started.agentId,
264 type: e.subagentType,
265 character,
266 name: crewNameOf(character, list),
267 description: e.description,
268 status: 'running',
269 startedAt: at,
270 lastActiveAt: at,
271 activity: 'start',
272 detail: '',
273 toolCalls: 0,
274 errors: 0,
275 model: started.model,
276 tokens: 0,
277 steps: 0,
278 progressBefore: 0,
279 progressAt: at,
280 }
281 // The oldest crab back at base leaves first once the crew is full.
282 const kept = list.length < MAX_CREW ? list : list.filter(c => c !== list.filter(o => o.status !== 'running').sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0))[0])
283 return Object.fromEntries([...kept, m].map(c => [c.id, c]))
284 })
285 await noteMoves($)
286 settle($)
287 await autoOpen($, isAutoOpenOn)
288 })
289 return started
290 }).catch(($, e, next) => next(e))
291
292 // Each model request of a subagent: its crab is thinking, and once answered, what it used is added up.
293 on('turn.step', async function* ($, e, next) {
294 const agentId = e.agentId
295 await safely(async () => {
296 if (!agentId) return setCaptain($, true, 'think', '')
297 const at = await $.clock.now()
298 await touchCrab($, agentId, () => ({ activity: 'think', detail: '', lastActiveAt: at }))
299 })
300 const result = yield* next(e)
301 const usage = result.usage
302 if (agentId && usage)
303 await safely(() => touchCrab($, agentId, m => ({ model: usage.model || m.model, tokens: (m.tokens ?? 0) + usageTokens(usage), steps: (m.steps ?? 0) + 1 })))
304 return result
305 })
306
307 // A subagent's run ends: its crab is done, failed or stopped. One that is done walks past its flag and
308 // stands there a moment, then flies to the top of the crabs that reported back; the others fly there now.
309 // The main conversation's turn ending puts the captain off watch until your next prompt.
310 on('turn.complete', async ($, e, next) => {
311 const agentId = e.agentId
312 await safely(async () => {
313 if (!agentId) return setCaptain($, false, 'other', '')
314 const at = await $.clock.now()
315 const status = outcomeOf(e.reason)
316 // A run whose requests were not seen one by one: its own totals stand for them.
317 const usage = e.usage
318 const patch = (m: LensCrab) => ({
319 status,
320 endedAt: at,
321 lastActiveAt: at,
322 ...(!m.steps && usage ? { model: usage.model || m.model, tokens: usageTokens(usage) } : {}),
323 })
324 if (!(await touchCrab($, agentId, patch))) return
325 await noteMoves($)
326 settle($)
327 if (status === 'done')
328 $.clock.after(LINGER_MS + 50, () => {
329 void safely(async () => {
330 await noteMoves($)
331 settle($)
332 })
333 })
334 })
335 return next(e)
336 }).catch(($, e, next) => next(e))
337
338 // A new prompt with no one out: the band clears, and comes back with the next crew to set out.
339 on('prompt.submit', async ($, e, next) => {
340 await safely(async () => {
341 if (Object.values(await read($, crew)).some(m => m.status === 'running')) return
342 const at = await $.clock.now()
343 await update($, crewBandSince, () => at)
344 })
345 return next(e)
346 }).catch(($, e, next) => next(e))
347
348 // The crew band above the prompt: the crabs out since the last prompt, and how far along they are,
349 // drawn above whatever other mods put there.
350 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
351 if (!isBandOn || e.props.hasSurvey) return next(e)
352 const since = await read($, crewBandSince)
353 const all = Object.values(await read($, crew))
354 const shown = all.filter(m => m.startedAt > since || (m.endedAt ?? -1) > since).sort((a, b) => a.startedAt - b.startedAt)
355 if (!shown.length) return next(e)
356 const ui = $.ui.resolve(e)
357 const ours = bandView({
358 ui,
359 isPictured: e.surface !== 'terminal' && 'Svg' in ui,
360 columns: Math.max(40, e.props.bodyColumns || 100),
361 shown,
362 now: Math.max(await $.clock.now(), await read($, crewTick)),
363 openCrew: () => void safely(() => openCrew($)),
364 dismiss: () =>
365 void safely(async () => {
366 const at = await $.clock.now()
367 await update($, crewBandSince, () => at)
368 }),
369 })
370 // The band is shared by every mod: ours goes first, and what the others draw stays under it.
371 const { Box } = ui
372 const others = await next(e)
373 return (
374 <Box flexDirection="column">
375 {ours}
376 {others}
377 </Box>
378 )
379 })
380
381 // The person's own close is remembered, so the next session does not open the pane by itself.
382 on('ui.close', async ($, e, next) => {
383 const out = await next(e)
384 if (e.id === PANE && e.origin.kind === 'person') await safely(() => $.store.set(CLOSED_KEY, true))
385 // The plan's rows in the transcript come back with the pane gone.
386 if (e.id === PANE) await safely(async () => $.ui.invalidate('ui.render'))
387 return out
388 }).catch(($, e, next) => next(e))
389
390 on('session.end', async ($, e, next) => {
391 if (e.reason === 'clear') await safely(() => startOver($))
392 return next(e)
393 }).catch(($, e, next) => next(e))
394
395 on('command.run', { command: COMMAND }, async $ => {
396 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
397 if (isOpen) {
398 await $.ui.close({ id: PANE })
399 await safely(() => $.store.set(CLOSED_KEY, true))
400 return { text: 'Session lens closed.' }
401 }
402 await openPane($)
403 void safely(() => readContext($))
404 return { text: 'Session lens opened.' }
405 }).catch(() => ({ text: 'The session lens could not open just now.' }))
406
407 // Every tool call, the main conversation's and each subagent's: the call runs first, then it is counted.
408 on('tool.call', async ($, e, next) => {
409 // A subagent's call: its crab is doing this now, and its own to-do list may move its progress.
410 const agentId = e.agentId
411 if (!agentId) await safely(() => setCaptain($, true, activityOf(String(e.tool)), detailOf(e as unknown as Record<string, unknown>)))
412 if (!agentId) await safely(() => notePlanRow($, String(e.tool), e.tool_use_id))
413 if (agentId)
414 await safely(async () => {
415 const tool = String(e.tool)
416 const input = e as unknown as Record<string, unknown>
417 const at = await $.clock.now()
418 const moved = await touchCrab($, agentId, m => ({
419 activity: activityOf(tool),
420 detail: detailOf(input),
421 lastActiveAt: at,
422 toolCalls: m.toolCalls + 1,
423 ...todoPatch(tool, input, m),
424 }))
425 if (moved) await noteMoves($)
426 })
427 const ran = await next(e)
428 await safely(async () => {
429 if (agentId && ran.isError === true) await touchCrab($, agentId, m => ({ errors: m.errors + 1 }))
430 if (ran.deny !== undefined) return
431 const at = await $.clock.now()
432 const name = String(e.tool)
433 const isNew = !(name in (await read($, tools)))
434 await update($, tools, cur => countTool(cur, name, ran.isError === true, at))
435 await noteMoves($)
436 // The task list is the main conversation's; a subagent's own list is its business.
437 if (PLAN_TOOLS.has(name) && e.agentId === undefined && ran.isError !== true) {
438 const input = e as unknown as Record<string, unknown>
439 await update($, plan, cur => planAfter(cur, name, input, ran.result, Math.max(at, cur.updatedAt + 1)) ?? cur)
440 }
441 settle($)
442 // A server not seen before may be a connector whose wire name is an id: ask for its listed name.
443 const { server } = splitTool(name)
444 if (isNew && server && !(server in (await read($, servers)))) void safely(() => readContext($))
445 await autoOpen($, isAutoOpenOn)
446 })
447 return ran
448 }).catch(($, e, next) => next(e))
449
450 // A skill expanded for the model: typed as /name, called through the Skill tool, or preloaded.
451 on('skill.prompt', async ($, e, next) => {
452 const out = await next(e)
453 await safely(async () => {
454 const at = await $.clock.now()
455 await update($, skills, cur => countSkill(cur, e.skill, at))
456 await noteMoves($)
457 settle($)
458 await autoOpen($, isAutoOpenOn)
459 })
460 return out
461 }).catch(($, e, next) => next(e))
462
463 // The main conversation's plan rows in the transcript, hidden while the lens shows the same list beside
464 // them. A row that errored, the ctrl+o transcript and a subagent's own list are drawn as ever.
465 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
466 const p = e.props
467 return isHideRowsOn && !p.isErrored && p.isExpanded !== true && (await isPlanInLens($, p.tool, p.tool_use_id)) ? <></> : next(e)
468 })
469 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
470 const p = e.props
471 return isHideRowsOn && !p.isErrored && p.isExpanded !== true && (await isPlanInLens($, p.tool, p.tool_use_id)) ? <></> : next(e)
472 })
473
474 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
475 const ui = $.ui.resolve(e)
476 // Each change is stamped, so what it sets moving moves once, from then, and not on later redraws,
477 // and the layout is kept for the next session.
478 const setView = (patch: (v: LensView, at: number) => Partial<LensView>) =>
479 void safely(async () => {
480 const at = await $.clock.now()
481 let changed: LensView | undefined
482 await update($, view, v => {
483 const full = viewOf(v)
484 changed = { ...full, ...patch(full, at) }
485 return changed
486 })
487 await noteMoves($)
488 settle($)
489 if (changed) await $.store.set(LAYOUT_KEY, layoutOf(changed))
490 })
491 const flip = <T,>(list: T[], item: T) => (list.includes(item) ? list.filter(x => x !== item) : [...list, item])
492 return paneView({
493 ui,
494 isPictured: e.surface !== 'terminal' && 'Svg' in ui,
495 columns: Math.max(24, e.props.bodyColumns || 48),
496 plan: await read($, plan),
497 tools: await read($, tools),
498 skills: await read($, skills),
499 servers: await read($, servers),
500 view: viewOf(await read($, view)),
501 moves: await read($, moves),
502 crew: await read($, crew),
503 captain: await read($, captain),
504 raceSince: await read($, raceSince),
505 showQuips: isQuipsOn,
506 // Read so the timer's tick redraws the pane; the drawing itself goes by the clock.
507 now: Math.max(await $.clock.now(), await read($, crewTick)),
508 act: {
509 toggleSection: (id: LensSection) => setView((v, at) => ({ folded: flip(v.folded, id), toggledAt: { ...v.toggledAt, [id]: at } })),
510 toggleGroup: (key: string) => setView((v, at) => ({ groupExceptions: flip(v.groupExceptions, key), toggledAt: { ...v.toggledAt, [`g:${key}`]: at } })),
511 setAllGroups: (isOpen: boolean) => setView((_, at) => ({ groupsOpen: isOpen, groupExceptions: [], layoutAt: at })),
512 setGrouped: (isGrouped: boolean) => setView((v, at) => (v.isGrouped === isGrouped ? {} : { isGrouped, layoutAt: at })),
513 // A new sort is a change of order: the rows fly to their new places (noted by setView).
514 setSort: (sort: LensSort) => setView(v => (v.sort === sort ? {} : { sort })),
515 setFilter: (filter: string) => setView(() => ({ filter })),
516 toggleLegend: () => setView((v, at) => ({ isLegendOpen: !v.isLegendOpen, toggledAt: { ...v.toggledAt, legend: at } })),
517 },
518 })
519 })
520}
521hooks/crew.ts 452 lines1// The crew: each subagent of the session is a crab, cast by its agent type. What it is doing, how far
2// it has got, and the order the pane lists the crew in. Pure functions: no `$` here.
3// Adapted from this repository's crab-crew mod.
4import type { LensActivity, LensCaptain, LensCrab } from '../types'
5
6/** What a crab wears: one small mark of its role, drawn on top of the crab. */
7export type Accessory = 'bandana' | 'cap' | 'tricorn' | 'glasses' | 'antenna' | 'halo' | 'toque' | 'deerstalker' | 'wizard' | 'goggles' | 'captain'
8
9export type Character = {
10 key: string
11 role: string
12 names: readonly string[]
13 /** The crab's shell. */
14 color: string
15 accessory: Accessory
16 /** Tool calls in a typical job: sets how fast an estimated progress climbs. */
17 pace: number
18}
19
20export const CHARACTERS: Record<string, Character> = {
21 deckhand: { key: 'deckhand', role: 'Deckhand', names: ['Barnacle', 'Rusty', 'Sal', 'Dory', 'Mack'], color: '#E0574F', accessory: 'bandana', pace: 22 },
22 scout: { key: 'scout', role: 'Scout', names: ['Pip', 'Wren', 'Kit', 'Marlo', 'Juno'], color: '#3FAE5F', accessory: 'cap', pace: 14 },
23 navigator: { key: 'navigator', role: 'Navigator', names: ['Ada', 'Vela', 'Orion', 'Mags'], color: '#4C8DFF', accessory: 'tricorn', pace: 16 },
24 professor: { key: 'professor', role: 'Professor', names: ['Kelp', 'Sage', 'Quill', 'Tomes'], color: '#8E6CF0', accessory: 'glasses', pace: 10 },
25 signaller: { key: 'signaller', role: 'Signaller', names: ['Sparky', 'Dot', 'Morse'], color: '#D99A1E', accessory: 'antenna', pace: 8 },
26 echo: { key: 'echo', role: 'Understudy', names: ['Echo', 'Mirror', 'Twin'], color: '#1EA7C5', accessory: 'halo', pace: 20 },
27 // The pool: any other agent type is dealt one of these, the same one every time.
28 cook: { key: 'cook', role: 'Cook', names: ['Cookie', 'Saffron', 'Basil'], color: '#E2775A', accessory: 'toque', pace: 20 },
29 sleuth: { key: 'sleuth', role: 'Sleuth', names: ['Inky', 'Clue', 'Sherl'], color: '#B07A45', accessory: 'deerstalker', pace: 18 },
30 wizard: { key: 'wizard', role: 'Wizard', names: ['Merla', 'Coral', 'Zap'], color: '#7B6BD8', accessory: 'wizard', pace: 20 },
31 diver: { key: 'diver', role: 'Diver', names: ['Bubbles', 'Finn', 'Marina'], color: '#2E8BC0', accessory: 'goggles', pace: 20 },
32 // The main thread: never cast for a subagent, always on the beach.
33 captain: { key: 'captain', role: 'Captain', names: ['Clawd'], color: '#4A6FB5', accessory: 'captain', pace: 20 },
34}
35
36const POOL = ['cook', 'sleuth', 'wizard', 'diver'] as const
37
38const BY_TYPE: Record<string, string> = {
39 'general-purpose': 'deckhand',
40 claude: 'deckhand',
41 Explore: 'scout',
42 Plan: 'navigator',
43 'claude-code-guide': 'professor',
44 'statusline-setup': 'signaller',
45 fork: 'echo',
46}
47
48/** `some-plugin:helper` and `helper` name the same agent; the plugin prefix is dropped. */
49const bareType = (type: string): string => type.replace(/^[^:]*:/, '')
50
51/** FNV-1a: a stable pick from a name, the same on every load. */
52const hash = (s: string): number => {
53 let h = 0x811c9dc5
54 for (let i = 0; i < s.length; i++) {
55 h ^= s.charCodeAt(i)
56 h = Math.imul(h, 0x01000193)
57 }
58 return h >>> 0
59}
60
61export const characterKeyOf = (type: string): string => BY_TYPE[type] ?? BY_TYPE[bareType(type)] ?? POOL[hash(bareType(type)) % POOL.length] ?? 'deckhand'
62
63export const characterOf = (key: string): Character => CHARACTERS[key] ?? (CHARACTERS.deckhand as Character)
64
65const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X']
66
67/** The next free name for a character: Pip, Wren, ..., then Pip II. */
68export function crewNameOf(key: string, crew: readonly LensCrab[]): string {
69 const { names } = characterOf(key)
70 const taken = crew.filter(m => m.character === key).length
71 const base = names[taken % names.length] ?? 'Crab'
72 const round = Math.floor(taken / names.length) + 1
73 return round > 1 ? `${base} ${ROMAN[round] ?? round}` : base
74}
75
76export const titleOf = (m: Pick<LensCrab, 'name' | 'character'>): string => `${m.name} the ${characterOf(m.character).role}`
77
78// ---------- what a crab is doing ----------
79
80export function activityOf(tool: string): LensActivity {
81 if (/^(Read|NotebookRead)$/.test(tool)) return 'read'
82 if (/^(Grep|Glob|LS|ToolSearch)$/.test(tool)) return 'search'
83 if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool)) return 'edit'
84 if (/^(Bash|PowerShell|Monitor|BashOutput)$/.test(tool)) return 'shell'
85 if (/^(WebFetch|WebSearch)$/.test(tool) || (tool.startsWith('mcp__') && /browser|chrome|fetch|web/i.test(tool))) return 'web'
86 if (/^(Agent|Task|SendMessage|Workflow)$/.test(tool)) return 'delegate'
87 if (/^(TodoWrite|TaskCreate|TaskUpdate|TaskList|TaskGet|EnterPlanMode|ExitPlanMode)$/.test(tool)) return 'plan'
88 return 'other'
89}
90
91const str = (v: unknown): string => (typeof v === 'string' ? v : '')
92const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p
93const clip = (s: string, max: number): string => (s.length > max ? `${s.slice(0, Math.max(1, max - 1))}…` : s)
94
95/** A call's target in a few characters: a file's name, a pattern, a command's head, a host. */
96export function detailOf(input: Record<string, unknown>): string {
97 const file = str(input.file_path) || str(input.notebook_path)
98 if (file) return baseName(file)
99 if (str(input.pattern)) return clip(str(input.pattern), 28)
100 const command = str(input.command).trim().split('\n')[0] ?? ''
101 if (command) return clip(command, 28)
102 const url = str(input.url)
103 if (url) return /^[a-z]+:\/\/([^/?#]+)/i.exec(url)?.[1] ?? clip(url, 28)
104 if (str(input.query)) return clip(str(input.query), 28)
105 if (str(input.description)) return clip(str(input.description), 28)
106 if (str(input.skill)) return str(input.skill)
107 if (str(input.path)) return baseName(str(input.path))
108 return ''
109}
110
111type Todo = { status?: unknown; activeForm?: unknown; content?: unknown }
112
113/** Progress an agent reports without knowing it: its own to-do list. */
114export function todoPatch(tool: string, input: Record<string, unknown>, m: LensCrab): Partial<LensCrab> {
115 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
116 const todos = input.todos as Todo[]
117 const done = todos.filter(t => t.status === 'completed').length
118 const active = todos.find(t => t.status === 'in_progress')
119 return {
120 todoTotal: todos.length,
121 todoDone: done + (active ? 0.5 : 0),
122 todoNote: active ? str(active.activeForm) || str(active.content) || undefined : undefined,
123 }
124 }
125 if (tool === 'TaskCreate') return { todoTotal: (m.todoTotal ?? 0) + 1, todoDone: m.todoDone ?? 0 }
126 if (tool === 'TaskUpdate') {
127 const total = m.todoTotal ?? 0
128 const done = Math.floor(m.todoDone ?? 0)
129 if (input.status === 'completed') return { todoDone: Math.min(total, done + 1), todoNote: undefined }
130 if (input.status === 'deleted') return { todoTotal: Math.max(0, total - 1), todoDone: Math.min(done, Math.max(0, total - 1)) }
131 if (input.status === 'in_progress') return { todoDone: done + 0.5, todoNote: str(input.activeForm) || str(input.subject) || undefined }
132 }
133 return {}
134}
135
136/** How a run ended, from `turn.complete`'s reason. */
137export const outcomeOf = (reason: string): LensCrab['status'] => (reason === 'answer' ? 'done' : reason === 'aborted' ? 'stopped' : 'failed')
138
139// ---------- progress and pose ----------
140
141/** No call for this long while running: the crab is drawn waiting. */
142export const STALL_MS = 20_000
143
144/**
145 * How far along a crab is, 0 to 1. Its own to-do list when it keeps one; otherwise an estimate that
146 * climbs with its calls toward 92% at its character's pace, and reaches 100% only when it is done.
147 */
148export function progressOf(m: Pick<LensCrab, 'status' | 'todoTotal' | 'todoDone' | 'toolCalls' | 'character'>): { value: number; isEstimate: boolean } {
149 if (m.status === 'done') return { value: 1, isEstimate: false }
150 if (m.todoTotal) return { value: Math.min(1, (m.todoDone ?? 0) / m.todoTotal), isEstimate: false }
151 return { value: 0.92 * (1 - Math.exp(-(m.toolCalls + 0.5) / characterOf(m.character).pace)), isEstimate: true }
152}
153
154export function progressLabel(m: LensCrab): string {
155 if (m.status === 'done') return '100%'
156 if (m.todoTotal) return `${Math.floor(m.todoDone ?? 0)}/${m.todoTotal}`
157 return `≈${Math.round(progressOf(m).value * 100)}%`
158}
159
160/** A crab with a change applied, its progress bar set to grow from where it was when it moves. */
161export function withChange(m: LensCrab, patch: Partial<LensCrab>, at: number): LensCrab {
162 const next = { ...m, ...patch }
163 const was = progressOf(m).value
164 const now = progressOf(next).value
165 return Math.abs(now - was) > 0.001 ? { ...next, progressBefore: was, progressAt: at } : next
166}
167
168export type Pose = 'work' | 'scurry' | 'think' | 'wait' | 'done' | 'failed' | 'stopped'
169
170/** A call this recent: the crab scurries, at twice its pace. */
171export const SCURRY_MS = 3_000
172
173export function poseOf(m: LensCrab, now: number): Pose {
174 if (m.status !== 'running') return m.status
175 if (m.activity === 'think' || m.activity === 'start') return 'think'
176 const quiet = now - m.lastActiveAt
177 return quiet > STALL_MS ? 'wait' : quiet < SCURRY_MS ? 'scurry' : 'work'
178}
179
180const VERB: Record<LensActivity, string> = {
181 start: 'setting out',
182 think: 'thinking',
183 read: 'reading',
184 search: 'searching',
185 edit: 'editing',
186 shell: 'running',
187 web: 'browsing',
188 delegate: 'delegating',
189 plan: 'planning',
190 other: 'working',
191}
192
193/** What the crab is doing, plainly: its to-do in progress, or the verb and target of its last call. */
194export function actionOf(m: LensCrab, now: number): string {
195 if (m.status === 'done') return 'back at base'
196 if (m.status === 'failed') return 'went overboard'
197 if (m.status === 'stopped') return 'called back'
198 if (m.todoNote) return m.todoNote
199 const waited = now - m.lastActiveAt > STALL_MS ? ` · waiting ${duration(now - m.lastActiveAt)}` : ''
200 if (m.activity === 'think' || m.activity === 'start') return `${VERB[m.activity]}${waited}`
201 return `${VERB[m.activity]}${m.detail ? ` ${m.detail}` : ''}${waited}`
202}
203
204/** A span of time as a clock reads it: `0:31`, `2:20`, `1:04:09`. */
205export function duration(ms: number): string {
206 const s = Math.max(0, Math.round(ms / 1000))
207 const h = Math.floor(s / 3600)
208 const m = Math.floor((s % 3600) / 60)
209 const ss = String(s % 60).padStart(2, '0')
210 return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
211}
212
213/** A model id as people say it: `claude-haiku-4-5` is Haiku 4.5. */
214export function modelName(id: string | undefined): string {
215 if (!id) return ''
216 const [, family = '', major = '', minor] = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id) ?? []
217 if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
218 return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? `.${minor}` : ''}`
219}
220
221/** A token count, short: `940`, `61k`, `1.2M`. */
222export const tokensLabel = (n: number): string => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`)
223
224/** What one request used, counted as crab-crew counts it: input, output and cache reads and writes. */
225export const usageTokens = (u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): number =>
226 (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
227
228const calls = (n: number) => `${n} call${n === 1 ? '' : 's'}`
229
230/**
231 * A crab's details, crab-crew's line: its model, what it is doing (at work) or how long it took (back),
232 * its calls, any that failed, and its tokens. A drawing short of room passes the action cut to fit.
233 */
234export function crabMeta(m: LensCrab, now: number, action = m.status === 'running' ? actionOf(m, now) : ''): string {
235 return [
236 m.status === 'running' ? '' : duration((m.endedAt ?? now) - m.startedAt),
237 modelName(m.model),
238 action,
239 calls(m.toolCalls),
240 m.errors ? `${m.errors} failed` : '',
241 m.tokens ? `${tokensLabel(m.tokens)} tok` : '',
242 ]
243 .filter(Boolean)
244 .join(' · ')
245}
246
247/**
248 * The totals of one list of the race, for beside its title: how many, how the runs back ended when not
249 * all well, their calls and tokens, and the time from the first to set out to now (at work) or to the
250 * last back.
251 */
252export function listTotals(kind: 'work' | 'base', list: readonly LensCrab[], now: number): string[] {
253 if (!list.length) return []
254 const count = (status: LensCrab['status']) => list.filter(m => m.status === status).length
255 const tokens = list.reduce((n, m) => n + (m.tokens ?? 0), 0)
256 const start = Math.min(...list.map(m => m.startedAt))
257 const end = kind === 'work' ? now : Math.max(...list.map(m => m.endedAt ?? now))
258 return [
259 String(list.length),
260 kind === 'base' && count('failed') ? `${count('failed')} overboard` : '',
261 kind === 'base' && count('stopped') ? `${count('stopped')} called back` : '',
262 calls(list.reduce((n, m) => n + m.toolCalls, 0)),
263 tokens ? `${tokensLabel(tokens)} tok` : '',
264 duration(end - start),
265 ].filter(Boolean)
266}
267
268// ---------- the crew's order ----------
269
270/** Splits the crew list: the crabs still out, before it; the ones back at base, after it. */
271export const BASE_MARK = '|'
272
273const byName = (a: LensCrab, b: LensCrab) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || a.startedAt - b.startedAt
274
275/** How long a crab that reached its flag stands there, past it, before it flies to the list of those back. */
276export const LINGER_MS = 3000
277
278/** Whether a crab is on the beach: still at work, or done and standing past its flag for a moment. */
279export const isOnBeach = (m: LensCrab, now: number): boolean => m.status === 'running' || (m.status === 'done' && now - (m.endedAt ?? 0) < LINGER_MS)
280
281/** The crabs of the race that began at `since`: those still out, and those that came back since. */
282export const raceCrew = (crew: Record<string, LensCrab>, since: number): LensCrab[] =>
283 Object.values(crew).filter(m => m.status === 'running' || (m.endedAt ?? 0) >= since)
284
285/**
286 * The race's crabs on the beach, in the order they set out: each keeps its lane and its row while it
287 * works (the race is in how far each has walked), and the ones that reported back, the latest first.
288 */
289export function crewLists(crew: Record<string, LensCrab>, now: number, since = 0): { working: LensCrab[]; base: LensCrab[] } {
290 const all = raceCrew(crew, since)
291 // A stable sort: crabs that set out at the same moment keep the order they were spawned in.
292 const working = all.filter(m => isOnBeach(m, now)).sort((a, b) => a.startedAt - b.startedAt)
293 const base = all.filter(m => !isOnBeach(m, now)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0) || byName(a, b))
294 return { working, base }
295}
296
297/** The crew's order as one list, the base mark between the two halves: what the flights compare. */
298export function crewOrder(crew: Record<string, LensCrab>, now: number, since = 0): string[] {
299 const { working, base } = crewLists(crew, now, since)
300 return [...working.map(m => m.id), BASE_MARK, ...base.map(m => m.id)]
301}
302
303export function crewSummary(crew: Record<string, LensCrab>, since = 0): string {
304 const all = raceCrew(crew, since)
305 const out = all.filter(m => m.status === 'running').length
306 const back = all.length - out
307 return [out ? `${out} at work` : '', back ? `${back} back` : ''].filter(Boolean).join(' · ')
308}
309
310// ---------- what a crab says ----------
311
312/** What a crab says depends on its mood: what it is doing, or how its run ended. */
313type Mood = LensActivity | 'wait' | 'done' | 'failed' | 'stopped'
314
315const GENERIC: Record<Mood, readonly string[]> = {
316 start: ['Reporting for duty!', 'Aye aye!', 'Claws ready.'],
317 think: ['Hmm, let me think', 'Pondering', 'Mulling it over'],
318 read: ['Reading {x}', 'Peeking at {x}'],
319 search: ['Searching for {x}', 'Combing the sand'],
320 edit: ['Tweaking {x}', 'Patching {x}'],
321 shell: ['Running {x}', 'Cranking the engine'],
322 web: ['Checking {x}', 'Asking the wider sea'],
323 delegate: ['Calling in help: {x}', 'Need a hand here!'],
324 plan: ['Making a list', 'Checking it twice'],
325 other: ['On it', 'Busy, busy'],
326 wait: ['Still waiting…', 'Tap, tap, tap'],
327 done: ['Back at base!', 'Job done.', 'Nailed it.'],
328 failed: ['Man overboard!', 'That went sideways.'],
329 stopped: ['Called back to base.', 'Standing down.'],
330}
331
332/** Each character's own lines, by mood; a mood with none falls back to the generic ones. */
333const QUIPS: Record<string, Partial<Record<Mood, readonly string[]>>> = {
334 deckhand: {
335 start: ["Aye, I'll handle it!", 'Rolling up me claws.'],
336 edit: ['Hammering {x} into shape', 'Swabbing {x}'],
337 shell: ['Hauling on {x}'],
338 wait: ['Leaning on the mop'],
339 done: ['Shipshape and done!', "Deck's clean, Cap'n."],
340 failed: ['Slipped on the deck!'],
341 },
342 scout: {
343 start: ['Off to explore!', 'Spyglass ready.'],
344 read: ['Spyglass on {x}', 'Ooh, what is {x}?'],
345 search: ['Land ho? Hunting {x}', 'Scanning for {x}'],
346 think: ['Mapping what I saw'],
347 done: ['Scouting report ready!', 'Charted it all!'],
348 failed: ['Lost in the fog!'],
349 },
350 navigator: {
351 start: ['Unrolling the charts.'],
352 think: ['Plotting a course', 'Weighing the winds'],
353 read: ['Studying {x}'],
354 search: ['Taking soundings: {x}'],
355 plan: ['Marking waypoints'],
356 done: ['Course plotted!', 'Heading set, Captain.'],
357 failed: ['Off the edge of the map!'],
358 },
359 professor: {
360 start: ['Ahem. To the library!'],
361 read: ['Consulting {x}'],
362 web: ['Looking it up: {x}'],
363 think: ['Fascinating, one moment'],
364 done: ['Footnotes included.'],
365 failed: ['The page is missing!'],
366 },
367 signaller: { start: ['Signals up!'], edit: ['Rewiring {x}'], shell: ['Testing the lamp: {x}'], done: ["Signal's lit!"] },
368 echo: { start: ['Same brain, new claws.'], think: ['As the Captain would'], done: ['Echo complete.'] },
369 cook: {
370 start: ['Firing up the galley!'],
371 edit: ['Stirring {x}', 'A pinch of {x}'],
372 shell: ['Turning up the heat'],
373 done: ['Order up!'],
374 failed: ['Burnt the omelette!'],
375 },
376 sleuth: {
377 start: ['The game is afoot!'],
378 search: ['Following the trail: {x}'],
379 read: ['Examining {x}'],
380 think: ['Connecting the dots'],
381 done: ['Case closed.'],
382 failed: ['The trail went cold.'],
383 },
384 wizard: {
385 start: ['By my shell!'],
386 edit: ['Enchanting {x}'],
387 shell: ['Casting {x}'],
388 think: ['Consulting the stars'],
389 done: ['Ta-da!'],
390 failed: ['The spell fizzled!'],
391 },
392 diver: { start: ['Taking the plunge!'], search: ['Diving for {x}'], read: ['Deep in {x}'], done: ['Surfaced with treasure!'], failed: ['Out of air!'] },
393}
394
395const moodOf = (m: LensCrab, now: number): Mood => (m.status !== 'running' ? m.status : poseOf(m, now) === 'wait' ? 'wait' : m.activity)
396
397/** The crab's line, in character: stable until it does something new, then a new one. */
398export function quipOf(m: LensCrab, now: number): string {
399 const mood = moodOf(m, now)
400 const list = QUIPS[m.character]?.[mood] ?? GENERIC[mood]
401 const i = hash(m.id + mood) + (m.status === 'running' ? m.toolCalls : 0)
402 return (list[i % list.length] ?? '').replace('{x}', clip(m.detail, 22) || 'the code')
403}
404
405// ---------- the captain ----------
406
407export const CAPTAIN_NAME = 'Captain Clawd'
408
409const CAPTAIN_LINES: Record<LensActivity, readonly string[]> = {
410 start: ['All hands!'],
411 think: ['Charting the course', 'Reading the stars'],
412 read: ['Reviewing {x}'],
413 search: ['Scanning the horizon: {x}'],
414 edit: ['Taking the wheel: {x}'],
415 shell: ['Full steam: {x}'],
416 web: ['Signalling ashore: {x}'],
417 delegate: ['Dispatching the crew', 'All hands to {x}'],
418 plan: ['Updating the log'],
419 other: ['At the helm'],
420}
421
422/** The captain asleep while Claude waits for you, thinking while it waits on the model, else at work. */
423export const captainPose = (c: LensCaptain): Pose => (!c.isBusy ? 'stopped' : c.activity === 'think' || c.activity === 'start' ? 'think' : 'work')
424
425/** What the captain says: what the main conversation is doing, or that it awaits your orders. */
426export function captainQuip(c: LensCaptain): string {
427 if (!c.isBusy) return 'Off watch, awaiting orders'
428 const list = CAPTAIN_LINES[c.activity]
429 return (list[hash(c.activity + c.detail) % list.length] ?? '').replace('{x}', clip(c.detail, 22) || 'the code')
430}
431
432// ---------- who's who ----------
433
434/**
435 * One character on duty: who it is, and the agent types it stands for in this race, spelled as Claude Code
436 * names them (`Explore`, `general-purpose`). The captain stands for no type: `note` says what he is instead.
437 */
438export type LegendEntry = { character: string; role: string; color: string; types: string[]; note?: string }
439
440/** The characters on duty: the captain, then each kind of crab in the race, in the order they first set out. */
441export function legendOf(crew: readonly LensCrab[]): LegendEntry[] {
442 const out: LegendEntry[] = [{ character: 'captain', role: 'Captain', color: characterOf('captain').color, types: [], note: 'Main thread' }]
443 for (const m of [...crew].sort((a, b) => a.startedAt - b.startedAt)) {
444 const c = characterOf(m.character)
445 const type = bareType(m.type)
446 const have = out.find(e => e.character === c.key)
447 if (!have) out.push({ character: c.key, role: c.role, color: c.color, types: [type] })
448 else if (!have.types.includes(type)) have.types.push(type)
449 }
450 return out
451}
452hooks/track.ts 296 lines1// What the lens keeps count of: the task list after each plan tool call, each tool's uses, each skill's,
2// and how the tools are grouped, sorted and filtered for drawing. Pure functions: no `$` here.
3import type { LensCrab, LensLayout, LensMove, LensPlan, LensSection, LensSkill, LensSort, LensStep, LensTool, LensView } from '../types'
4import { crewOrder } from './crew'
5
6// ---------- layout ----------
7
8/** The layout the first session starts with: To-do and Skills open, Tools closed, its groups collapsed. */
9export const DEFAULT_LAYOUT: LensLayout = { folded: ['tools'], groupsOpen: false, groupExceptions: [], isGrouped: true, sort: 'count', isLegendOpen: false }
10export const DEFAULT_VIEW: LensView = { ...DEFAULT_LAYOUT, filter: '', toggledAt: {}, layoutAt: 0 }
11
12const SECTIONS: LensSection[] = ['todo', 'crew', 'crewWork', 'crewBase', 'skills', 'tools']
13const SORTS: LensSort[] = ['count', 'recent', 'name']
14
15/** What of a view is kept across sessions. */
16export const layoutOf = (v: LensView): LensLayout => ({
17 folded: v.folded,
18 groupsOpen: v.groupsOpen,
19 groupExceptions: v.groupExceptions,
20 isGrouped: v.isGrouped,
21 sort: v.sort,
22 isLegendOpen: v.isLegendOpen,
23})
24
25/** A kept layout as the store hands it back, each field checked; the default where one is missing or wrong. */
26export function parseLayout(raw: unknown): LensLayout {
27 const o = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
28 const strings = (v: unknown) => (Array.isArray(v) ? v.filter((s): s is string => typeof s === 'string') : null)
29 return {
30 folded: (strings(o.folded)?.filter((s): s is LensSection => SECTIONS.includes(s as LensSection)) ?? DEFAULT_LAYOUT.folded),
31 groupsOpen: typeof o.groupsOpen === 'boolean' ? o.groupsOpen : DEFAULT_LAYOUT.groupsOpen,
32 groupExceptions: strings(o.groupExceptions) ?? [],
33 isGrouped: typeof o.isGrouped === 'boolean' ? o.isGrouped : DEFAULT_LAYOUT.isGrouped,
34 sort: SORTS.includes(o.sort as LensSort) ? (o.sort as LensSort) : DEFAULT_LAYOUT.sort,
35 isLegendOpen: typeof o.isLegendOpen === 'boolean' ? o.isLegendOpen : DEFAULT_LAYOUT.isLegendOpen,
36 }
37}
38
39/** A view as `$.state` holds it, filled out; one saved before the layout was kept starts from the defaults. */
40export function viewOf(stored: Partial<LensView> | undefined): LensView {
41 if (!stored || !('groupsOpen' in stored)) return { ...DEFAULT_VIEW, toggledAt: stored?.toggledAt ?? {} }
42 return { ...DEFAULT_VIEW, ...stored }
43}
44
45/** Whether a tool group is open: the groups' default, unless the person turned this one the other way. */
46export const isGroupOpen = (v: LensLayout, key: string) => v.groupsOpen !== v.groupExceptions.includes(key)
47
48export const EMPTY_PLAN: LensPlan = { steps: [], doneBefore: 0, updatedAt: 0 }
49
50/** The desktop app's own task list and its closing call, which ticks every step off. */
51export const DESKTOP_PLAN = 'mcp__ccd_turn__update_plan'
52export const DESKTOP_WRAP_UP = 'mcp__ccd_turn__wrap_up'
53export const PLAN_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', DESKTOP_PLAN, DESKTOP_WRAP_UP])
54
55const STATUSES = new Set(['pending', 'in_progress', 'completed'])
56const statusOf = (v: unknown): LensStep['status'] => (typeof v === 'string' && STATUSES.has(v) ? (v as LensStep['status']) : 'pending')
57const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
58
59export const doneOf = (steps: LensStep[]) => steps.filter(s => s.status === 'completed').length
60
61/** Steps from a list of objects, the text read from the first key that has one. */
62function stepsOf(raw: unknown, textKey: string): LensStep[] {
63 if (!Array.isArray(raw)) return []
64 return raw.flatMap(item => {
65 if (!item || typeof item !== 'object') return []
66 const o = item as Record<string, unknown>
67 const text = str(o[textKey])
68 if (!text) return []
69 const active = str(o.activeForm)
70 return [{ text, status: statusOf(o.status), ...(active ? { active } : {}) }]
71 })
72}
73
74/** 1-based step numbers from a field that holds one or a list. */
75const numbersOf = (v: unknown): number[] => (Array.isArray(v) ? v : [v]).filter((n): n is number => typeof n === 'number' && Number.isInteger(n))
76
77/** The desktop app's plan call: a whole new list, or `advance`, `done` and `start` on the one there is. */
78function desktopPlan(steps: LensStep[], input: Record<string, unknown>): LensStep[] | null {
79 if (Array.isArray(input.items)) return stepsOf(input.items, 'content')
80 let next = steps.map(s => ({ ...s }))
81 let changed = false
82 if (input.advance === true) {
83 const at = next.findIndex(s => s.status === 'in_progress')
84 if (at >= 0) next[at]!.status = 'completed'
85 const following = next.findIndex((s, i) => i > at && s.status === 'pending')
86 if (following >= 0) next[following]!.status = 'in_progress'
87 changed = at >= 0 || following >= 0
88 }
89 for (const n of numbersOf(input.done)) {
90 const step = next[n - 1]
91 if (step) {
92 step.status = 'completed'
93 changed = true
94 }
95 }
96 for (const n of numbersOf(input.start)) {
97 const step = next[n - 1]
98 if (step) {
99 next = next.map(s => (s.status === 'in_progress' ? { ...s, status: 'pending' } : s))
100 next[n - 1] = { ...step, status: 'in_progress' }
101 changed = true
102 }
103 }
104 return changed ? next : null
105}
106
107/**
108 * Carries each step's moments over from the list before: a step found again (by id, else by text) keeps
109 * when it was added, and keeps when it changed unless its text or status differs now.
110 */
111export function stampSteps(old: LensStep[], next: LensStep[], at: number): LensStep[] {
112 const used = new Set<number>()
113 return next.map(s => {
114 let i = s.id ? old.findIndex((o, j) => !used.has(j) && o.id === s.id) : -1
115 if (i < 0) i = old.findIndex((o, j) => !used.has(j) && o.text === s.text)
116 const was = old[i]
117 if (i >= 0) used.add(i)
118 const isSame = was !== undefined && was.status === s.status && was.text === s.text
119 return { ...s, addedAt: was?.addedAt ?? at, changedAt: isSame ? (was.changedAt ?? at) : at }
120 })
121}
122
123/** The plan after one main-conversation call to a plan tool, or null when the call changes nothing. */
124export function planAfter(cur: LensPlan, tool: string, input: Record<string, unknown>, result: unknown, at: number): LensPlan | null {
125 const make = (steps: LensStep[]): LensPlan => ({ steps: stampSteps(cur.steps, steps, at), doneBefore: doneOf(cur.steps), updatedAt: at })
126 if (tool === 'TodoWrite') return make(stepsOf(input.todos, 'content'))
127 if (tool === DESKTOP_PLAN) {
128 const steps = desktopPlan(cur.steps, input)
129 return steps ? make(steps) : null
130 }
131 if (tool === DESKTOP_WRAP_UP) {
132 if (!cur.steps.some(s => s.status !== 'completed')) return null
133 return make(cur.steps.map(s => ({ ...s, status: 'completed' })))
134 }
135 if (tool === 'TaskCreate') {
136 const text = str(input.subject)
137 if (!text) return null
138 const made = (result as { task?: { id?: unknown } } | undefined)?.task?.id
139 const id = typeof made === 'string' || typeof made === 'number' ? String(made) : String(cur.steps.length + 1)
140 const active = str(input.activeForm)
141 return make([...cur.steps, { text, status: 'pending', id, ...(active ? { active } : {}) }])
142 }
143 if (tool === 'TaskUpdate') {
144 const id = typeof input.taskId === 'number' ? String(input.taskId) : str(input.taskId)
145 let at2 = cur.steps.findIndex(s => s.id === id)
146 if (at2 < 0 && /^\d+$/.test(id) && !cur.steps.some(s => s.id)) at2 = Number(id) - 1
147 const old = cur.steps[at2]
148 if (!old) return null
149 if (input.status === 'deleted') return make(cur.steps.filter((_, i) => i !== at2))
150 const text = str(input.subject) || old.text
151 const active = str(input.activeForm) || old.active
152 const status = input.status === undefined ? old.status : statusOf(input.status)
153 return make(cur.steps.map((s, i) => (i === at2 ? { ...s, text, status, ...(active ? { active } : {}) } : s)))
154 }
155 return null
156}
157
158// ---------- tools ----------
159
160/** The server and tool parts of `mcp__<server>__<tool>`; no server for a built-in tool. */
161export function splitTool(name: string): { server: string; tool: string } {
162 if (!name.startsWith('mcp__')) return { server: '', tool: name }
163 const [, server = '', ...rest] = name.split('__')
164 return { server, tool: rest.join('__') || name }
165}
166
167const UUID_ISH = /^[0-9a-f]{8}-[0-9a-f-]{4,}$/i
168
169const titleCase = (s: string) =>
170 s
171 .split(/[-_\s]+/)
172 .filter(Boolean)
173 .map(w => (w.length <= 3 && /^(mcp|cli|api|ai)$/i.test(w) ? w.toUpperCase() : w[0]!.toUpperCase() + w.slice(1)))
174 .join(' ')
175
176/** The name a tool group is shown under: the name /mcp lists, else the server's own name tidied. */
177export function groupLabel(server: string, known: Record<string, string>): string {
178 if (!server) return 'Built-in'
179 const listed = known[server]
180 if (listed) return listed.replace(/^claude\.ai\s+/i, '')
181 if (server.startsWith('ccd_')) return `Desktop app · ${titleCase(server.slice(4))}`
182 if (UUID_ISH.test(server)) return `Connector ${server.slice(0, 4)}`
183 return titleCase(server.replace(/^plugin_[^_]+_/, '').replace(/^claude_ai_/i, '')) || server
184}
185
186/** A tool's own name, tidied for reading: `get_file_contents` reads `get file contents`. */
187export const toolLabel = (name: string) => {
188 const { server, tool } = splitTool(name)
189 return server ? tool.replace(/[-_]+/g, ' ') : tool
190}
191
192export function countTool(tools: Record<string, LensTool>, name: string, isError: boolean, at: number): Record<string, LensTool> {
193 const old = tools[name]
194 return {
195 ...tools,
196 [name]: {
197 name,
198 count: (old?.count ?? 0) + 1,
199 countBefore: old?.count ?? 0,
200 errors: (old?.errors ?? 0) + (isError ? 1 : 0),
201 firstAt: old?.firstAt ?? at,
202 lastAt: at,
203 },
204 }
205}
206
207export function countSkill(skills: Record<string, LensSkill>, name: string, at: number): Record<string, LensSkill> {
208 const old = skills[name]
209 return { ...skills, [name]: { name, count: (old?.count ?? 0) + 1, firstAt: old?.firstAt || at, lastAt: at, isAlways: old?.isAlways ?? false } }
210}
211
212/** The skill folder a SKILL.md path names, either slash. */
213export const skillOfPath = (path: string) => /[\\/]([^\\/]+)[\\/]SKILL\.md$/i.exec(path)?.[1] ?? ''
214
215export function markAlways(skills: Record<string, LensSkill>, names: string[]): Record<string, LensSkill> {
216 const out = { ...skills }
217 for (const name of names) out[name] = { name, count: out[name]?.count ?? 0, firstAt: out[name]?.firstAt ?? 0, lastAt: out[name]?.lastAt ?? 0, isAlways: true }
218 return out
219}
220
221export type ToolRow = LensTool & { label: string; group: string; groupLabel: string }
222export type ToolGroup = { key: string; label: string; count: number; errors: number; firstAt: number; lastAt: number; rows: ToolRow[] }
223
224const bySort = (sort: LensSort) => (a: { label: string; count: number; lastAt: number }, b: { label: string; count: number; lastAt: number }) => {
225 if (sort === 'name') return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
226 if (sort === 'recent') return b.lastAt - a.lastAt || b.count - a.count
227 return b.count - a.count || b.lastAt - a.lastAt || a.label.localeCompare(b.label)
228}
229
230/** Every tool used, labelled, filtered and sorted. */
231export function toolRows(tools: Record<string, LensTool>, servers: Record<string, string>, sort: LensSort, filter: string): ToolRow[] {
232 const needle = filter.trim().toLowerCase()
233 return Object.values(tools)
234 .map(t => {
235 const { server } = splitTool(t.name)
236 return { ...t, label: toolLabel(t.name), group: server || 'builtin', groupLabel: groupLabel(server, servers) }
237 })
238 .filter(r => !needle || [r.label, r.name, r.groupLabel].some(s => s.toLowerCase().includes(needle)))
239 .sort(bySort(sort))
240}
241
242/** The rows in groups by server, built-ins last when sorted by name, the groups in the same order as their rows. */
243export function toolGroups(rows: ToolRow[], sort: LensSort): ToolGroup[] {
244 const groups = new Map<string, ToolGroup>()
245 for (const r of rows) {
246 const g = groups.get(r.group) ?? { key: r.group, label: r.groupLabel, count: 0, errors: 0, firstAt: r.firstAt, lastAt: 0, rows: [] }
247 g.count += r.count
248 g.errors += r.errors
249 g.firstAt = Math.min(g.firstAt, r.firstAt)
250 g.lastAt = Math.max(g.lastAt, r.lastAt)
251 g.rows.push(r)
252 groups.set(r.group, g)
253 }
254 return [...groups.values()].sort(bySort(sort))
255}
256
257/** The skills in the order the pane lists them: most used first, then the always-loaded ones, then by name. */
258export const skillList = (skills: Record<string, LensSkill>): LensSkill[] =>
259 Object.values(skills).sort((a, b) => b.count - a.count || Number(b.isAlways) - Number(a.isAlways) || a.name.localeCompare(b.name))
260
261/**
262 * The order of every list the pane can show, by list key: `crew` (both halves, split by the base mark),
263 * `skills`, `flat`, and `g:<group>` for each server's tools. Unfiltered, so typing in the filter is never
264 * taken for a change of order.
265 */
266export function listOrders(
267 tools: Record<string, LensTool>,
268 skills: Record<string, LensSkill>,
269 servers: Record<string, string>,
270 sort: LensSort,
271 crew: Record<string, LensCrab> = {},
272 now = 0,
273 raceSince = 0,
274): Record<string, string[]> {
275 const rows = toolRows(tools, servers, sort, '')
276 const out: Record<string, string[]> = { crew: crewOrder(crew, now, raceSince), skills: skillList(skills).map(s => s.name), flat: rows.map(r => r.name) }
277 for (const g of toolGroups(rows, sort)) out[`g:${g.key}`] = g.rows.map(r => r.name)
278 return out
279}
280
281const sameOrder = (a: string[], b: string[]) => a.length === b.length && a.every((n, i) => n === b[i])
282
283/** The moves after a change: each list whose order differs from what was last recorded gets a new move at `at`. */
284export function movesAfter(moves: Record<string, LensMove>, orders: Record<string, string[]>, at: number): Record<string, LensMove> | null {
285 let next: Record<string, LensMove> | null = null
286 for (const [key, to] of Object.entries(orders)) {
287 const was = moves[key]?.to ?? []
288 if (sameOrder(was, to)) continue
289 next ??= { ...moves }
290 next[key] = { from: was, to, at }
291 }
292 return next
293}
294
295export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
296hooks/view.tsx 530 lines1// The pane's drawing: three sections that open and close on their own (To-do, Skills, Tools).
2// The desktop draws each row as an animated SVG; the terminal draws the same rows in text.
3import type { Elements, RenderElement } from 'claude-code'
4
5import type { LensCaptain, LensCrab, LensMove, LensPlan, LensSection, LensSkill, LensSort, LensTool, LensView } from '../types'
6import { ACCENT, barShare, groupTileSvg, groupTotalSvg, hueOf, skillListSvg, stepSvg, todoProgressSvg, toolListSvg } from './art'
7import { bandSvg, beachSvg, crewListSvg, legendSvg } from './crabs'
8import { CAPTAIN_NAME, captainQuip, characterOf, crabMeta, crewLists, crewSummary, duration, isOnBeach, legendOf, listTotals, progressLabel, progressOf, quipOf } from './crew'
9import { doneOf, isGroupOpen, plural, skillList, toolGroups, toolRows } from './track'
10import type { ToolGroup, ToolRow } from './track'
11
12type UI = Elements['terminal'] | Elements['desktop'] | Elements['vscode'] | Elements['mobile']
13
14export type Actions = {
15 toggleSection: (id: LensSection) => void
16 toggleGroup: (key: string) => void
17 /** Opens or closes every tool group, and sets how groups seen later start. */
18 setAllGroups: (isOpen: boolean) => void
19 setGrouped: (isGrouped: boolean) => void
20 setSort: (sort: LensSort) => void
21 setFilter: (text: string) => void
22 /** Opens or closes the crew's who's-who legend. */
23 toggleLegend: () => void
24}
25
26export type Ctx = {
27 ui: UI
28 /** Draws pictures: the desktop, the editor and the phone. */
29 isPictured: boolean
30 /** The pane's body, in character columns. */
31 columns: number
32 plan: LensPlan
33 tools: Record<string, LensTool>
34 skills: Record<string, LensSkill>
35 servers: Record<string, string>
36 view: LensView
37 /** Each list's last change of order, so its rows fly to their new places once. */
38 moves: Record<string, LensMove>
39 /** This session's subagents, each played by a crab. */
40 crew: Record<string, LensCrab>
41 /** The main thread, played by the captain. */
42 captain: LensCaptain
43 /** When the current race began: the lists show its crabs alone. */
44 raceSince: number
45 /** The crabs say their lines: bubbles on the beach, last lines in the lists (the `showQuips` setting). */
46 showQuips: boolean
47 /** When this drawing is made: each animation is drawn as it stands at this moment. */
48 now: number
49 act: Actions
50}
51
52const GOOD = '#22a06b'
53const SORTS: { sort: LensSort; label: string }[] = [
54 { sort: 'count', label: 'Most used' },
55 { sort: 'recent', label: 'Recent' },
56 { sort: 'name', label: 'A–Z' },
57]
58
59/** CSS pixels for a pane this many columns wide, and for a row inside a section's frame. */
60const pixels = (columns: number) => Math.max(240, Math.min(900, columns * 8 - 8))
61const rowPixels = (columns: number) => pixels(columns) - 40
62
63/** Text cells a terminal bar takes for a share, as filled and empty blocks. */
64const cells = (share: number, width: number) => {
65 const n = Math.max(share > 0 ? 1 : 0, Math.round(share * width))
66 return { on: '━'.repeat(n), off: '─'.repeat(Math.max(0, width - n)) }
67}
68
69/** When something comes into view: the latest of the moments given (0 for one not known). */
70const latest = (...at: (number | undefined)[]) => Math.max(0, ...at.map(t => t ?? 0))
71
72/** When a section's rows come in: when the pane opened or the section did. */
73const sectionAt = (ctx: Ctx, id: LensSection) => latest(ctx.view.toggledAt.pane, ctx.view.toggledAt[id])
74
75function picture(ctx: Ctx, key: string, d: { svg: string; height: number; alt: string }, width?: number): RenderElement | null {
76 if (!('Svg' in ctx.ui)) return null
77 const { Svg } = ctx.ui
78 return <Svg key={key} source={d.svg} alt={d.alt} width={width} height={d.height} />
79}
80
81/**
82 * A section: a header that opens and closes it, a summary on the right, and its body while open.
83 * A section with nothing in it yet is its header alone.
84 */
85function section(ctx: Ctx, id: LensSection, title: string, right: RenderElement | string | null, body: () => RenderElement[]): RenderElement {
86 const { Box, Text, Button } = ctx.ui
87 const isFolded = ctx.view.folded.includes(id)
88 const rows = isFolded ? [] : body()
89 const isOpen = !isFolded
90 return (
91 <Box key={`sec-${id}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} gap={rows.length ? 1 : 0} flexShrink={0}>
92 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={1}>
93 {/* The arrow is part of the button: a picture beside it could not be pressed. */}
94 <Button key={`sec-${id}`} plain onPress={() => ctx.act.toggleSection(id)}>
95 <Text color={ACCENT}>{isOpen ? '▾ ' : '▸ '}</Text>
96 <Text bold>{title}</Text>
97 </Button>
98 {typeof right === 'string' ? <Text dimColor>{right}</Text> : right}
99 </Box>
100 {rows.length ? rows : null}
101 </Box>
102 )
103}
104
105// ---------- to-do ----------
106
107function todoSection(ctx: Ctx): RenderElement {
108 const { Box, Text } = ctx.ui
109 const { steps, doneBefore } = ctx.plan
110 const done = doneOf(steps)
111 const right = steps.length ? `${done} of ${steps.length}` : null
112 return section(ctx, 'todo', 'To-do', right, () => {
113 if (!steps.length) return []
114 if (ctx.isPictured) {
115 const w = rowPixels(ctx.columns)
116 const opened = sectionAt(ctx, 'todo')
117 return [
118 picture(ctx, 'todo-bar', todoProgressSvg(w, done, steps.length, doneBefore, latest(ctx.plan.updatedAt, opened), ctx.now), w)!,
119 <Box key="todo-steps" flexDirection="column">
120 {steps.map((st, i) => picture(ctx, `step-${i}`, stepSvg(w, st, i, latest(st.addedAt, opened), ctx.now), w))}
121 </Box>,
122 ]
123 }
124 const width = Math.max(8, ctx.columns - 12)
125 const bar = cells(done / steps.length, width)
126 return [
127 <Box key="todo-bar" flexDirection="row" gap={1}>
128 <Text>
129 <Text color={done === steps.length ? GOOD : ACCENT}>{bar.on}</Text>
130 <Text dimColor>{bar.off}</Text>
131 </Text>
132 <Text dimColor>{`${Math.round((done / steps.length) * 100)}%`}</Text>
133 </Box>,
134 <Box key="todo-steps" flexDirection="column">
135 {steps.map((st, i) => {
136 const isDone = st.status === 'completed'
137 const isNow = st.status === 'in_progress'
138 return (
139 <Box key={`step-${i}`} flexDirection="row">
140 <Box width={2} flexShrink={0}>
141 {isDone ? <Text color={GOOD}>✓</Text> : isNow ? <Text color={ACCENT}>●</Text> : <Text dimColor>○</Text>}
142 </Box>
143 <Text wrap="wrap" dimColor={isDone} strikethrough={isDone} bold={isNow}>
144 {isNow && st.active ? st.active : st.text}
145 </Text>
146 </Box>
147 )
148 })}
149 </Box>,
150 ]
151 })
152}
153
154// ---------- crew ----------
155
156const OUTCOME: Record<LensCrab['status'], { glyph: string; color: string }> = {
157 running: { glyph: '●', color: ACCENT },
158 done: { glyph: '✓', color: GOOD },
159 failed: { glyph: '✗', color: '#e5484d' },
160 stopped: { glyph: '■', color: '#8a8984' },
161}
162
163/**
164 * One crab in the terminal: its mark, name (in its colour) and role, its progress or how its run ended,
165 * then its task and its line. The right-hand column keeps its width; a narrow terminal cuts the name and
166 * the line, not the bar or the task.
167 */
168function crabLine(ctx: Ctx, m: LensCrab): RenderElement {
169 const { Box, Text } = ctx.ui
170 const c = characterOf(m.character)
171 const o = OUTCOME[m.status]
172 const bar = cells(progressOf(m).value, 8)
173 return (
174 <Box key={`crab-${m.id}`} flexDirection="column">
175 <Box flexDirection="row" justifyContent="space-between" gap={1}>
176 <Box flexShrink={1} flexGrow={1} overflow="hidden">
177 <Text wrap="truncate-end">
178 <Text color={o.color}>{`${o.glyph} `}</Text>
179 <Text bold color={c.color}>
180 {m.name}
181 </Text>
182 <Text dimColor>{` the ${c.role}`}</Text>
183 </Text>
184 </Box>
185 <Box flexShrink={0}>
186 {isOnBeach(m, ctx.now) ? (
187 <Text>
188 <Text color={c.color}>{bar.on}</Text>
189 <Text dimColor>{bar.off}</Text>
190 <Text bold>{` ${progressLabel(m)}`}</Text>
191 </Text>
192 ) : (
193 <Text dimColor>{`${m.status} · ${duration((m.endedAt ?? ctx.now) - m.startedAt)}`}</Text>
194 )}
195 </Box>
196 </Box>
197 <Box paddingLeft={2} flexDirection="column">
198 <Text dimColor wrap="truncate-end">
199 {crabMeta(m, ctx.now)}
200 </Text>
201 <Text dimColor wrap="truncate-end">
202 {m.description}
203 {ctx.showQuips ? <Text italic>{` “${quipOf(m, ctx.now)}”`}</Text> : null}
204 </Text>
205 </Box>
206 </Box>
207 )
208}
209
210/** A list inside the crew: a title that folds it, like a section's, and the list's totals beside it. */
211function crewListHead(ctx: Ctx, id: 'crewWork' | 'crewBase', label: string, list: LensCrab[]): RenderElement {
212 const { Text, Button } = ctx.ui
213 const isOpen = !ctx.view.folded.includes(id)
214 return (
215 <Button key={`sec-${id}`} plain onPress={() => ctx.act.toggleSection(id)}>
216 <Text color={ACCENT}>{isOpen ? '▾ ' : '▸ '}</Text>
217 <Text bold dimColor>
218 {label}
219 </Text>
220 <Text dimColor>{listTotals(id === 'crewWork' ? 'work' : 'base', list, ctx.now).map(part => ` · ${part}`).join('')}</Text>
221 </Button>
222 )
223}
224
225/**
226 * The crew: the beach, where the captain always is, with the race's crabs at work; then the crabs at work,
227 * a row each; then the crabs that reported back, the latest first. The two lists fold on their own.
228 */
229function crewSection(ctx: Ctx): RenderElement {
230 const { Box, Text, Button } = ctx.ui
231 const { working, base } = crewLists(ctx.crew, ctx.now, ctx.raceSince)
232 const summary = working.length + base.length ? crewSummary(ctx.crew, ctx.raceSince) : ctx.captain.isBusy ? 'Captain at the helm' : 'Captain off watch'
233 const isLegendOpen = ctx.view.isLegendOpen
234 // The summary, and a ? that opens who's who: which character stands for which agent type.
235 const right = (
236 <Box flexDirection="row" gap={1} alignItems="center">
237 <Text dimColor>{summary}</Text>
238 <Button key="crew-legend" plain dimColor={!isLegendOpen} label={isLegendOpen ? '✕ who' : '? who'} onPress={() => ctx.act.toggleLegend()} />
239 </Box>
240 )
241 const legend = legendOf([...working, ...base])
242 const workOpen = !ctx.view.folded.includes('crewWork')
243 const baseOpen = !ctx.view.folded.includes('crewBase')
244 return section(ctx, 'crew', 'Crew', right, () => {
245 const rows: RenderElement[] = []
246 if (ctx.isPictured) {
247 const w = rowPixels(ctx.columns)
248 if (isLegendOpen) rows.push(picture(ctx, 'crew-legend-strip', legendSvg(w, legend, ctx.view.toggledAt.legend ?? 0, ctx.now), w)!)
249 const steps = ctx.plan.steps
250 const skipper = {
251 captain: ctx.captain,
252 done: doneOf(steps),
253 total: steps.length,
254 doneBefore: ctx.plan.doneBefore,
255 changedAt: ctx.plan.updatedAt,
256 }
257 rows.push(picture(ctx, 'crew-beach', beachSvg(w, working, ctx.moves.crew, sectionAt(ctx, 'crew'), ctx.now, skipper, ctx.showQuips), w)!)
258 if (working.length) {
259 rows.push(crewListHead(ctx, 'crewWork', 'At work', working))
260 if (workOpen) rows.push(picture(ctx, 'crew-work', crewListSvg(w, 'work', working, ctx.moves.crew, latest(sectionAt(ctx, 'crew'), ctx.view.toggledAt.crewWork), ctx.now), w)!)
261 }
262 if (base.length) {
263 rows.push(crewListHead(ctx, 'crewBase', 'Reported back', base))
264 if (baseOpen) rows.push(picture(ctx, 'crew-base', crewListSvg(w, 'base', base, ctx.moves.crew, latest(sectionAt(ctx, 'crew'), ctx.view.toggledAt.crewBase), ctx.now, ctx.showQuips), w)!)
265 }
266 return rows
267 }
268 if (isLegendOpen)
269 rows.push(
270 <Text key="crew-legend-strip" wrap="wrap">
271 {legend.map((e, i) => (
272 <Text key={`lg${i}`}>
273 <Text color={e.color}>{'● '}</Text>
274 <Text bold>{e.role}</Text>
275 <Text dimColor>{` ${e.note ?? e.types.join(', ')}${i < legend.length - 1 ? ' ' : ''}`}</Text>
276 </Text>
277 ))}
278 </Text>,
279 )
280 rows.push(
281 <Text key="crew-captain" wrap="truncate-end">
282 <Text color={ACCENT}>{ctx.captain.isBusy ? '(\\/)(°,,°)(\\/) ' : '(\\/)(-,,-)(\\/) '}</Text>
283 <Text bold>{CAPTAIN_NAME}</Text>
284 <Text dimColor>{` · ${ctx.showQuips ? captainQuip(ctx.captain) : ctx.captain.isBusy ? 'at the helm' : 'off watch'}`}</Text>
285 </Text>,
286 )
287 const group = (id: 'crewWork' | 'crewBase', label: string, list: LensCrab[], isOpen: boolean) =>
288 list.length ? (
289 <Box key={id} flexDirection="column">
290 {crewListHead(ctx, id, label, list)}
291 {isOpen ? (
292 <Box flexDirection="column" paddingLeft={1}>
293 {list.map(m => crabLine(ctx, m))}
294 </Box>
295 ) : null}
296 </Box>
297 ) : null
298 for (const g of [group('crewWork', 'At work', working, workOpen), group('crewBase', 'Reported back', base, baseOpen)]) if (g) rows.push(g)
299 return rows
300 })
301}
302
303// ---------- skills ----------
304
305function skillsSection(ctx: Ctx): RenderElement {
306 const { Box, Text } = ctx.ui
307 const list = skillList(ctx.skills)
308 const uses = list.reduce((n, s) => n + s.count, 0)
309 const right = list.length ? plural(uses, 'use') : null
310 return section(ctx, 'skills', 'Skills', right, () => {
311 if (!list.length) return []
312 if (ctx.isPictured) {
313 const w = rowPixels(ctx.columns)
314 return [picture(ctx, 'skill-list', skillListSvg(w, list, ctx.moves.skills, sectionAt(ctx, 'skills'), ctx.now), w)!]
315 }
316 return list.map(s => (
317 <Box key={`skill-${s.name}`} flexDirection="row" justifyContent="space-between" gap={1}>
318 <Text wrap="truncate-end">
319 <Text color={ACCENT}>/</Text>
320 {s.name}
321 </Text>
322 <Text>
323 {s.isAlways ? <Text color="#4c8dff">always loaded</Text> : null}
324 {s.isAlways && s.count ? ' ' : null}
325 {s.count ? <Text dimColor>{plural(s.count, 'use')}</Text> : null}
326 </Text>
327 </Box>
328 ))
329 })
330}
331
332// ---------- tools ----------
333
334function controls(ctx: Ctx, groups: ToolGroup[]): RenderElement {
335 const { Box, Button } = ctx.ui
336 const { view, act } = ctx
337 const filter =
338 'Input' in ctx.ui ? (
339 <ctx.ui.Input key="filter" placeholder="Filter tools" value={view.filter} submitLabel="filter" onInput={v => act.setFilter(v)} onSubmit={v => act.setFilter(v)} />
340 ) : null
341 return (
342 <Box key="tool-controls" flexDirection="column" gap={1}>
343 {filter || view.filter ? (
344 <Box flexDirection="row" gap={1} alignItems="center">
345 <Box flexGrow={1} flexShrink={1}>
346 {filter}
347 </Box>
348 {view.filter ? <Button key="filter-clear" plain dimColor label="✕ Clear" onPress={() => act.setFilter('')} /> : null}
349 </Box>
350 ) : null}
351 <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
352 <Button key="group-on" variant={view.isGrouped ? 'primary' : 'secondary'} label="By server" onPress={() => act.setGrouped(true)} />
353 <Button key="group-off" variant={view.isGrouped ? 'secondary' : 'primary'} label="All tools" onPress={() => act.setGrouped(false)} />
354 {SORTS.map(s => (
355 <Button key={`sort-${s.sort}`} plain dimColor={view.sort !== s.sort} label={`${view.sort === s.sort ? '● ' : ''}${s.label}`} onPress={() => act.setSort(s.sort)} />
356 ))}
357 {view.isGrouped && groups.length ? (
358 groups.some(g => isGroupOpen(view, g.key)) ? (
359 <Button key="groups-all" plain dimColor label="⊟ Collapse all" onPress={() => act.setAllGroups(false)} />
360 ) : (
361 <Button key="groups-all" plain dimColor label="⊞ Expand all" onPress={() => act.setAllGroups(true)} />
362 )
363 ) : null}
364 </Box>
365 </Box>
366 )
367}
368
369/**
370 * A list of tools: one server's, or every tool when the list is flat (`listKey` `flat`). The desktop draws
371 * it as one picture, so a tool that changes place can fly there; the terminal draws a row per tool.
372 */
373function toolList(ctx: Ctx, rows: ToolRow[], showGroup: boolean, listKey: string): RenderElement[] {
374 if (ctx.isPictured) {
375 const w = rowPixels(ctx.columns) - (showGroup ? 0 : 24)
376 const enterAt = (r: ToolRow) => latest(r.firstAt, sectionAt(ctx, 'tools'), ctx.view.layoutAt, showGroup ? 0 : ctx.view.toggledAt[`g:${r.group}`])
377 // While the filter narrows the list, rows come and go with each key: that is no change of order.
378 const canFly = ctx.view.filter.trim() === ''
379 return [picture(ctx, `tools-${listKey}`, toolListSvg(w, rows, showGroup, ctx.moves[listKey], canFly, enterAt, ctx.now), w)!]
380 }
381 return rows.map(r => toolRow(ctx, r, showGroup))
382}
383
384/** One tool in the terminal: its name, its server when the list is flat, a bar and its count. */
385function toolRow(ctx: Ctx, r: ToolRow, showGroup: boolean): RenderElement {
386 const { Box, Text } = ctx.ui
387 const bar = cells(barShare(r.count), 6)
388 return (
389 <Box key={`tool-${r.name}`} flexDirection="row" justifyContent="space-between" gap={1} paddingLeft={showGroup ? 0 : 2}>
390 <Box flexShrink={1} flexGrow={1} overflow="hidden">
391 <Text wrap="truncate-end">
392 {r.label}
393 {showGroup ? <Text dimColor>{` ${r.groupLabel}`}</Text> : null}
394 </Text>
395 </Box>
396 <Box flexShrink={0} flexDirection="row" gap={1}>
397 {r.errors ? <Text color="#e5484d">{`${r.errors}✕`}</Text> : null}
398 <Text>
399 <Text color={hueOf(r.group)}>{bar.on}</Text>
400 <Text dimColor>{bar.off}</Text>
401 </Text>
402 <Text bold>{String(r.count).padStart(3)}</Text>
403 </Box>
404 </Box>
405 )
406}
407
408function groupBlock(ctx: Ctx, g: ToolGroup): RenderElement {
409 const { Box, Text, Button } = ctx.ui
410 const isOpen = isGroupOpen(ctx.view, g.key) || ctx.view.filter.trim() !== ''
411 const enterAt = latest(g.firstAt, sectionAt(ctx, 'tools'), ctx.view.layoutAt)
412 const tile = ctx.isPictured ? picture(ctx, `tile-${g.key}`, groupTileSvg(g.key, g.label, enterAt, ctx.now), 26) : null
413 const total = ctx.isPictured ? picture(ctx, `total-${g.key}`, groupTotalSvg(g.key, g.count, g.errors, g.rows.length, g.count > 1 ? g.lastAt : 0, ctx.now)) : null
414 return (
415 <Box key={`group-${g.key}`} flexDirection="column" gap={ctx.isPictured && isOpen ? 1 : 0}>
416 <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap={1}>
417 <Box flexDirection="row" alignItems="center" gap={1} flexShrink={1}>
418 {tile}
419 <Button key={`grp-${g.key}`} plain onPress={() => ctx.act.toggleGroup(g.key)}>
420 <Text color={hueOf(g.key)}>{isOpen ? '▾ ' : '▸ '}</Text>
421 <Text bold>{g.label}</Text>
422 <Text dimColor>{` ${plural(g.rows.length, 'tool')}`}</Text>
423 </Button>
424 </Box>
425 {total ?? (
426 <Text>
427 {g.errors ? <Text color="#e5484d">{`${g.errors}✕ `}</Text> : null}
428 <Text dimColor>{plural(g.count, 'call')}</Text>
429 </Text>
430 )}
431 </Box>
432 {isOpen ? (
433 <Box flexDirection="column" paddingLeft={ctx.isPictured ? 3 : 0}>
434 {toolList(ctx, g.rows, false, `g:${g.key}`)}
435 </Box>
436 ) : null}
437 </Box>
438 )
439}
440
441function toolsSection(ctx: Ctx): RenderElement {
442 const { Box, Text } = ctx.ui
443 const all = Object.values(ctx.tools)
444 const calls = all.reduce((n, t) => n + t.count, 0)
445 const right = all.length ? `${plural(all.length, 'tool')} · ${plural(calls, 'call')}` : null
446 return section(ctx, 'tools', 'Tools', right, () => {
447 if (!all.length) return []
448 const rows = toolRows(ctx.tools, ctx.servers, ctx.view.sort, ctx.view.filter)
449 const groups = ctx.view.isGrouped ? toolGroups(rows, ctx.view.sort) : []
450 const list = !rows.length ? (
451 <Text key="tools-nomatch" dimColor wrap="wrap">{`No tool matches “${ctx.view.filter.trim()}”.`}</Text>
452 ) : ctx.view.isGrouped ? (
453 <Box key="tool-groups" flexDirection="column" gap={1}>
454 {groups.map(g => groupBlock(ctx, g))}
455 </Box>
456 ) : (
457 <Box key="tool-flat" flexDirection="column">
458 {toolList(ctx, rows, true, 'flat')}
459 </Box>
460 )
461 return [controls(ctx, groups), list]
462 })
463}
464
465export function paneView(ctx: Ctx): RenderElement {
466 const { Box } = ctx.ui
467 return (
468 <Box flexDirection="column" gap={1}>
469 {todoSection(ctx)}
470 {crewSection(ctx)}
471 {skillsSection(ctx)}
472 {toolsSection(ctx)}
473 </Box>
474 )
475}
476
477// ---------- the crew band above the prompt ----------
478
479export type BandCtx = {
480 ui: UI
481 isPictured: boolean
482 columns: number
483 /** The crabs the band shows: those that set out or came back since the last prompt. */
484 shown: LensCrab[]
485 now: number
486 /** Opens the lens at its crew; hides the band until the next crew sets out. */
487 openCrew: () => void
488 dismiss: () => void
489}
490
491/** The band: the crew's crabs, who is at work, and how far along they are, with the way to the lens. */
492export function bandView(b: BandCtx): RenderElement {
493 const { Box, Text, Button } = b.ui
494 const open = <Button key="lens-crew-open" plain label={`Crew ×${b.shown.length}`} onPress={b.openCrew} />
495 const dismiss = <Button key="lens-crew-dismiss" plain role="dismiss" label="✕" onPress={b.dismiss} />
496 if (b.isPictured && 'Svg' in b.ui) {
497 const { Svg } = b.ui
498 const w = Math.max(220, Math.min(1400, b.columns * 8 - 140))
499 const d = bandSvg(w, b.shown, b.now)
500 return (
501 <Box flexDirection="row" alignItems="center" gap={1}>
502 <Svg key="lens-band" source={d.svg} alt={d.alt} width={w} height={d.height} />
503 {open}
504 {dismiss}
505 </Box>
506 )
507 }
508 const working = b.shown.filter(m => m.status === 'running')
509 const mean = b.shown.reduce((s, m) => s + (m.status === 'running' ? progressOf(m).value : 1), 0) / Math.max(1, b.shown.length)
510 const bar = cells(mean, Math.max(6, Math.min(20, b.columns - 60)))
511 // Short, so the bar keeps its room: the first crab out, and how many more.
512 const who = working.length ? `${working[0]!.name}${working.length > 1 ? ` +${working.length - 1}` : ''} at work` : 'All back at base'
513 return (
514 <Box flexDirection="row" gap={1}>
515 <Text color={ACCENT}>{working.length > 1 ? '(°,,°)' : working.length ? '(\\/)(°,,°)(\\/)' : '\\(^,,^)/'}</Text>
516 <Text wrap="truncate-end">
517 {who}
518 <Text dimColor>{` · ${crewSummary(Object.fromEntries(b.shown.map(m => [m.id, m])))}`}</Text>
519 </Text>
520 <Text>
521 <Text color={working.length ? ACCENT : GOOD}>{bar.on}</Text>
522 <Text dimColor>{bar.off}</Text>
523 <Text bold>{` ${working.length ? '≈' : ''}${Math.round(mean * 100)}%`}</Text>
524 </Text>
525 {open}
526 {dismiss}
527 </Box>
528 )
529}
530hooks/art.ts 454 lines1// Animated SVG for the desktop pane, one drawing per row.
2// The pane is redrawn on every event of the session, and a redrawn picture starts its animations over.
3// So no animation here starts when it is drawn: each is pinned to the moment its row changed. A drawing
4// made after that moment shows the animation part-way through (a negative delay) or, once it is over,
5// carries no animation at all and stays still. The loops (the step in progress) keep their phase from
6// one drawing to the next. Every drawing ends in its final state when motion is reduced.
7import type { LensMove, LensSkill, LensStep } from '../types'
8import type { ToolRow } from './track'
9
10export type Drawn = { svg: string; height: number; alt: string }
11
12export const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
13export const f1 = (n: number) => n.toFixed(1)
14
15const STYLE = `<style>
16svg{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
17.ink{fill:#1f1e1c}.dim{fill:#6b6a66}.faint{fill:#a3a19a}.track{fill:#ecebe6}.ring{stroke:#c9c7c0}.chip{fill:#eef3ff}.chipt{fill:#2f5bd3}.chips{stroke:#c9d8ff}
18.pill{fill:#f3f2ee}.pillt{fill:#55534e}.err{fill:#e5484d}.done{fill:#22a06b}.strike{stroke:#a3a19a}.sheen{fill:#ffffff}
19@media (prefers-color-scheme: dark){
20.ink{fill:#ecebe8}.dim{fill:#a8a7a2}.faint{fill:#6f6d68}.track{fill:#34332f}.ring{stroke:#55534e}.chip{fill:#1d2a4a}.chipt{fill:#9db8ff}.chips{stroke:#2f4373}
21.pill{fill:#2e2d2a}.pillt{fill:#cfcdc7}.err{fill:#ff6369}.done{fill:#3fc287}.strike{stroke:#6f6d68}.sheen{fill:#ffffff}}
22@keyframes sl-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
23@keyframes sl-fade{from{opacity:0}to{opacity:1}}
24@keyframes sl-pop{0%{transform:scale(.4);opacity:0}60%{transform:scale(1.18);opacity:1}100%{transform:scale(1)}}
25@keyframes sl-bump{0%{transform:scale(1)}40%{transform:scale(1.35)}100%{transform:scale(1)}}
26@keyframes sl-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}
27@keyframes sl-strike{from{transform:scaleX(0)}to{transform:scaleX(1)}}
28@keyframes sl-halo{0%{transform:scale(1);opacity:.55}100%{transform:scale(2.3);opacity:0}}
29@keyframes sl-spin{to{transform:rotate(360deg)}}
30@keyframes sl-sheen{0%{transform:translateX(-120px)}100%{transform:translateX(var(--sl-run,600px))}}
31.in{animation:sl-in .5s cubic-bezier(.2,.8,.2,1) both}
32.fade{animation:sl-fade .45s ease both}
33.pop{transform-box:fill-box;transform-origin:center;animation:sl-pop .55s cubic-bezier(.3,1.4,.5,1) both}
34.bump{transform-box:fill-box;transform-origin:center;animation:sl-bump .45s ease-out both}
35.draw{animation:sl-draw .45s ease-out both}
36.strk{transform-box:fill-box;transform-origin:left center;animation:sl-strike .5s cubic-bezier(.6,0,.2,1) both}
37.halo{transform-box:fill-box;transform-origin:center;animation:sl-halo 1.8s ease-out infinite}
38.spin{transform-box:fill-box;transform-origin:center;animation:sl-spin 1.1s linear infinite}
39.sheenr{animation:sl-sheen 2.6s cubic-bezier(.4,0,.2,1) infinite}
40@media (prefers-reduced-motion: reduce){*{animation:none!important}.sheenr,.c-spark{display:none}}
41</style>`
42
43/** How long each one-shot animation runs, in seconds, as STYLE sets it. */
44const LENGTH = { in: 0.5, fade: 0.45, pop: 0.55, bump: 0.45, draw: 0.45, strk: 0.5 } as const
45type Once = keyof typeof LENGTH
46
47/** The loops' periods, in seconds, as STYLE sets them. */
48const PERIOD = { halo: 1.8, spin: 1.1, sheenr: 2.6 } as const
49
50/**
51 * A one-shot animation pinned to `at` (ms), `wait` seconds after it: the class and delay that show it at
52 * `now`, or nothing once it is over, or when `at` is unknown (0). Nothing then animates on a redraw.
53 */
54export type Anim = { cls: string; style: string }
55export const STILL: Anim = { cls: '', style: '' }
56
57/** A one-shot animation of class `cls`, `length` seconds long, pinned to `at`: as `once`, for any class. */
58export function onceOf(cls: string, length: number, at: number, now: number, wait = 0): Anim {
59 if (!at) return STILL
60 const since = (now - at) / 1000 - wait
61 if (since >= length) return STILL
62 return { cls, style: `animation-delay:${(-since).toFixed(3)}s;` }
63}
64
65/** A loop of class `cls`, `period` seconds long, at the same point of its cycle in every drawing made at `now`. */
66export const loopOf = (cls: string, period: number, now: number): Anim => ({ cls, style: `animation-delay:${(-((now / 1000) % period)).toFixed(3)}s;` })
67
68export const once = (name: Once, at: number, now: number, wait = 0): Anim => onceOf(name, LENGTH[name], at, now, wait)
69
70const loop = (name: keyof typeof PERIOD, now: number): Anim => loopOf(name, PERIOD[name], now)
71
72/** `class` and `style` attributes for an element: its own classes, then any animation's. */
73export function attrs(base: string, ...anims: { cls: string; style: string }[]): string {
74 const cls = [base, ...anims.map(a => a.cls)].filter(Boolean).join(' ')
75 const style = anims.map(a => a.style).join('')
76 return `${cls ? ` class="${cls}"` : ''}${style ? ` style="${style}"` : ''}`
77}
78
79/** A row comes in a little after the one above it. */
80export const stagger = (index: number) => Math.min(index, 8) * 0.04
81
82/** An SVG document: the shared style, then `extra` (a drawing's own classes), then the body. */
83export const doc = (w: number, h: number, body: string, extra = '') =>
84 `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${STYLE}${extra}${body}</svg>`
85
86/** About how wide a run of text is, in pixels, at a font size: enough to wrap and cut by. */
87export const textWidth = (s: string, size: number) => s.length * size * 0.56
88
89/** Cuts a text to fit a width, ending it with an ellipsis. */
90export function fit(s: string, width: number, size: number): string {
91 const max = Math.max(3, Math.floor(width / (size * 0.56)))
92 return s.length > max ? `${s.slice(0, max - 1)}…` : s
93}
94
95/** Greedy word wrap to a width in pixels. */
96export function wrapText(s: string, width: number, size: number): string[] {
97 const per = Math.max(8, Math.floor(width / (size * 0.56)))
98 const lines: string[] = []
99 let line = ''
100 for (const word of s.replace(/\s+/g, ' ').trim().split(' ')) {
101 let rest = word
102 while (rest.length > per) {
103 if (line) lines.push(line)
104 line = ''
105 lines.push(rest.slice(0, per))
106 rest = rest.slice(per)
107 }
108 if (!rest) continue
109 if (!line) line = rest
110 else if (line.length + 1 + rest.length <= per) line += ` ${rest}`
111 else {
112 lines.push(line)
113 line = rest
114 }
115 }
116 if (line || !lines.length) lines.push(line)
117 return lines
118}
119
120// ---------- colour ----------
121
122/** Mid-tones that read on light and dark; each tool group keeps one by its name. */
123const HUES = ['#8e6cf0', '#4c8dff', '#12a594', '#e2775a', '#d99a1e', '#e5579a', '#3fae5f', '#1ea7c5']
124const BUILT_IN_HUE = '#7c8594'
125export const ACCENT = '#d97757'
126const ACCENT_2 = '#f2a65a'
127
128export function hueOf(group: string): string {
129 if (group === 'builtin') return BUILT_IN_HUE
130 let h = 0
131 for (const c of group) h = (h * 31 + c.charCodeAt(0)) >>> 0
132 return HUES[h % HUES.length]!
133}
134
135/** A lighter tone of a hex colour, for the far end of a gradient. */
136export function lighter(hex: string, by = 0.35): string {
137 const n = parseInt(hex.slice(1), 16)
138 const mix = (c: number) => Math.round(c + (255 - c) * by)
139 const r = mix((n >> 16) & 255)
140 const g = mix((n >> 8) & 255)
141 const b = mix(n & 255)
142 return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`
143}
144
145export const gradient = (id: string, from: string, to: string) =>
146 `<defs><linearGradient id="${id}" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs>`
147
148/** Seconds a bar takes to grow. */
149const GROW = 0.9
150
151/**
152 * A bar filled to `to` (0 to 1) of its track. Within GROW seconds of `at` it is still growing from `from`;
153 * after that it is drawn full and still. A sheen runs along it while `isLive`.
154 */
155export function bar(id: string, x: number, y: number, w: number, h: number, from: number, to: number, fill: string, isLive: boolean, at: number, now: number, wait = 0): string {
156 const full = Math.max(to > 0 ? h : 0, w * Math.min(1, to))
157 const since = at ? (now - at) / 1000 - wait : GROW
158 let grow = ''
159 if (full > 0 && since < GROW) {
160 const start = Math.max(0, Math.min(1, (w * from) / full))
161 grow =
162 `<style>@keyframes ${id}{from{transform:scaleX(${start.toFixed(3)})}to{transform:scaleX(1)}}` +
163 `.${id}{transform-box:fill-box;transform-origin:left center;animation:${id} ${GROW}s cubic-bezier(.25,.9,.25,1) ${(-since).toFixed(3)}s both}</style>`
164 }
165 const clip = `<clipPath id="${id}c"><rect x="${f1(x)}" y="${f1(y)}" width="${f1(full)}" height="${h}" rx="${h / 2}"/></clipPath>`
166 // The skew sits on a wrapper: the animation's own transform would replace one on the rect.
167 const sheen =
168 isLive && full > 24
169 ? `<g clip-path="url(#${id}c)"><g transform="skewX(-20)"><rect class="sheen sheenr" x="${f1(x)}" y="${f1(y - 2)}" width="60" height="${h + 4}" opacity=".35" style="--sl-run:${f1(full + 120)}px;${loop('sheenr', now).style}"/></g></g>`
170 : ''
171 return (
172 `<defs>${clip}${grow}</defs>` +
173 `<rect class="track" x="${f1(x)}" y="${f1(y)}" width="${f1(w)}" height="${h}" rx="${h / 2}"/>` +
174 (full > 0 ? `<rect${grow ? ` class="${id}"` : ''} x="${f1(x)}" y="${f1(y)}" width="${f1(full)}" height="${h}" rx="${h / 2}" fill="${fill}"/>` : '') +
175 sheen
176 )
177}
178
179/** A rounded pill with a number or a word in it; it bumps for a moment after `at`. */
180function pill(x: number, y: number, label: string, at: number, now: number): { svg: string; width: number } {
181 const w = Math.max(22, textWidth(label, 11) + 14)
182 const g = `<g${attrs('', once('bump', at, now))}><rect class="pill" x="${f1(x - w)}" y="${y}" width="${f1(w)}" height="18" rx="9"/><text class="pillt" x="${f1(x - w / 2)}" y="${y + 12.5}" font-size="11" font-weight="600" text-anchor="middle">${esc(label)}</text></g>`
183 return { svg: g, width: w }
184}
185
186// ---------- to-do ----------
187
188/** The list's progress: a bar that grew from the steps done before the last change, and the share done. */
189export function todoProgressSvg(w: number, done: number, total: number, doneBefore: number, at: number, now: number): Drawn {
190 const h = 30
191 const isDone = total > 0 && done === total
192 const label = isDone ? 'All done' : `${Math.round((done / Math.max(1, total)) * 100)}%`
193 const labelW = 64
194 const fill = isDone ? 'url(#slpd)' : 'url(#slpg)'
195 const body =
196 gradient('slpg', ACCENT, ACCENT_2) +
197 gradient('slpd', '#22a06b', '#5fd3a0') +
198 bar('slpb', 2, 12, w - labelW - 10, 7, doneBefore / Math.max(1, total), done / Math.max(1, total), fill, !isDone, at, now) +
199 `<text${attrs(isDone ? 'done' : 'dim', once('fade', at, now))} x="${w - 2}" y="19" font-size="12" font-weight="600" text-anchor="end">${esc(label)}</text>`
200 return { svg: doc(w, h, body), height: h, alt: `${done} of ${total} steps done` }
201}
202
203/**
204 * One step: its mark, then its text wrapped beside it. It comes in after `enterAt` (it joined the list, or
205 * its section opened), and draws its check and strike after its own `changedAt`.
206 */
207export function stepSvg(w: number, step: LensStep, index: number, enterAt: number, now: number): Drawn {
208 const size = 13
209 const lineH = 19
210 const textX = 30
211 const isDone = step.status === 'completed'
212 const isNow = step.status === 'in_progress'
213 const changedAt = step.changedAt ?? 0
214 const text = isNow && step.active ? step.active : step.text
215 const lines = wrapText(text, w - textX - 4, size)
216 const h = Math.max(26, lines.length * lineH + 8)
217 const cy = 14
218 let mark: string
219 if (isDone) {
220 mark =
221 `<g${attrs('', once('pop', changedAt, now))}><circle cx="12" cy="${cy}" r="8.5" class="done"/></g>` +
222 `<path${attrs('', once('draw', changedAt, now, 0.12))} pathLength="1" stroke-dasharray="1" d="M8 ${cy + 0.3} l2.7 2.7 l5.3 -5.6" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`
223 } else if (isNow) {
224 mark =
225 gradient('slsa', ACCENT, ACCENT_2) +
226 `<circle${attrs('', loop('halo', now))} cx="12" cy="${cy}" r="6" fill="${ACCENT}"/>` +
227 `<circle cx="12" cy="${cy}" r="9" fill="none" stroke="${ACCENT}" stroke-opacity=".22" stroke-width="2"/>` +
228 // A whole circle with a dash showing a third of it: its box is the ring's, so it turns on the ring's centre.
229 `<circle${attrs('', loop('spin', now))} cx="12" cy="${cy}" r="9" fill="none" stroke="url(#slsa)" stroke-width="2.2" stroke-linecap="round" pathLength="100" stroke-dasharray="30 70"/>` +
230 `<circle cx="12" cy="${cy}" r="4.2" fill="${ACCENT}"/>`
231 } else {
232 mark = `<circle cx="12" cy="${cy}" r="7.5" fill="none" class="ring" stroke-width="1.6"/>`
233 }
234 const cls = isDone ? 'faint' : isNow ? 'ink' : 'dim'
235 const weight = isNow ? 650 : 450
236 const words = lines
237 .map((l, i) => `<text class="${cls}" x="${textX}" y="${f1(cy + 4.5 + i * lineH)}" font-size="${size}" font-weight="${weight}">${esc(l)}</text>`)
238 .join('')
239 const strikes = isDone
240 ? lines
241 // A tighter guess than for wrapping: a strike that runs past the words shows more than one that stops short.
242 .map(
243 (l, i) =>
244 `<line${attrs('strike', once('strk', changedAt, now, 0.2 + i * 0.08))} x1="${textX}" x2="${f1(textX + Math.min(w - textX - 4, l.length * size * 0.49))}" y1="${f1(cy + i * lineH)}" y2="${f1(cy + i * lineH)}" stroke-width="1.4" stroke-linecap="round"/>`,
245 )
246 .join('')
247 : ''
248 const sheen =
249 isNow && lines.length
250 ? `<defs><clipPath id="slst">${lines.map((l, i) => `<text x="${textX}" y="${f1(cy + 4.5 + i * lineH)}" font-size="${size}" font-weight="${weight}">${esc(l)}</text>`).join('')}</clipPath></defs>` +
251 `<g clip-path="url(#slst)"><g transform="skewX(-20)"><rect class="sheen sheenr" x="${textX}" y="0" width="70" height="${h}" opacity=".55" style="--sl-run:${f1(w + 120)}px;${loop('sheenr', now).style}"/></g></g>`
252 : ''
253 const body = `<g${attrs('', once('in', enterAt, now, stagger(index)))}>${mark}${words}${strikes}${sheen}</g>`
254 const state = isDone ? 'done' : isNow ? 'in progress' : 'to do'
255 return { svg: doc(w, h, body), height: h, alt: `Step ${index + 1}, ${state}: ${text}` }
256}
257
258// ---------- lists that move ----------
259
260/** Seconds a row takes to fly to its new place, and how long its highlight lingers after. */
261const FLY = 0.7
262const GLOW = 0.5
263
264/**
265 * Where a row flies from, for a moment after its list's last change of order: its old slot, or below the
266 * list's end when it has just joined. Null when it stays put, the moment is over, or flights are off.
267 */
268function flightOf(name: string, index: number, rowH: number, move: LensMove | undefined, now: number, canFly: boolean): { dy: number; isNew: boolean; since: number } | null {
269 if (!canFly || !move?.from.length) return null
270 const since = (now - move.at) / 1000
271 if (since >= FLY + GLOW) return null
272 const was = move.from.indexOf(name)
273 const fromIndex = was >= 0 ? was : move.to.length
274 const dy = (fromIndex - index) * rowH
275 return dy === 0 ? null : { dy, isNew: was < 0, since }
276}
277
278/**
279 * A list drawn as one picture, so a row can fly past the others to its new place. Each row is drawn by
280 * `draw`; one in flight goes from its old slot to its new one, its highlight fading as it lands, and is
281 * painted above the rows it passes. One not in flight comes in after its `enterAt`.
282 */
283function listSvg<T extends { name: string }>(
284 w: number,
285 rowH: number,
286 items: T[],
287 move: LensMove | undefined,
288 canFly: boolean,
289 now: number,
290 enterAt: (item: T) => number,
291 hue: (item: T) => string,
292 draw: (item: T, index: number, enterAt: number) => string,
293 defs = '',
294): string {
295 const still: string[] = []
296 // Rows pushed down make room; the row that moved up or joined is the news, lit and painted on top.
297 const pushed: string[] = []
298 const flying: string[] = []
299 items.forEach((item, i) => {
300 const y = i * rowH
301 const f = flightOf(item.name, i, rowH, move, now, canFly)
302 if (!f) {
303 still.push(`<g transform="translate(0 ${y})">${draw(item, i, enterAt(item))}</g>`)
304 return
305 }
306 const id = `slf${i}`
307 const fly =
308 f.since < FLY
309 ? `@keyframes ${id}{from{transform:translateY(${f.dy}px)${f.isNew ? ';opacity:0' : ''}}to{transform:none;opacity:1}}` +
310 `.${id}{animation:${id} ${FLY}s cubic-bezier(.2,.85,.25,1) ${(-f.since).toFixed(3)}s both}`
311 : ''
312 const isNews = f.isNew || f.dy > 0
313 const glow = isNews ? `.${id}h{animation:sl-fade ${(FLY + GLOW).toFixed(2)}s ease-in reverse ${(-f.since).toFixed(3)}s both}` : ''
314 ;(isNews ? flying : pushed).push(
315 `<defs><style>${fly}${glow}</style></defs>` +
316 `<g transform="translate(0 ${y})"><g${fly ? ` class="${id}"` : ''}>` +
317 (isNews ? `<rect class="${id}h" x="-2" y="1" width="${w + 4}" height="${rowH - 2}" rx="8" fill="${hue(item)}" fill-opacity=".16"/>` : '') +
318 // A row in flight needs no entrance of its own: the flight is its entrance.
319 `${draw(item, i, 0)}</g></g>`,
320 )
321 })
322 return doc(w, Math.max(1, items.length * rowH), defs + still.join('') + pushed.join('') + flying.join(''))
323}
324
325// ---------- skills ----------
326
327const SKILL_ROW = 32
328
329/** One skill: a slash tile, its name, an "always loaded" chip and its use count, which bumps on each use. */
330function skillRow(w: number, skill: LensSkill, index: number, enterAt: number, now: number): string {
331 const tile =
332 `<g${attrs('', once('pop', enterAt, now, stagger(index)))}><rect x="2" y="5" width="22" height="22" rx="7" fill="url(#slsk)"/><text x="13" y="21" font-size="14" font-weight="700" fill="#fff" text-anchor="middle">/</text></g>`
333 const uses = skill.count ? pill(w - 2, 7, skill.count === 1 ? '1 use' : `${skill.count} uses`, skill.count > 1 ? skill.lastAt : 0, now) : { svg: '', width: 0 }
334 const chipLabel = 'always loaded'
335 const chipW = textWidth(chipLabel, 10.5) + 14
336 const chipX = w - 2 - uses.width - (uses.width ? 6 : 0) - chipW
337 const chip = skill.isAlways
338 ? `<g><rect class="chip chips" x="${f1(chipX)}" y="7" width="${f1(chipW)}" height="18" rx="9" stroke-width="1"/><text class="chipt" x="${f1(chipX + chipW / 2)}" y="19.5" font-size="10.5" font-weight="600" text-anchor="middle">${chipLabel}</text></g>`
339 : ''
340 const nameRoom = (skill.isAlways ? chipX : w - uses.width - 8) - 36
341 const name = `<text class="ink" x="34" y="20.5" font-size="13" font-weight="550">${esc(fit(skill.name, nameRoom, 13))}</text>`
342 return `<g${attrs('', once('in', enterAt, now, stagger(index)))}>${tile}${name}${chip}${uses.svg}</g>`
343}
344
345const skillAlt = (s: LensSkill) => {
346 const parts = [s.isAlways ? 'always loaded' : '', s.count ? `${s.count} use${s.count === 1 ? '' : 's'}` : ''].filter(Boolean)
347 return `/${s.name}${parts.length ? `, ${parts.join(', ')}` : ''}`
348}
349
350/** Every skill, in order, as one picture: a skill that moves up flies there, a new one flies in from below. */
351export function skillListSvg(w: number, skills: LensSkill[], move: LensMove | undefined, sectionAt: number, now: number): Drawn {
352 const svg = listSvg(
353 w,
354 SKILL_ROW,
355 skills,
356 move,
357 true,
358 now,
359 s => Math.max(s.firstAt ?? 0, sectionAt),
360 () => '#8e6cf0',
361 (s, i, enterAt) => skillRow(w, s, i, enterAt, now),
362 gradient('slsk', '#8e6cf0', '#c4a8ff'),
363 )
364 return { svg, height: Math.max(1, skills.length * SKILL_ROW), alt: skills.map(skillAlt).join('; ') }
365}
366
367// ---------- tools ----------
368
369/** A group's tile: its initials on its hue, popping in after `enterAt`. */
370export function groupTileSvg(group: string, label: string, enterAt: number, now: number): Drawn {
371 const hue = hueOf(group)
372 const initials =
373 group === 'builtin'
374 ? '⌘'
375 : label
376 .replace(/^Desktop app · /, 'D ')
377 .split(/\s+/)
378 .filter(w => /^[A-Za-z0-9]/.test(w))
379 .slice(0, 2)
380 .map(w => w[0]!.toUpperCase())
381 .join('') || '•'
382 const body =
383 gradient('slgt', hue, lighter(hue)) +
384 `<g${attrs('', once('pop', enterAt, now))}><rect x="1" y="1" width="24" height="24" rx="7" fill="url(#slgt)"/><text x="13" y="${initials.length > 1 ? 17 : 18}" font-size="${initials.length > 1 ? 10.5 : 12.5}" font-weight="700" fill="#fff" text-anchor="middle">${esc(initials)}</text></g>`
385 return { svg: doc(26, 26, body), height: 26, alt: `${label} tools` }
386}
387
388/** A group's total, with a small stack of its tools' shares; the total bumps after `at`, its last call. */
389export function groupTotalSvg(group: string, count: number, errors: number, tools: number, at: number, now: number): Drawn {
390 const label = `${count}`
391 const w = Math.max(60, textWidth(label, 11) + 50)
392 const hue = hueOf(group)
393 const dots = Array.from({ length: Math.min(tools, 4) }, (_, i) => `<circle cx="${8 + i * 7}" cy="13" r="3" fill="${hue}" opacity="${1 - i * 0.18}"/>`).join('')
394 const err = errors ? `<circle class="err" cx="${8 + Math.min(tools, 4) * 7 + 2}" cy="13" r="3"/>` : ''
395 const p = pill(w - 1, 4, label, at, now)
396 return { svg: doc(w, 26, `${dots}${err}${p.svg}`), height: 26, alt: `${count} calls${errors ? `, ${errors} failed` : ''}` }
397}
398
399/**
400 * How full a tool's bar is: a log scale that fills at 50 calls. It depends on the tool's own count alone,
401 * so a call to one tool changes that row's bar and no other.
402 */
403export const barShare = (count: number) => Math.min(1, Math.log2(1 + count) / Math.log2(51))
404
405const TOOL_ROW = 36
406
407/**
408 * One tool: its name, its server when the list is flat, its bar and its count. It comes in after
409 * `enterAt`; after each call (`lastAt`) the bar grows from the old count and the count bumps.
410 */
411function toolRow(w: number, row: ToolRow, index: number, showGroup: boolean, enterAt: number, now: number): string {
412 const hue = hueOf(row.group)
413 const id = `slt${index}`
414 const count = String(row.count)
415 const countW = textWidth(count, 12.5) + 4
416 const errLabel = row.errors ? `${row.errors} failed` : ''
417 const errW = errLabel ? textWidth(errLabel, 10.5) + 16 : 0
418 const tagLabel = showGroup ? row.groupLabel : ''
419 const tagW = tagLabel ? Math.min(140, textWidth(tagLabel, 10.5) + 22) : 0
420 const nameRoom = w - countW - errW - tagW - 20
421 const tag = tagLabel
422 ? `<g><circle cx="${f1(w - countW - errW - tagW + 6)}" cy="11" r="3.5" fill="${hue}"/><text class="dim" x="${f1(w - countW - errW - tagW + 13)}" y="14.5" font-size="10.5">${esc(fit(tagLabel, tagW - 20, 10.5))}</text></g>`
423 : ''
424 const err = errLabel
425 ? `<g><circle class="err" cx="${f1(w - countW - errW + 5)}" cy="11" r="3.5"/><text class="err" x="${f1(w - countW - errW + 12)}" y="14.5" font-size="10.5" font-weight="600">${esc(errLabel)}</text></g>`
426 : ''
427 // A row that has just come in grows its bar from empty with the others; a later call grows it from the old count.
428 const entering = once('in', enterAt, now, stagger(index)).cls !== ''
429 const grewAt = entering ? enterAt : row.lastAt
430 const from = entering ? 0 : barShare(row.countBefore)
431 return (
432 `<g${attrs('', once('in', enterAt, now, stagger(index)))}>` +
433 gradient(`${id}g`, hue, lighter(hue)) +
434 `<text class="ink" x="2" y="15" font-size="13" font-weight="550">${esc(fit(row.label, nameRoom, 13))}</text>` +
435 tag +
436 err +
437 `<text${attrs('ink', row.countBefore > 0 ? once('bump', row.lastAt, now) : { cls: '', style: '' })} x="${w - 2}" y="15" font-size="12.5" font-weight="700" text-anchor="end">${count}</text>` +
438 bar(id, 2, 24, w - 4, 5, from, barShare(row.count), `url(#${id}g)`, false, grewAt, now, entering ? 0.1 + stagger(index) : 0) +
439 `</g>`
440 )
441}
442
443const toolAlt = (r: ToolRow, showGroup: boolean) =>
444 `${r.label}${showGroup ? ` (${r.groupLabel})` : ''}: ${r.count} call${r.count === 1 ? '' : 's'}${r.errors ? `, ${r.errors} failed` : ''}`
445
446/**
447 * A list of tools as one picture: a server's tools, or every tool when the list is flat. A tool that
448 * changes place flies there; a new one flies in from below. `canFly` is off while the filter narrows the list.
449 */
450export function toolListSvg(w: number, rows: ToolRow[], showGroup: boolean, move: LensMove | undefined, canFly: boolean, enterAt: (r: ToolRow) => number, now: number): Drawn {
451 const svg = listSvg(w, TOOL_ROW, rows, move, canFly, now, enterAt, r => hueOf(r.group), (r, i, at) => toolRow(w, r, i, showGroup, at, now))
452 return { svg, height: Math.max(1, rows.length * TOOL_ROW), alt: rows.map(r => toolAlt(r, showGroup)).join('; ') }
453}
454hooks/crabs.ts 564 lines1// The crew drawn for the desktop: vector crabs, and the crew's two lists (at work, back at base) as one
2// picture, so a crab can fly from one list to the other and to a new place in its own.
3// As in art.ts, nothing animates from the moment it is drawn: a one-shot (a cheer, a flight, a bar
4// growing) is pinned to when it happened, and a loop (legs, claws, blinks) keeps its phase across drawings.
5import type { LensCaptain, LensCrab, LensMove } from '../types'
6import { ACCENT, STILL, attrs, bar, doc, esc, f1, fit, gradient, lighter, loopOf, once, onceOf, stagger, textWidth } from './art'
7import type { Anim, Drawn } from './art'
8import { BASE_MARK, CAPTAIN_NAME, actionOf, captainPose, captainQuip, characterOf, crabMeta, duration, poseOf, progressLabel, progressOf, quipOf } from './crew'
9import type { Accessory, Character, LegendEntry, Pose } from './crew'
10
11const INK = '#1f1e1c'
12
13const CRAB_STYLE = `<style>
14@keyframes c-legA{0%,100%{transform:rotate(0)}50%{transform:rotate(16deg)}}
15@keyframes c-legB{0%,100%{transform:rotate(16deg)}50%{transform:rotate(0)}}
16@keyframes c-snip{0%,100%{transform:rotate(0)}50%{transform:rotate(-22deg)}}
17@keyframes c-bob{0%,100%{transform:translateY(0)}50%{transform:translateY(-1.3px)}}
18@keyframes c-blink{0%,92%,100%{transform:scaleY(1)}96%{transform:scaleY(.1)}}
19@keyframes c-dot{0%,100%{opacity:.15}50%{opacity:1}}
20@keyframes c-tap{0%,100%{transform:rotate(0)}50%{transform:rotate(-20deg)}}
21@keyframes c-hop{0%{transform:translateY(0)}18%{transform:translateY(-10px)}36%{transform:translateY(0)}54%{transform:translateY(-6px)}72%,100%{transform:translateY(0)}}
22@keyframes c-raise{0%{transform:rotate(0)}20%,70%{transform:rotate(-38deg)}100%{transform:rotate(0)}}
23@keyframes c-spark{0%{transform:scale(0);opacity:1}60%{transform:scale(1);opacity:1}100%{transform:scale(1.35);opacity:0}}
24@keyframes c-swirl{to{transform:rotate(360deg)}}
25@keyframes c-zz{0%{transform:translate(0,0);opacity:0}25%{opacity:1}100%{transform:translate(7px,-11px);opacity:0}}
26@keyframes c-glow{0%,100%{opacity:.55}50%{opacity:1}}
27@keyframes c-wave{from{transform:translateX(-20px)}to{transform:none}}
28.c-legA,.c-legB{transform-box:fill-box;transform-origin:right top;animation:c-legA .45s ease-in-out infinite}
29.c-legB{animation-name:c-legB}
30.c-snip{transform-box:fill-box;transform-origin:left bottom;animation:c-snip .9s ease-in-out infinite}
31.c-bob{animation:c-bob .45s ease-in-out infinite}
32.c-blink{transform-box:fill-box;transform-origin:center;animation:c-blink 4.2s ease-in-out infinite}
33.c-dot{animation:c-dot 1.2s ease-in-out infinite}
34.c-tap{transform-box:fill-box;transform-origin:right top;animation:c-tap .6s ease-in-out infinite}
35.c-hop{animation:c-hop 1.3s cubic-bezier(.3,.7,.4,1) both}
36.c-raise{transform-box:fill-box;transform-origin:right bottom;animation:c-raise 1.3s ease-in-out both}
37.c-spark{transform-box:fill-box;transform-origin:center;animation:c-spark 1s ease-out both}
38.c-swirl{transform-box:fill-box;transform-origin:center;animation:c-swirl 1.6s linear infinite}
39.c-zz{animation:c-zz 2.4s ease-out infinite}
40.c-glow{animation:c-glow 1.4s ease-in-out infinite}
41.c-wave{animation:c-wave 4s linear infinite}
42.sk1{stop-color:#dff0fa}.sk2{stop-color:#eef6f6}.sd1{stop-color:#f5e9cf}.sd2{stop-color:#ecdcb6}.wv1{stroke:#bfe0f0}.wv2{stroke:#d6ecf5}.fp{fill:#c9b48a;opacity:.8}.pole{fill:#8a8984}.bub{fill:#fff;stroke:#d8d5cc;stroke-width:.8}.bubt{fill:#1f1e1c}.bubd{fill:#6b6a66}
43.gchip{fill:#e6f4ea}.gchipt{fill:#1f7a45}.rchip{fill:#fdecec}.rchipt{fill:#c0392b}.schip{fill:#efeeea}.schipt{fill:#6b6a66}
44@media (prefers-color-scheme: dark){.sk1{stop-color:#1b2a33}.sk2{stop-color:#1f2b2e}.sd1{stop-color:#3a3226}.sd2{stop-color:#332b1f}.wv1{stroke:#2c4c5c}.wv2{stroke:#284451}.fp{fill:#6b5a3c}.bub{fill:#2e2d2a;stroke:#4a4843}.bubt{fill:#ecebe8}.bubd{fill:#a8a7a2}.gchip{fill:#1c3326}.gchipt{fill:#6fd39b}.rchip{fill:#3a1f1d}.rchipt{fill:#ff8a80}.schip{fill:#2e2d2a}.schipt{fill:#a8a7a2}}
45</style>`
46
47/** Seconds of the one-shots above, as CRAB_STYLE sets them. */
48const CHEER = 1.3
49const SPARK = 1
50const FLY = 0.8
51const GLOW = 0.5
52
53/** Loops' periods, as CRAB_STYLE sets them. */
54const P = { leg: 0.45, snip: 0.9, bob: 0.45, blink: 4.2, dot: 1.2, tap: 0.6, swirl: 1.6, zz: 2.4, glow: 1.4 } as const
55
56/** A darker tone of a hex colour, for outlines and legs. */
57function darker(hex: string, by = 0.3): string {
58 const n = parseInt(hex.slice(1), 16)
59 const mix = (c: number) => Math.round(c * (1 - by))
60 return `#${((mix((n >> 16) & 255) << 16) | (mix((n >> 8) & 255) << 8) | mix(n & 255)).toString(16).padStart(6, '0')}`
61}
62
63/** A loop run at another pace than its class sets: its period, so its phase still holds across drawings. */
64const faster = (a: Anim, period: number): Anim => ({ cls: a.cls, style: `${a.style}animation-duration:${period}s;` })
65
66const star = (x: number, y: number, r: number, fill: string, a: Anim = STILL) =>
67 `<g transform="translate(${f1(x)} ${f1(y)})"><path${attrs('', a)} d="M0,${-r} L${f1(r * 0.27)},${f1(-r * 0.27)} L${r},0 L${f1(r * 0.27)},${f1(r * 0.27)} L0,${r} L${f1(-r * 0.27)},${f1(r * 0.27)} L${-r},0 L${f1(-r * 0.27)},${f1(-r * 0.27)} Z" fill="${fill}"/></g>`
68
69// ---------- one crab, in a 48 × 44 box ----------
70
71/** The left half: legs, a claw on its arm, an eye on its stalk. The right half is its mirror. */
72function half(c: Character, pose: Pose, now: number, endedAt: number, gid: string): string {
73 const shell = c.color
74 const line = darker(shell)
75 const working = pose === 'work' || pose === 'scurry'
76 // A scurrying crab steps and snips at about twice its pace.
77 const pace = (cls: string, period: number) => (pose === 'scurry' ? faster(loopOf(cls, period / 2, now), period / 2) : loopOf(cls, period, now))
78 const legA = working ? pace('c-legA', P.leg) : STILL
79 const legB = working ? pace('c-legB', P.leg) : STILL
80 const tap = pose === 'wait' ? loopOf('c-tap', P.tap, now) : STILL
81 const legs =
82 `<path${attrs('', legA)} d="M13,29 q-5,1 -8,5" stroke="${line}" stroke-width="2.2" fill="none" stroke-linecap="round"/>` +
83 `<path${attrs('', legB)} d="M14,32 q-5,2 -7,7" stroke="${line}" stroke-width="2.2" fill="none" stroke-linecap="round"/>` +
84 `<path${attrs('', pose === 'wait' ? tap : legA)} d="M16,34 q-3,3 -4,7" stroke="${line}" stroke-width="2.2" fill="none" stroke-linecap="round"/>`
85 const snip = working ? pace('c-snip', P.snip) : STILL
86 const raise = pose === 'done' ? onceOf('c-raise', CHEER, endedAt, now) : STILL
87 const claw =
88 `<g${attrs('', raise)}>` +
89 `<path d="M13,25 q-4,-3 -6,-7.5" stroke="${line}" stroke-width="2.4" fill="none" stroke-linecap="round"/>` +
90 `<path d="M2.5,14 Q3,19.5 9.5,16.5 L7,14 Z" fill="url(#${gid})" stroke="${line}" stroke-width=".7" stroke-linejoin="round"/>` +
91 `<path${attrs('', snip)} d="M2.5,13.5 Q3.5,7.5 10,10.5 L7.2,13.2 Z" fill="${lighter(shell, 0.2)}" stroke="${line}" stroke-width=".7" stroke-linejoin="round"/>` +
92 `</g>`
93 const stalk = `<path d="M19,21 L18,13.5" stroke="${line}" stroke-width="2" stroke-linecap="round"/>`
94 let eye: string
95 if (pose === 'done') eye = `<path d="M15,12 q3,-3.6 6,0" stroke="${INK}" stroke-width="1.7" fill="none" stroke-linecap="round"/>`
96 else if (pose === 'failed') eye = `<path d="M15.6,9.6 l4.8,4.8 M20.4,9.6 l-4.8,4.8" stroke="${INK}" stroke-width="1.6" stroke-linecap="round"/>`
97 else if (pose === 'stopped') eye = `<path d="M15,11.2 q3,2.6 6,0" stroke="${INK}" stroke-width="1.6" fill="none" stroke-linecap="round"/>`
98 else {
99 const look = pose === 'think' ? { x: 18.4, y: 9.9 } : pose === 'wait' ? { x: 16.9, y: 11.3 } : { x: 18.7, y: 11.5 }
100 eye =
101 `<g${attrs('', loopOf('c-blink', P.blink, now))}>` +
102 `<circle cx="18" cy="11" r="3.7" fill="#fff" stroke="${line}" stroke-width=".8"/>` +
103 `<circle cx="${look.x}" cy="${look.y}" r="1.75" fill="${INK}"/><circle cx="${f1(look.x + 0.6)}" cy="${f1(look.y - 0.6)}" r=".55" fill="#fff"/>` +
104 `</g>`
105 }
106 return legs + claw + stalk + eye
107}
108
109/** What the crab wears for its role, drawn over its shell and eyes. */
110function accessory(kind: Accessory, line: string, now: number): string {
111 switch (kind) {
112 case 'bandana':
113 return `<path d="M11.5,25 Q24,15.5 36.5,25 Q24,21.5 11.5,25 Z" fill="#2f3b6b"/><circle cx="36.6" cy="25.4" r="1.6" fill="#2f3b6b"/><path d="M36.8,26 l2.6,3.2 M36.8,26 l3.4,1" stroke="#2f3b6b" stroke-width="1.3" stroke-linecap="round"/>`
114 case 'cap':
115 return `<path d="M15.5,22.2 Q24,12.8 32.5,22.2 Z" fill="#e8c574" stroke="#b8933f" stroke-width=".7"/><path d="M28.5,21.7 q5.5,-1 8.6,1.2" stroke="#b8933f" stroke-width="1.6" fill="none" stroke-linecap="round"/><circle cx="24" cy="15.8" r=".9" fill="#b8933f"/>`
116 case 'tricorn':
117 return `<path d="M12.5,22.2 L24,15 L35.5,22.2 Q24,19.2 12.5,22.2 Z" fill="#1f2a44"/><path d="M14.5,21.4 Q24,18.6 33.5,21.4" stroke="#e0b44a" stroke-width=".9" fill="none"/>`
118 case 'glasses':
119 return `<circle cx="18" cy="11" r="4.8" fill="none" stroke="#2b2a28" stroke-width="1.1"/><circle cx="30" cy="11" r="4.8" fill="none" stroke="#2b2a28" stroke-width="1.1"/><path d="M22.8,11 Q24,9.8 25.2,11" stroke="#2b2a28" stroke-width="1.1" fill="none"/>`
120 case 'antenna':
121 return `<path d="M24,19 L24,9.5" stroke="${line}" stroke-width="1.4" stroke-linecap="round"/><circle${attrs('', loopOf('c-glow', P.glow, now))} cx="24" cy="8" r="2.6" fill="#ffd54a"/>`
122 case 'halo':
123 return `<ellipse cx="24" cy="4.8" rx="7.2" ry="2" fill="none" stroke="#ffd54a" stroke-width="1.7"/>`
124 case 'toque':
125 return `<path d="M18,21.2 h12 v-2.6 q4,-1 2.2,-4.8 q-1.8,-3 -5,-1.1 q-2,-3.6 -6.4,0 q-3.2,-1.9 -5,1.1 q-1.8,3.8 2.2,4.8 z" fill="#ffffff" stroke="#d6d4ce" stroke-width=".8"/>`
126 case 'deerstalker':
127 return `<path d="M15,22.2 Q24,13.4 33,22.2 Z" fill="#8a6a45" stroke="#6b5233" stroke-width=".7"/><path d="M19,17.6 L29,17.6 M17.4,19.8 L30.6,19.8" stroke="#6b5233" stroke-width=".6"/><path d="M33,22 l3.4,1.4 M15,22 l-3.4,1.4" stroke="#6b5233" stroke-width="1.5" stroke-linecap="round"/>`
128 case 'wizard':
129 return `<ellipse cx="24" cy="21.4" rx="9" ry="1.9" fill="#4b3fa8"/><path d="M17.2,21.2 L24.5,5.6 L30.8,21.2 Z" fill="#5b4fc0"/>${star(25.2, 13.5, 1.8, '#ffd54a')}`
130 case 'captain':
131 return `<path d="M14.5,21.6 Q24,12.2 33.5,21.6 Z" fill="#1f2a44"/><path d="M13.5,21.6 h21" stroke="#111a2e" stroke-width="2.2" stroke-linecap="round"/><path d="M28.5,22 q5,.2 8.2,2.2" stroke="#111a2e" stroke-width="1.7" fill="none" stroke-linecap="round"/><circle cx="24" cy="17.4" r="1.9" fill="#e0b44a"/><path d="M15.5,19.6 Q24,15.2 32.5,19.6" stroke="#e0b44a" stroke-width=".7" fill="none"/>`
132 case 'goggles':
133 return `<path d="M13,11 L35,11" stroke="#1d6f8f" stroke-width="1.6"/><circle cx="18" cy="11" r="4.9" fill="#bfe9f7" fill-opacity=".35" stroke="#1d6f8f" stroke-width="1.8"/><circle cx="30" cy="11" r="4.9" fill="#bfe9f7" fill-opacity=".35" stroke="#1d6f8f" stroke-width="1.8"/>`
134 }
135}
136
137/**
138 * A crab in a 48 × 44 box, in its pose: working (legs scuttle, claws snip, eyes blink), thinking (dots
139 * pulse above), waiting (a foot taps), done (a cheer just after it finished, then a happy rest), failed
140 * (crossed eyes, stars circling) or stopped (asleep, z's rising). `gid` keeps its gradient's id unique.
141 */
142export function crabMarkup(m: Pick<LensCrab, 'character' | 'endedAt'>, pose: Pose, now: number, gid: string): string {
143 const c = characterOf(m.character)
144 const line = darker(c.color)
145 const endedAt = m.endedAt ?? 0
146 const left = half(c, pose, now, endedAt, gid)
147 let mouth: string
148 if (pose === 'done') mouth = `<path d="M20.5,30.4 q3.5,3.6 7,0" stroke="${line}" stroke-width="1.5" fill="none" stroke-linecap="round"/>`
149 else if (pose === 'failed') mouth = `<path d="M20.4,32 q1.2,-1.3 2.4,0 q1.2,1.3 2.4,0 q1.2,-1.3 2.4,0" stroke="${line}" stroke-width="1.3" fill="none" stroke-linecap="round"/>`
150 else if (pose === 'think') mouth = `<circle cx="24" cy="31.6" r="1.3" fill="${line}"/>`
151 else if (pose === 'stopped') mouth = `<path d="M22,31.4 h4" stroke="${line}" stroke-width="1.3" stroke-linecap="round"/>`
152 else mouth = `<path d="M21.6,30.8 q2.4,1.7 4.8,0" stroke="${line}" stroke-width="1.3" fill="none" stroke-linecap="round"/>`
153 const body =
154 `<ellipse cx="24" cy="28" rx="13.5" ry="9.6" fill="url(#${gid})" stroke="${line}" stroke-width=".9"/>` +
155 `<ellipse cx="19.4" cy="24.3" rx="5.2" ry="2.1" fill="#fff" opacity=".38" transform="rotate(-12 19.4 24.3)"/>` +
156 mouth
157 let extra = ''
158 if (pose === 'think')
159 extra = [0, 1, 2]
160 .map(i => `<circle${attrs('', loopOf('c-dot', P.dot, now - i * 250))} cx="${37 + i * 4}" cy="${7 - i * 2}" r="${1.3 + i * 0.25}" fill="${line}"/>`)
161 .join('')
162 else if (pose === 'failed')
163 extra = `<g${attrs('', loopOf('c-swirl', P.swirl, now))}>${star(18, 4.5, 2.2, '#ffd54a')}${star(30, 4.5, 2.2, '#ffd54a')}${star(24, 1.8, 1.6, '#ffb84d')}${star(24, 7.6, 1.6, '#ffb84d')}</g>`
164 else if (pose === 'stopped')
165 extra = [0, 1]
166 .map(i => `<text${attrs('', loopOf('c-zz', P.zz, now - i * 1200))} x="${33 + i * 4}" y="${12 - i * 3}" font-size="${8 - i * 1.5}" font-weight="700" fill="${line}">z</text>`)
167 .join('')
168 else if (pose === 'done')
169 extra = [
170 [5, 5],
171 [43, 7],
172 [3, 29],
173 [45, 28],
174 ]
175 // Sparkles burst once, while the crab cheers; once they are over they are not drawn at all.
176 .map(([x, y], i) => ({ x: x!, y: y!, i, burst: onceOf('c-spark', SPARK, endedAt, now, 0.15 + i * 0.07) }))
177 .filter(s => s.burst.cls)
178 .map(s => star(s.x, s.y, 2.6, s.i % 2 ? '#ffd54a' : c.color, s.burst))
179 .join('')
180 const working = pose === 'work' || pose === 'scurry'
181 const hop =
182 pose === 'done'
183 ? onceOf('c-hop', CHEER, endedAt, now)
184 : pose === 'scurry'
185 ? faster(loopOf('c-bob', P.bob / 2, now), P.bob / 2)
186 : working
187 ? loopOf('c-bob', P.bob, now)
188 : STILL
189 return (
190 gradient(gid, lighter(c.color, 0.3), c.color) +
191 `<ellipse cx="24" cy="41" rx="13" ry="2.2" fill="#000" fill-opacity=".14"/>` +
192 `<g${attrs('', hop)}>` +
193 left +
194 `<g transform="translate(48 0) scale(-1 1)">${left}</g>` +
195 body +
196 accessory(c.accessory, line, now) +
197 `</g>` +
198 extra
199 )
200}
201
202/** One crab on its own, 48 × 44: for a crab drawn outside the crew list. */
203export function crabSvg(m: Pick<LensCrab, 'character' | 'endedAt'>, pose: Pose, now: number, alt: string): Drawn {
204 return { svg: doc(48, 44, crabMarkup(m, pose, now, 'cg'), CRAB_STYLE), height: 44, alt }
205}
206
207// ---------- the crew: the beach, and a picture per list ----------
208
209/**
210 * Heights: a lane of the beach; the beach's sky and waves above its lanes, and the sand below them;
211 * a working crab's row; a crab's row among those reported back.
212 */
213const ROW = { lane: 38, sky: 24, shore: 10, work: 42, base: 60, baseQuiet: 46 } as const
214
215/** How tall the beach is for this many lanes: the captain's, and one per crab on it. */
216const beachH = (lanes: number) => ROW.sky + lanes * ROW.lane + ROW.shore
217
218/** A crew order's two halves: the crabs on the beach, and the ones that reported back. */
219function splitOrder(order: readonly string[]): { work: string[]; base: string[] } {
220 const cut = order.indexOf(BASE_MARK)
221 return cut < 0 ? { work: [...order], base: [] } : { work: order.slice(0, cut), base: order.slice(cut + 1) }
222}
223
224/** Seconds a crab takes to walk to where its progress has got to. */
225const WALK = 1.1
226
227/** A speech bubble beside a crab, or a dashed thought bubble with dots that blink in turn. */
228function bubbleAt(x: number, y: number, text: string, isLeft: boolean, isThought: boolean, maxW: number, pop: Anim, now: number): string {
229 const size = 10.5
230 const dotsW = isThought ? 15 : 0
231 const shown = fit(text, Math.max(30, maxW - 14 - dotsW), size)
232 const bw = textWidth(shown, size) + 14 + dotsW
233 const bx = isLeft ? x - bw : x
234 const tail = isThought
235 ? `<circle class="bub" cx="${f1(isLeft ? bx + bw + 3 : bx - 3)}" cy="${y + 12}" r="1.8"/>`
236 : `<path class="bub" d="M${f1(isLeft ? bx + bw - 1 : bx + 1)},${y + 6} L${f1(isLeft ? bx + bw + 6 : bx - 6)},${y + 11} L${f1(isLeft ? bx + bw - 1 : bx + 1)},${y + 13} Z"/>`
237 const dots = isThought
238 ? [0, 1, 2].map(i => `<circle${attrs('bubd', loopOf('c-dot', P.dot, now - i * 250))} cx="${f1(bx + bw - dotsW + i * 4.5 + 1)}" cy="${y + 9}" r="1.3"/>`).join('')
239 : ''
240 return (
241 `<g${attrs('', pop)}>` +
242 `<rect class="bub" x="${f1(bx)}" y="${y}" width="${f1(bw)}" height="18" rx="9"${isThought ? ' stroke-dasharray="3 2"' : ''}/>` +
243 tail +
244 `<text class="bubt" x="${f1(bx + 7)}" y="${y + 12.6}" font-size="${size}"${isThought ? ' font-style="italic"' : ''}>${esc(shown)}</text>` +
245 dots +
246 `</g>`
247 )
248}
249
250/** The main thread on the beach: the captain, and how far along Claude's own to-do list is. */
251export type Skipper = { captain: LensCaptain; done: number; total: number; doneBefore: number; changedAt: number }
252
253/** Someone walking a lane: the captain or a crab. */
254type Walker = {
255 key: string
256 look: Pick<LensCrab, 'character' | 'endedAt'>
257 pose: Pose
258 /** How far along, 0 to 1, where it was before it last moved, and when that was. */
259 progress: number
260 before: number
261 movedAt: number
262 /** Done and standing past its flag, for a moment before it reports back. */
263 isPast: boolean
264 line: string
265 saidAt: number
266}
267
268const crabWalker = (m: LensCrab, now: number): Walker => ({
269 key: m.id,
270 look: m,
271 pose: poseOf(m, now),
272 progress: progressOf(m).value,
273 before: Math.min(1, m.progressBefore),
274 movedAt: m.progressAt,
275 isPast: m.status === 'done',
276 line: quipOf(m, now),
277 saidAt: m.lastActiveAt,
278})
279
280const captainWalker = (s: Skipper): Walker => ({
281 key: 'captain',
282 look: { character: 'captain' },
283 pose: captainPose(s.captain),
284 progress: s.total ? s.done / s.total : 0,
285 before: s.total ? s.doneBefore / s.total : 0,
286 movedAt: s.changedAt,
287 isPast: false,
288 line: captainQuip(s.captain),
289 saidAt: s.captain.lastActiveAt,
290})
291
292/**
293 * The beach: one scene with a lane each, the captain's first. Each walks its lane toward its flag as its
294 * progress climbs (the captain's is Claude's own to-do list), footprints behind it, with its line in a
295 * bubble beside it. A crab that is done walks past its flag and stands there, cheering, until it reports
296 * back. A step is pinned to when the progress moved; a lane that changes place glides there, and a new
297 * one fades in.
298 */
299function beachMarkup(w: number, walkers: Walker[], oldLanes: Map<string, number> | null, since: number, now: number, showQuips: boolean): string {
300 const h = beachH(walkers.length)
301 const flagX = w - 50
302 const laneW = flagX - 40
303 const sky =
304 `<defs><linearGradient id="bsky" x1="0" x2="0" y1="0" y2="1"><stop offset="0" class="sk1"/><stop offset="${f1(ROW.sky / h)}" class="sk2"/><stop offset="${f1((ROW.sky + 4) / h)}" class="sd1"/><stop offset="1" class="sd2"/></linearGradient>` +
305 `<clipPath id="bclip"><rect x="0" y="0" width="${w}" height="${h}" rx="12"/></clipPath></defs>`
306 const wave = (y: number, cls: string, period: number, a: number) => {
307 let d = `M-40,${y}`
308 for (let x = -40; x < w + 40; x += 20) d += ` q5,${-a} 10,0 q5,${a} 10,0`
309 return `<path${attrs(cls, loopOf('c-wave', period, now))} d="${d}" fill="none" stroke-width="2" stroke-linecap="round"/>`
310 }
311 const lanes = walkers
312 .map((m, i) => {
313 const c = characterOf(m.look.character)
314 const top = ROW.sky + i * ROW.lane
315 const x = m.isPast ? flagX + 8 : 4 + m.progress * laneW
316 const fromX = 4 + m.before * laneW
317 const walkSince = (now - m.movedAt) / 1000
318 const id = `cw${i}`
319 const walking =
320 m.movedAt && walkSince < WALK && Math.abs(fromX - x) > 0.5
321 ? `<style>@keyframes ${id}{from{transform:translateX(${f1(fromX - x)}px)}to{transform:none}}.${id}{animation:${id} ${WALK}s cubic-bezier(.3,.8,.3,1) ${(-walkSince).toFixed(3)}s both}</style>`
322 : ''
323 const prints: string[] = []
324 for (let fx = 8, k = 0; fx < x + 2; fx += 10, k++) prints.push(`<rect class="fp" x="${fx}" y="${top + 31 + (k % 2)}" width="3" height="1.6" rx=".8"/>`)
325 const flag = `<rect class="pole" x="${flagX}" y="${top + 8}" width="1.6" height="26"/><path d="M${flagX + 1.6},${top + 8} l9,4.5 l-9,4.5 Z" fill="${c.color}"/>`
326 const crab = `<g transform="translate(${f1(x)} ${top + 2}) scale(.72)">${crabMarkup(m.look, m.pose, now, `bc${i}`)}</g>`
327 const roomRight = flagX - (x + 40)
328 // The bubble goes where the line fits: right of the crab, else left if there is more room there.
329 const need = textWidth(m.line, 10.5) + 30
330 const isLeft = m.isPast || (need > roomRight && x - 6 > roomRight)
331 const bubble = showQuips ? bubbleAt(isLeft ? x - 2 : x + 38, top + 6, m.line, isLeft, m.pose === 'think', isLeft ? x - 6 : roomRight, once('pop', m.saidAt, now), now) : ''
332 // A lane that changed place glides there with its crab; a new lane fades in. The captain's stays first.
333 const was = i === 0 ? 0 : oldLanes?.get(m.key)
334 const glide = oldLanes && since < FLY ? (was === undefined ? 'new' : was !== i ? (was - i) * ROW.lane : 0) : 0
335 const lid = `cl${i}`
336 const laneStyle =
337 glide === 'new'
338 ? `<style>.${lid}{animation:sl-fade ${FLY}s ease ${(-since).toFixed(3)}s both}</style>`
339 : glide
340 ? `<style>@keyframes ${lid}{from{transform:translateY(${glide}px)}to{transform:none}}.${lid}{animation:${lid} ${FLY}s cubic-bezier(.25,.8,.25,1) ${(-since).toFixed(3)}s both}</style>`
341 : ''
342 return `${walking}${laneStyle}<g${laneStyle ? ` class="${lid}"` : ''}>${prints.join('')}${flag}<g${walking ? ` class="${id}"` : ''}>${crab}</g>${bubble}</g>`
343 })
344 .join('')
345 return (
346 sky +
347 `<g clip-path="url(#bclip)"><rect x="0" y="0" width="${w}" height="${h}" fill="url(#bsky)"/>` +
348 wave(ROW.sky - 3, 'wv1', 4, 2.4) +
349 wave(ROW.sky + 1, 'wv2', 6, 1.8) +
350 lanes +
351 `</g>`
352 )
353}
354
355/** A name, its role in its colour, and the task after it: the first line of a crab's row. */
356function crabTitle(x: number, y: number, m: LensCrab, maxW: number): string {
357 const c = characterOf(m.character)
358 const name = fit(m.name, 140, 13)
359 const taskW = maxW - textWidth(`${name} the ${c.role} · `, 13)
360 const task = taskW > 30 && m.description ? `<tspan class="dim" font-size="12"> · ${esc(fit(m.description, taskW, 12))}</tspan>` : ''
361 return `<text x="${x}" y="${y}" font-size="13"><tspan class="ink" font-weight="650">${esc(name)}</tspan><tspan fill="${c.color}" font-weight="650"> the ${esc(c.role)}</tspan>${task}</text>`
362}
363
364/** A crab's details in the room there is: its action is cut first, so its calls and tokens show. */
365function metaText(m: LensCrab, now: number, maxW: number, size: number): string {
366 if (m.status !== 'running') return fit(crabMeta(m, now), maxW, size)
367 const room = maxW - textWidth(`${crabMeta(m, now, '')} · `, size)
368 return fit(crabMeta(m, now, fit(actionOf(m, now), Math.max(40, room), size)), maxW, size)
369}
370
371/**
372 * A crab at work, under the beach: its name, role and task, and how long it has been out; under them its
373 * model, what it is doing, its calls and tokens, and its progress.
374 */
375function workRow(w: number, m: LensCrab, now: number): string {
376 const c = characterOf(m.character)
377 const label = progressLabel(m)
378 const time = duration(now - m.startedAt)
379 return (
380 `<circle cx="6" cy="13" r="4" fill="${c.color}"/>` +
381 crabTitle(16, 17, m, w - 16 - textWidth(time, 10.5) - 10) +
382 `<text class="faint" x="${w - 2}" y="17" font-size="10.5" text-anchor="end">${esc(time)}</text>` +
383 `<text class="dim" x="16" y="34" font-size="11">${esc(metaText(m, now, w - 16 - textWidth(label, 12) - 12, 11))}</text>` +
384 `<text x="${w - 2}" y="34" font-size="12" font-weight="700" fill="${c.color}" text-anchor="end">${esc(label)}</text>`
385 )
386}
387
388const CHIP: Record<Exclude<LensCrab['status'], 'running'>, { label: string; cls: string }> = {
389 done: { label: 'done', cls: 'g' },
390 failed: { label: 'failed', cls: 'r' },
391 stopped: { label: 'stopped', cls: 's' },
392}
393
394/**
395 * A crab back at base: a smaller crab, its name, role and task, and how its run ended; under them how long
396 * it took, its model, calls and tokens; then its last line.
397 */
398function baseRow(w: number, m: LensCrab, i: number, now: number, showQuips: boolean): string {
399 const x0 = 54
400 const chip = CHIP[m.status === 'running' ? 'done' : m.status]
401 const chipW = textWidth(chip.label, 10.5) + 14
402 return (
403 `<g transform="translate(9 10) scale(.78)">${crabMarkup(m, poseOf(m, now), now, `cg${i}`)}</g>` +
404 crabTitle(x0, 19, m, w - x0 - chipW - 10) +
405 `<g${attrs('', once('pop', m.endedAt ?? 0, now, 0.5))}><rect class="${chip.cls}chip" x="${f1(w - 2 - chipW)}" y="7" width="${f1(chipW)}" height="17" rx="8.5"/><text class="${chip.cls}chipt" x="${f1(w - 2 - chipW / 2)}" y="19" font-size="10.5" font-weight="650" text-anchor="middle">${chip.label}</text></g>` +
406 `<text class="dim" x="${x0}" y="36" font-size="11">${esc(metaText(m, now, w - x0 - 4, 11))}</text>` +
407 (showQuips ? `<text class="faint" x="${x0}" y="52" font-size="11" font-style="italic">${esc(fit(`“${quipOf(m, now)}”`, w - x0 - 4, 11))}</text>` : '')
408 )
409}
410
411const IDLE_SKIPPER: Skipper = { captain: { isBusy: false, activity: 'other', detail: '', lastActiveAt: 0 }, done: 0, total: 0, doneBefore: 0, changedAt: 0 }
412
413const crabAlt = (m: LensCrab, now: number) =>
414 `${m.name} the ${characterOf(m.character).role}, ${m.status === 'running' ? `${progressLabel(m)}, ` : `${m.status}, `}${crabMeta(m, now)}`
415
416/** For a moment after the crew's order last changed: how long ago that was, and the order before it. */
417function lastMove(move: LensMove | undefined, now: number): { since: number; before: { work: string[]; base: string[] } } | null {
418 if (!move?.from.length) return null
419 const since = (now - move.at) / 1000
420 return since < FLY + GLOW ? { since, before: splitOrder(move.from) } : null
421}
422
423/**
424 * The beach as a picture: the captain's lane, then one per crab on it. For a moment after the crew's
425 * order changed, a lane that moved glides to its new place and a new one fades in. It comes in after
426 * `enterAt` (its section opened).
427 */
428export function beachSvg(w: number, working: LensCrab[], move: LensMove | undefined, enterAt: number, now: number, skipper: Skipper = IDLE_SKIPPER, showQuips = true): Drawn {
429 const last = lastMove(move, now)
430 const oldLanes = last ? new Map(last.before.work.map((id, i) => [id, i + 1])) : null
431 const walkers = [captainWalker(skipper), ...working.map(m => crabWalker(m, now))]
432 const h = beachH(walkers.length)
433 const body = `<g${attrs('', once('fade', enterAt, now))}>${beachMarkup(w, walkers, oldLanes, last?.since ?? Infinity, now, showQuips)}</g>`
434 // Their lines, or, with the crabs quiet, who is on the beach and how far each has got.
435 const alt = showQuips
436 ? [`${CAPTAIN_NAME}: ${captainQuip(skipper.captain)}`, ...working.map(m => `${m.name}: ${quipOf(m, now)}`)].join('. ')
437 : [`${CAPTAIN_NAME}, ${skipper.captain.isBusy ? 'at the helm' : 'off watch'}`, ...working.map(m => `${m.name}, ${progressLabel(m)}`)].join('. ')
438 return { svg: doc(w, h, body, CRAB_STYLE), height: h, alt }
439}
440
441/**
442 * One list of the crew as a picture: the crabs at work (a row each under the beach), or those that
443 * reported back. For a moment after the crew's order changed, a row that moved within the list flies to
444 * its new place; a crab new to the list flies in, lit (at the foot of those at work, or from just under
445 * its title into the top of those back) and is painted above the rest. Rows not in flight come in after
446 * `enterAt` (the list or its section opened).
447 */
448export function crewListSvg(w: number, kind: 'work' | 'base', list: LensCrab[], move: LensMove | undefined, enterAt: number, now: number, showQuips = true): Drawn {
449 const last = lastMove(move, now)
450 const before = last ? last.before[kind] : null
451 const h = kind === 'work' ? ROW.work : showQuips ? ROW.base : ROW.baseQuiet
452 const still: string[] = []
453 const pushed: string[] = []
454 const flying: string[] = []
455 list.forEach((m, i) => {
456 const y = i * h
457 const row = kind === 'work' ? workRow(w, m, now) : baseRow(w, m, i, now, showQuips)
458 const was = before ? before.indexOf(m.id) : i
459 const isNew = before !== null && was < 0
460 // A crab new to the list comes from below its last row (at work), or from just above its first (back).
461 const oldY = !before ? y : isNew ? (kind === 'work' ? list.length * h : -h) : was * h
462 const dy = oldY - y
463 if (!last || dy === 0) {
464 still.push(`<g transform="translate(0 ${y})"><g${attrs('', once('in', enterAt, now, stagger(i)))}>${row}</g></g>`)
465 return
466 }
467 const since = last.since
468 const id = `cf${i}`
469 // A slight lift at mid-flight, so a crab reads as flying rather than sliding.
470 const fly =
471 since < FLY
472 ? `@keyframes ${id}{0%{transform:translateY(${dy}px)${isNew ? ';opacity:0' : ''}}55%{transform:translateY(${f1(dy * 0.3 - (isNew ? 5 : 0))}px);opacity:1}100%{transform:none;opacity:1}}` +
473 `.${id}{animation:${id} ${FLY}s cubic-bezier(.25,.8,.25,1) ${(-since).toFixed(3)}s both}`
474 : ''
475 const glow = isNew ? `.${id}h{animation:sl-fade ${(FLY + GLOW).toFixed(2)}s ease-in reverse ${(-since).toFixed(3)}s both}` : ''
476 ;(isNew ? flying : pushed).push(
477 `<defs><style>${fly}${glow}</style></defs>` +
478 `<g transform="translate(0 ${y})"><g${fly ? ` class="${id}"` : ''}>` +
479 (isNew ? `<rect class="${id}h" x="-2" y="1" width="${w + 4}" height="${h - 2}" rx="10" fill="${characterOf(m.character).color}" fill-opacity=".14"/>` : '') +
480 `${row}</g></g>`,
481 )
482 })
483 const height = Math.max(1, list.length * h)
484 const alt = `${kind === 'work' ? 'At work' : 'Reported back'}: ${list.map(m => crabAlt(m, now)).join('; ')}`
485 return { svg: doc(w, height, still.join('') + pushed.join('') + flying.join(''), CRAB_STYLE), height, alt }
486}
487
488// ---------- the band above the prompt ----------
489
490export const BAND_H = 32
491
492/**
493 * The crew band: the crabs out since the last prompt (at work first), who is at work, and one bar of the
494 * crew's progress, a finished crab counting as done. The bar grows from where it was at the crew's last
495 * change; it shimmers while anyone is still out.
496 */
497export function bandSvg(w: number, shown: readonly LensCrab[], now: number): Drawn {
498 const working = shown.filter(m => m.status === 'running')
499 const valueOf = (m: LensCrab) => (m.status === 'running' ? progressOf(m).value : 1)
500 const at = Math.max(0, ...shown.map(m => m.progressAt))
501 const mean = shown.reduce((s, m) => s + valueOf(m), 0) / Math.max(1, shown.length)
502 const before = shown.reduce((s, m) => s + (m.progressAt === at ? m.progressBefore : valueOf(m)), 0) / Math.max(1, shown.length)
503 const room = Math.max(1, Math.floor((w * 0.32) / 30))
504 const faces = [...working, ...shown.filter(m => m.status !== 'running')].slice(0, room)
505 const crabs = faces.map((m, i) => `<g transform="translate(${i * 30} 3) scale(.6)">${crabMarkup(m, poseOf(m, now), now, `bg${i}`)}</g>`).join('')
506 const x0 = faces.length * 30 + 8
507 const back = shown.length - working.length
508 const who = working.length
509 ? `${working
510 .slice(0, 3)
511 .map(m => m.name)
512 .join(', ')}${working.length > 3 ? ` +${working.length - 3}` : ''} at work${back ? ` · ${back} back` : ''}`
513 : `All back at base · ${back}`
514 const barW = Math.max(48, Math.min(170, w * 0.24))
515 const pctW = 40
516 const barX = w - pctW - barW
517 const label = `${working.length ? '≈' : ''}${Math.round(mean * 100)}%`
518 const color = working.length ? ACCENT : '#22a06b'
519 const body =
520 crabs +
521 `<text class="ink" x="${x0}" y="20.5" font-size="12.5" font-weight="550">${esc(fit(who, barX - x0 - 10, 12.5))}</text>` +
522 gradient('bbg', color, lighter(color)) +
523 bar('bbar', barX, 13, barW, 7, before, mean, 'url(#bbg)', working.length > 0, at, now) +
524 `<text${attrs('dim', once('bump', at, now))} x="${w - 2}" y="20.5" font-size="11.5" font-weight="650" text-anchor="end">${esc(label)}</text>`
525 return {
526 svg: doc(w, BAND_H, body, CRAB_STYLE),
527 height: BAND_H,
528 alt: `Crew: ${who}, ${label} along`,
529 }
530}
531
532// ---------- who's who ----------
533
534/**
535 * The characters on duty as a strip of small cards: each one's crab, its role, and the agent types it
536 * stands for. The cards wrap to as many rows as the width needs, and pop in when the legend opens (`openedAt`).
537 */
538export function legendSvg(w: number, entries: readonly LegendEntry[], openedAt: number, now: number): Drawn {
539 const cellW = 150
540 const cellH = 34
541 const perRow = Math.max(1, Math.floor(w / cellW))
542 const cells = entries
543 .map((e, i) => {
544 const x = (i % perRow) * cellW
545 const y = Math.floor(i / perRow) * cellH
546 // Agent types are names, shown as code and spelled as they are; the captain's note is plain words.
547 const under = e.note
548 ? `<text class="dim" x="34" y="28" font-size="10.5">${esc(e.note)}</text>`
549 : `<text class="dim" x="34" y="28" font-size="10" font-family="ui-monospace,SFMono-Regular,Consolas,monospace">${esc(fit(e.types.join(', '), cellW - 40, 10.5))}</text>`
550 return (
551 `<g transform="translate(${x} ${y})"><g${attrs('', once('pop', openedAt, now, stagger(i)))}>` +
552 `<g transform="translate(0 2) scale(.62)">${crabMarkup({ character: e.character }, 'work', 0, `lg${i}`)}</g>` +
553 `<text x="34" y="14" font-size="12" font-weight="650" fill="${e.color}">${esc(e.role)}</text>` +
554 under +
555 `</g></g>`
556 )
557 })
558 .join('')
559 const height = Math.max(1, Math.ceil(entries.length / perRow) * cellH)
560 // A still crab here: the legend says who is who, the beach does the walking.
561 const still = cells.replace(/ class="c-(legA|legB|snip|bob|blink)" style="[^"]*"/g, '')
562 return { svg: doc(w, height, still, CRAB_STYLE), height, alt: `Who's who: ${entries.map(e => `${e.role} = ${e.note ?? e.types.join(', ')}`).join('; ')}` }
563}
564types/index.d.ts 168 lines1/** One step of Claude's task list. */
2export type LensStep = {
3 text: string
4 status: 'pending' | 'in_progress' | 'completed'
5 /** The "doing now" wording, shown while the step is in progress. */
6 active?: string
7 /** TaskCreate's id, so TaskUpdate finds the step again. */
8 id?: string
9 /** When the step joined the list, and when its text or status last changed: its animations are pinned to these. */
10 addedAt?: number
11 changedAt?: number
12}
13
14/** The task list, as the last plan tool call in the main conversation left it. */
15export type LensPlan = {
16 steps: LensStep[]
17 /** Steps done before the last change, so the progress bar grows from there. */
18 doneBefore: number
19 updatedAt: number
20}
21
22/** One tool this session called, with how often. */
23export type LensTool = {
24 /** The wire name: `Read`, `mcp__notion__search`. */
25 name: string
26 count: number
27 /** The count before the last call, so its bar grows from there. */
28 countBefore: number
29 /** Calls that came back as errors or were denied. */
30 errors: number
31 firstAt: number
32 lastAt: number
33}
34
35/** One skill this session used. */
36export type LensSkill = {
37 name: string
38 count: number
39 /** First and last use; 0 for an always-loaded skill not used yet. */
40 firstAt: number
41 lastAt: number
42 /** Pulled in at start through a memory file (a CLAUDE.md import). */
43 isAlways: boolean
44}
45
46/**
47 * The last change of order of one list (`skills`, `flat`, or `g:<group>` for a server's tools): the names
48 * in their order before and after, and when. Rows fly from their old place to their new one for a moment
49 * after it; a name not in `from` joined the list and flies in from the bottom.
50 */
51export type LensMove = { from: string[]; to: string[]; at: number }
52
53/** The pane's sections, and the two lists inside the crew (`crewWork`, `crewBase`), each folding on its own. */
54export type LensSection = 'todo' | 'crew' | 'crewWork' | 'crewBase' | 'skills' | 'tools'
55export type LensSort = 'count' | 'name' | 'recent'
56
57/** What a crab was last seen doing, from its last call or model request. */
58export type LensActivity = 'start' | 'think' | 'read' | 'search' | 'edit' | 'shell' | 'web' | 'delegate' | 'plan' | 'other'
59
60/** The main thread, played by Captain Clawd: what it is doing, from its own calls and model requests. */
61export type LensCaptain = {
62 /** Claude is working on a turn; false while it waits for you. */
63 isBusy: boolean
64 activity: LensActivity
65 detail: string
66 lastActiveAt: number
67}
68
69/** One subagent of this session, played by a crab. */
70export type LensCrab = {
71 /** The agent's id; the spawn's tool_use_id when the engine gave none. */
72 id: string
73 agentId?: string
74 /** The agent type as spawned: `Explore`, `general-purpose`, a plugin's own. */
75 type: string
76 /** Key into the cast of characters. */
77 character: string
78 /** Its given name: `Pip`, then `Pip II` for a second scout of that name. */
79 name: string
80 description: string
81 status: 'running' | 'done' | 'failed' | 'stopped'
82 startedAt: number
83 endedAt?: number
84 lastActiveAt: number
85 activity: LensActivity
86 /** The last call's target, short: a file name, a pattern, a command's head. */
87 detail: string
88 toolCalls: number
89 errors: number
90 /** The model it runs on, as the engine names it (`claude-haiku-4-5`); each request may update it. */
91 model?: string
92 /** Tokens its requests used: input, output and cache reads and writes, summed over every request. */
93 tokens?: number
94 /** Model requests it made. */
95 steps?: number
96 /** From the agent's own to-do list, when it keeps one. */
97 todoDone?: number
98 todoTotal?: number
99 todoNote?: string
100 /** Progress before its last change, and when that was: the bar grows from there. */
101 progressBefore: number
102 progressAt: number
103}
104
105/** The part of the layout kept across sessions, in the mod's store. */
106export type LensLayout = {
107 /** Sections closed with their header. */
108 folded: LensSection[]
109 /** Whether tool groups start open; a group in `groupExceptions` is the other way. */
110 groupsOpen: boolean
111 /** Groups, by key, the person opened or closed against `groupsOpen`. */
112 groupExceptions: string[]
113 /** Tools grouped by their MCP server or connector, or one flat list. */
114 isGrouped: boolean
115 sort: LensSort
116 /** The crew's who's-who legend is open. */
117 isLegendOpen: boolean
118}
119
120/** How the person has the pane laid out: the kept layout, and what lasts only for the session. */
121export type LensView = LensLayout & {
122 /** Narrows the tools to those whose name or group holds it. */
123 filter: string
124 /**
125 * When each section (by id) or tool group (`g:<key>`) was last opened or closed, and when the pane
126 * opened (`pane`), so rows come in and chevrons turn once then, not on every redraw.
127 */
128 toggledAt: Record<string, number>
129 /** When the tools were last regrouped or resorted: their rows come in again then. */
130 layoutAt: number
131}
132
133declare module 'claude-code' {
134 interface PluginState {
135 'session-lens': {
136 plan: LensPlan
137 tools: Record<string, LensTool>
138 skills: Record<string, LensSkill>
139 /** MCP server ids to the names /mcp lists them by. */
140 servers: Record<string, string>
141 view: LensView
142 /** Each list's last change of order, by list key. */
143 moves: Record<string, LensMove>
144 /** This session's subagents, by id. */
145 crew: Record<string, LensCrab>
146 /** The main thread, always on the beach. */
147 captain: LensCaptain
148 /**
149 * When the current race began: a crab set out with no one at work. The crew lists show the crabs of
150 * this race alone, so the list of those back starts empty with each race.
151 */
152 raceSince: number
153 /** Moved on a timer while a crab works, so a crab left waiting is drawn waiting. */
154 crewTick: number
155 /** The band above the prompt shows crabs that set out or came back after this moment. */
156 crewBandSince: number
157 /** True once a resumed session's earlier calls were counted, so a reload does not count them twice. */
158 isSeeded: boolean
159 /** True once the kept layout was read into `view` this session, so a reload does not read it over the person's changes. */
160 isLayoutLoaded: boolean
161 /** True once the pane opened by itself this session. */
162 isAutoOpened: boolean
163 /** The main conversation's TodoWrite, TaskCreate and TaskUpdate calls, by tool_use_id: the rows the lens may hide. */
164 planRows: string[]
165 }
166 }
167}
168