SLOPSHOPPER

session-lens

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…

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-lens
│ ┃ Session lens ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ sec-todo │ ⏺ Read(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ sec-crew Captain off watch ? who │ ⏺ Bash(bun test) │ ┃ │ │ ⎿ 3 pass, 1 fail │ ┃ │ (\/)(-,,-)(\/) Captain Clawd · Off watc… │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ✻ Worked for 42s · done 4:20 PM │ ┃ │ sec-skills │ │ ┃ ╰──────────────────────────────────────────╯ › /lens │ ┃ ⎿ session-lens: Session lens opened. │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ sec-tools 6 tools · 10 calls │ │ ┃ ╰──────────────────────────────────────────╯ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session lens
╭──────────────────────────────────────────────────────────╮ │ sec-todo │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ sec-crew Captain off watch ? who │ │ │ │ (\/)(-,,-)(\/) Captain Clawd · Off watch, awaiting orde… │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ sec-skills │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ sec-tools 6 tools · 10 calls │ ╰──────────────────────────────────────────────────────────╯
README

session-lens

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 session lens: To-do, Crew, Skills and Tools

What it does

The pane has four sections, each folding on its own with its arrow or title:

  • To-do: Claude's current task list, with how far along it is.
  • Crew: Captain Clawd (the main thread) and a crab for each subagent, racing toward their flags on a beach, with a row each for the crabs at work and those that reported back.
  • Skills: each skill used, with its count.
  • Tools: each tool used, grouped by MCP server or connector, with its count.

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.

To-do and Skills

To-do and Skills, light and dark

  • To-do lists Claude's steps: ✓ done (struck through), ● in progress (bold, in its "doing now" wording, with a pulse and a sheen across it) and ○ pending. The header counts them ("2 of 5") and a bar shows how far along the list is. A step ticked off draws its check and strike-through. It follows TodoWrite, TaskCreate / TaskUpdate and the desktop app's own task list.
  • While the lens is open in front with To-do unfolded, the 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.
  • Skills lists each skill used as /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.

Crew

The crew: who's who, the beach, At work and Reported back

  • The beach. Captain Clawd always has the top lane. He walks toward his flag as Claude's own to-do list moves, says what the main conversation is doing ("Dispatching the crew", "Taking the wheel: register.tsx"), and sleeps while Claude waits for you. Each subagent is a crab with a lane of its own, walking toward its flag as its progress climbs, footprints behind it, with its line in a speech bubble in character (a dashed thought bubble while it thinks). The crabs keep the order they set out in: the race is in how far each has walked.
  • At work. A row per crab: its name, role and task, and how long it has been out; under them its model, what it is doing (and how long it has waited, when a call runs long), its calls and tokens, and its progress. For example Haiku 4.5 · searching loadConfig · 4 calls · 61k tok, then ≈25%.
  • Reported back. A crab that reaches 100% walks past its flag and stands there cheering for 3 seconds, then flies to the top of this list. A crab that fails or is stopped reports back straight away. Each row shows how the run ended (done, failed or stopped), how long it took, its model, calls and tokens, and its last line, the latest first.
  • Totals. Beside each list's title are the race's totals for that list, a · 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.
  • Races. A race starts when a crab sets out with no one at work. The lists show that race's crabs, so Reported back starts empty with each one. At work and Reported back fold on their own, and the beach stays.
  • Who's who. ? who beside the summary opens a strip of the characters on duty, the captain first, with the agent types each stands for.
  • Quiet crabs. With the 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.

The cast
Agent typeCharacterNamesMark
main threadCaptainClawdcaptain's hat
general-purpose, claudeDeckhandBarnacle, Rusty, Sal, Dory, Mackbandana
ExploreScoutPip, Wren, Kit, Marlo, Junocap
PlanNavigatorAda, Vela, Orion, Magstricorn
claude-code-guideProfessorKelp, Sage, Quill, Tomesglasses
statusline-setupSignallerSparky, Dot, Morseantenna
forkUnderstudyEcho, Mirror, Twinhalo
any other typeCook, Sleuth, Wizard or Diverdealt by the type's name, so the same type always gets the same onetoque, 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).

How the crabs move

Every character in every pose

PoseWhenMotion
workat work, its last call a few seconds agolegs step, claws snip, body bobs, eyes blink
scurrya call in the last 3 secondsthe same, at twice the pace
thinkwaiting on the modelstill, with a dashed thought bubble and blinking dots
waitno call for 20 secondstaps a foot; its row says how long it has waited
donefinishedwalks past its flag, hops with claws up in a burst of sparkles, then rests
failedended on an errorsees stars, eyes crossed
stoppedstopped by youeyes 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.

The band above the prompt

The crew band, at work and all back

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.

Tools

Tools grouped by server

  • Each tool used, with a use count and a bar. Tools are grouped by their MCP server or connector, each with its own colour and tile, and the built-in tools have a group of their own. A claude.ai connector shows the name /mcp lists it by, not its id.
  • By server and All tools switch between grouped and flat; in the flat list each tool shows its server beside its name.
  • Sort by Most used, Recent or A–Z, and type in the filter to narrow the list. While the filter holds text, every group with a match is shown open, and ✕ Clear empties it.
  • Press a server's name to open or close its group. ⊞ Expand all / ⊟ Collapse all turns every group at once, and servers that show up later start the way you last chose.
  • Calls that failed are counted and marked in red. A tool used for the first time flies into its list from the bottom; one that changes place, because its count passed another's or you changed the sort, flies to its new spot past the others, lit as it lands. Bars grow and a count bumps when it goes up.

Using the pane

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.

  • Press a section's arrow or title (▾ To-do, ▾ Crew, ▾ Skills, ▸ Tools) to close or open that section; in Crew, ▾ At work and ▾ Reported back fold the same way.
  • What you change is kept for your next session: open and closed sections, lists and groups, grouped or flat, the sort, and whether who's who is open. The filter always starts empty.

Install

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.

Commands

CommandDoes
/lensOpens 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).

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 } } } }
SettingDefaultEffect
crewBandtrueShow the crew band above the prompt while subagents are out.
showQuipstrueShow 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.
hidePlanRowstrueHide the main conversation's TodoWrite, TaskCreate and TaskUpdate rows in the transcript while the pane is open in front with To-do unfolded.
autoOpentrueIn 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.

How it works

  • A 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.
  • The to-do list is rebuilt from the plan tools the main conversation calls: 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.
  • The crew comes from the subagents' own events. 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.
  • A skill.prompt hook counts each skill as it is expanded for the model, whether typed as /name, called through the Skill tool, or preloaded.
  • Group names come from the session's context breakdown ($.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.
  • A resumed session's earlier calls are counted once, from its transcript. A /clear starts the counts over and keeps the pane's layout.
  • Counts and the layout live in $.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.
  • After each change that can reorder a list (a call, a skill, a new sort), the mod notes every list whose order changed: its old order, its new one, and when. Each list (the skills, each server's tools, the flat list) is drawn as one picture, so a row can fly from its old slot to its new one. Lists are compared unfiltered, and nothing flies while the filter holds text.
  • The desktop drawings are SVG with CSS animations. The pane is redrawn on every event of the session, and a redrawn picture would start its animations over. So each animation is pinned to the moment its row changed: a drawing made later shows it part-way through, or with no animation once it is over. The loops on the step in progress keep their phase from one drawing to the next. The app also keeps the pane's last drawing and may show it again (a new message, a re-layout) without asking the mod, which would replay an animation caught mid-way. So about 1.6 s after each change, once its animations are over, the mod asks for one more drawing, with nothing left to replay. Under 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.

Known limits

  • The crew starts empty in a resumed session: crabs come only from subagents that set out in it. It keeps the 40 latest crabs.
  • Tool bars use a log scale that fills at 50 calls, so a row's bar depends on its own count alone. Compare the numbers rather than the bar lengths.
  • A connector whose server name the context breakdown does not list shows as Connector plus the first characters of its id.
  • A skill typed as /name in a resumed session's earlier turns is not counted; skills called through the Skill tool are.
  • The mobile app draws no text field, so it has no filter there.
  • The desktop app's own task list, pinned under the latest message, is drawn by the app and no mod can hide it; 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.
  • Built on Claude Code's function-hook plugin API (early access), tested against Claude Code 2.1.307.

Credits

  • The idea of a pane listing what a chat has used, its to-do list, tools and skills side by side, comes from Ben AI's mods, shown in this video (its chat-inventory mod). session-lens takes the idea, not the code.
  • The crew, its cast of characters, their lines, the walk toward the flag and the band above the prompt are adapted from this repository's crab-crew mod.

Licence

MIT, see the repository's LICENSE.

Source 7 files
hooks/register.tsx 521 lines
1import { 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}
521
hooks/crew.ts 452 lines
1// 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}
452
hooks/track.ts 296 lines
1// 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'}`
296
hooks/view.tsx 530 lines
1// 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}
530
hooks/art.ts 454 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
454
hooks/crabs.ts 564 lines
1// 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}
564
types/index.d.ts 168 lines
1/** 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