SLOPSHOPPER

claudex

Claudex: Claude and Codex, working together from Claude Code. Reviews and second opinions, tasks in their own worktree, and pictures, with live cards in the…

newpanebandrowsguardcommand
★ 2v2.6.1MITupdated 2026-10-24viscolex/claudex
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claudex
│ ┃ Claudex ✕ › fix the failing auth test and add an audit log call │ ┃ Claudex · app │ ┃ Idle · nothing runs until you start it ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Usage & settings ▾ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ▣ client module ./tiles.tsx ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Describe a picture for Codex to paint… ⏎ pai │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Shape: Square ▾ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭─────────────────────────────────────────── │ ┃ │ › /claudex │ ┃ │ No pictures yet. Describe one above, ⎿ claudex: Claudex pane opened. │ ┃ │ ask Claude for one, or try: │ ┃ │ › Clawd and the Codex companion high-fivi │ ┃ │ › A cozy workshop at night where two smal │ ┃ │ › An app icon: a coral and periwinkle due │ ┃ │ │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ⬡ Codex idle Open Claudex ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⬡ Codex idle Open Claudex ⟨Claude Code's own drawing⟩
Pane · Claudex
Claudex · app Idle · nothing runs until you start it Usage & settings ▾ ▣ client module ./tiles.tsx Describe a picture for Codex to paint… ⏎ paint Shape: Square ▾ ╭─────────────────────────────────────────────────────────── │ │ No pictures yet. Describe one above, ask Claude for │ one, or try: │ › Clawd and the Codex companion high-fiving on a tiny sta │ › A cozy workshop at night where two small robots review │ › An app icon: a coral and periwinkle duet of two speech │ ╰───────────────────────────────────────────────────────────
README

Claudex

Claude and Codex, working together from Claude Code.

Reviews and second opinions, tasks in their own worktree, build loops, shared jobs and pictures:<br> Codex on your ChatGPT plan, brought into Claude Code, live in the chat.

version Claude Code Codex license platforms node

What it does · Conversations · Examples · Install · Settings · FAQ

<img src="docs/images/band-working.svg" alt="The Claudex band above the prompt: Codex reads the code during a review, each step it takes landing as a chip on the stage" width="860">

[!NOTE] Claudex is an unofficial community mod. It isn't made by, endorsed by, or affiliated with Anthropic or OpenAI. "Claude" and "Clawd" belong to Anthropic; "Codex" and "ChatGPT" belong to OpenAI.

What it does

You talk to Claude as usual. When you want a second pair of eyes or a second pair of hands, ask Claude to bring in Codex. You can watch the two of them work together on a little stage above the prompt.

<table> <tr> <td width="50%" valign="top">

🔍 Reviews and second opinions

Codex reviews your uncommitted changes, a branch, a commit or specific files, with a focus on correctness, security, performance, tests, simplicity or UX. Claude checks each finding and marks whether it agrees, disagrees or is unsure, and you see both views side by side.

</td> <td width="50%" valign="top">

🤝 Joint reviews

Claude and Codex each review on their own, without seeing the other's findings. Then they compare notes, argue out where they disagree, and agree on one solution.

</td> </tr> <tr> <td valign="top">

🛠️ Tasks in their own worktree

Codex builds in its own copy of the repo, so nothing touches your files until Claude has reviewed the diff and applies it, in whole or file by file. Every apply can be undone in one click.

</td> <td valign="top">

🔁 Build loops and shared jobs

Claude builds, Codex reviews, Claude fixes, Codex rechecks, until Codex approves. For bigger jobs, Claude splits the work between them and tracks it on one board.

</td> </tr> <tr> <td valign="top">

💬 A two-way line

While Codex works, you or Claude can steer it now or queue notes for its next round. Codex can tell Claude what it finds, or ask a question and either wait or carry on.

</td> <td valign="top">

🎨 Pictures

Codex's image model paints PNGs, up to four variations at once. Cut-outs come with a real transparent background (an alpha channel) straight from Codex; if its image tool can't do that, it paints on flat magenta and Claudex keys it out for you. It also paints empty backdrops ("plates") and edits pictures you give it.

</td> </tr> <tr> <td valign="top">

✅ Fixes checked, not assumed

Applying a fix leaves its findings open, marked fix applied, until Claude or a Codex recheck confirms them. One tap closes them if you checked yourself.

</td> <td valign="top">

🧭 Plans, bugs, visual reviews and Codex's browser

Codex checks plans before you build, hunts bugs from a log, and reviews desktop and phone screenshots of a page. When it wants to open a site in its own browser, you choose Allow once, Always allow or Deny.

</td> </tr> </table>

Codex only runs when you ask for it. Claude never calls it on its own initiative.

See it

<img src="docs/images/band-idle.png" alt="The Claudex band at rest above the Claude Code prompt, with Clawd, the Codex mascot, a usage ring and an Open Claudex button" width="900">

<sub><b>Always there, never in the way.</b> At rest, the band sits above the prompt with your Codex usage and a way into the pane. Nothing runs until you start it.</sub>

<img src="docs/images/band-asking.svg" alt="The band while Codex asks Claude a question mid-review, with the question in large type" width="860">

<sub><b>A question mid-run.</b> Codex asks Claude something. It either waits for the answer or carries on with what doesn't depend on it.</sub>

<img src="docs/images/result-changes.svg" alt="A review result: Changes needed, with one high, two medium and one low finding" width="860">

<sub><b>A review lands.</b> Codex's findings drop onto the stage in their severity colours, and the two trade a line about them.</sub>

<img src="docs/images/band-task-check.svg" alt="The band while Claude checks a task Codex built: two files changed, +92 −6" width="860">

<sub><b>Claude's turn.</b> Codex built the task in its own worktree; Claude checks the diff before anything reaches your files.</sub>

<img src="docs/images/result-approved.svg" alt="A review result: Approved, all findings resolved, Codex jumping for joy" width="860">

<sub><b>Approved.</b> The recheck closes the loop.</sub>

<img src="docs/images/pane-hero.svg" alt="The top of the Claudex pane: Codex is reviewing, two running, one needs you, 12% of the 5-hour usage" width="560">

<sub><b>The Claudex pane</b> (<code>/claudex</code>): every conversation, task, job and picture, plus your Codex usage, with the stage on top.</sub>

The same scenes show up as live cards in the chat. Motion is up to you: Calm (the default) moves when something happens and then rests, Full keeps moving, and Still stops all motion except the clocks.

Every conversation, in full

Open any conversation in the pane (/claudex, then a row under Talk or Tasks) to see the whole thing: what was asked, what each side found, how they argued, and what they settled on.

<img src="docs/images/conversation-top.png" alt="The top of a joint review in the Claudex pane: its title, a drawn timeline of every move, and the stage showing Claude and Codex agreeing" width="526">

<img src="docs/images/conversation.png" alt="A whole joint review in the Claudex pane: the timeline, the findings grouped by who found them, the agreed solution, and every round between Claude and Codex" width="526">

From top to bottom:

  • The timeline. Every move, drawn as a path between two lanes: Claude's on top, Codex's below. You see who did what, in order: found 3, triaged, sent 2 findings, replied, agreed on a fix.
  • How it ended, on the stage: approved, changes needed or, in a joint review, agreed.
  • The findings. A joint review groups them by who found them: both found, only Claude, only Codex and still argued. Each one shows its severity, a link that opens the file at the line, and what the other side made of it (agrees, refines, is unsure). Tap ▸ to open one: its detail, the suggested fix, the replies from each round, and buttons to have Claude or Codex fix it, or to dismiss it.
  • The agreed solution in full, and what to do next: Have Claude handle it, Copy as Markdown or Share with Claude.
  • Every round, in order. What Claude sent and what Codex answered, the files it read, the searches it ran, how long it took, and its tokens (and how many were cached). What Codex did lists each step. Where Claude disputed a finding, Codex's answer says whether it holds, concedes or refines it.

While a round runs, the same page shows it live: send Codex a note now, queue one for its next round, or answer a question it asked. A task's page shows each file it changed with its diff, and an Undo once it's applied.

Examples

There are no commands to learn. You just tell Claude what you want, in your own words.

Ask Codex to review my uncommitted changes, especially for security.

Codex returns findings (F1, F2, …). Claude checks each one against the code and marks it agree, disagree or unsure with a note. Then it fixes what's real, and Codex rechecks.

Do a joint review of this branch against main with Codex.

Claude writes its own findings while Codex writes its own. Neither sees the other's until both are done. Then they compare, debate the points they disagree on, and agree on one solution.

Before you start, have Codex look over the plan.

Claude weighs each of Codex's points and gives you the revised plan with a short "Codex review" section.

Have Codex add pagination to the orders API. Done means the tests pass
and the page size is configurable.

Codex works in its own git worktree, so your files stay untouched. When it finishes, Claude reviews the patch like a colleague's. It then applies the patch, applies only some files, sends it back with notes, or throws it away.

Build the settings page, and loop with Codex reviewing until it approves.
Codex, why does `npm test` fail on CI but pass locally? Here's the log: …
Have Codex do a visual review of http://localhost:3000/pricing compared with the live site.
Tell Codex the API moved to src/api/v2, and that it should use that.
When it's done, queue up a note for Codex to also add tests.
Ask Codex for three variations of a cosy pixel-art kitchen, wide format.
Then paint the chef as a cut-out on a transparent background.
Stop Codex.

Requirements

NeedsDetails
Claude CodeThe desktop app or the terminal
CodexSigned in with your ChatGPT account. The Codex desktop app works best, because Claudex uses its CLI. The CLI on its own also works: npm i -g @openai/codex, then codex login
Node.js20 or newer
gitAny recent version

Optional, per project: Playwright (npm i -D playwright) plus Chrome or Edge for visual reviews, and sharp for WebP copies of pictures. Cut-outs need nothing extra.

Install

1. Clone the repo into your Claude Code mods folder:

git clone https://github.com/viscolex/claudex.git ~/.claude/mods/claudex

2. Load it. Add this to ~/.claude/settings.json, merging it into any env block you already have:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/Users/YOUR-NAME/.claude/mods/claudex"
  }
}

On Windows, use C:\\Users\\YOUR-NAME\\.claude\\mods\\claudex.

3. Restart Claude Code and start a new chat. The Claudex band appears above the prompt after your first message, and /claudex opens the pane.

4. Check that it can reach your Codex:

node ~/.claude/mods/claudex/scripts/smoke.mjs

Each check prints PASS or FAIL. No model runs, so it costs nothing. Add --turn to also run one tiny real turn.

[!TIP] To update later, run git pull in ~/.claude/mods/claudex and restart Claude Code. INSTALL.md covers upgrading from the older Codex mod and where Claudex keeps its files.

Settings

Open Usage & settings in the Claudex pane, or the mod's settings in Claude Code:

SettingOptionsDefault
Codex modelAny model your Codex offersgpt-6.1-sol
Reasoning effortlow · medium · high · xhigh · max · ultrahigh
MotionCalm · Full · StillCalm

Codex runs use your own Codex quota. Claudex shows your usage, and warns you before starting work once you're above 90%.

FAQ

No. In a conversation, Codex's shell is read-only. Tasks run in a separate git worktree, and nothing reaches your files until Claude reviews the diff and applies it. Every apply can be undone.

Claudex is free. Codex runs on your own ChatGPT plan's Codex quota, and the smoke test uses none.

The Codex companion on the stage is cut from the Codex desktop app's own artwork on your computer, so it always matches your installed version. The mod doesn't ship that artwork itself (it appears here only in the README pictures). With only the Codex CLI installed, everything still works; the stage just shows Claude's side without the mascot.

Claudex finds the updated Codex app by itself, with no restart needed. If something still fails, run node scripts/smoke.mjs from the mod folder; the FAIL lines say what changed. See INSTALL.md for more fixes.

That warning is about your own Codex settings, not Claudex: a key in ~/.codex/config.toml, or in the project's .codex/config.toml, that your Codex version no longer reads. The warning names the file and the key. Remove or rename the key and the warning goes away. Claudex shows it so you know, and the run carries on either way.

Project layout

.claude-plugin/plugin.json   mod manifest and settings
hooks/register.tsx           the mod: tools, cards, band, pane
hooks/art.ts, duet.ts        the stage and its drawings
scripts/                     Node helpers: Codex app server, tasks, worktrees, images, smoke test
schemas/                     JSON schemas for Codex's structured replies
types/index.d.ts             the mod API types
tests/                       tests, run with Claude Code's mod test runner
docs/images/                 README images: the SVGs are drawn by hooks/duet.ts, the PNGs are the pane

License

MIT © Andrej Safranovic

Source 6 files
hooks/register.tsx 11981 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, Timer, UiPressArgument } from 'claude-code'
3
4import type {
5  CodexAspect as Aspect,
6  CodexChat as Chat,
7  CodexConsult as Consult,
8  CodexFinding as Finding,
9  CodexFix as Fix,
10  CodexJob as Job,
11  CodexImage as Image,
12  CodexImage as Item,
13  CodexImageStatus as ImageStatus,
14  CodexInfo as Info,
15  CodexKind as Kind,
16  CodexLens as Lens,
17  CodexLoop as Loop,
18  CodexPrefs as Prefs,
19  CodexPreset as Preset,
20  CodexResponse as Response,
21  CodexAsk as Ask,
22  CodexFollowUp as FollowUp,
23  CodexHeard as Heard,
24  CodexPermit as Permit,
25  CodexStatus as Status,
26  CodexSteer as Steer,
27  CodexStep as Step,
28  CodexTab as Tab,
29  CodexTaskWork as Work,
30  CodexThumb as Thumb,
31  CodexUsage as Usage,
32  CodexVerb as Verb,
33  CodexVerdict as Verdict,
34  CodexView as View,
35} from '../types'
36import {
37  allClearSvg,
38  BAD,
39  BAND_CONTROLS,
40  BAND_ROW_HEIGHT,
41  BUILD,
42  bandHeroSvg,
43  bandJobSvg,
44  bandResultSvg,
45  bandRowSvg,
46  jobCounts,
47  partColour,
48  chipSvg,
49  CLAUDE,
50  CODEX,
51  codexMarkSvg,
52  familyColour,
53  calm,
54  continueAnimations,
55  meterSvg,
56  DUO_SIZE,
57  duoSvg,
58  GAUGE_SIZE,
59  POKE_STAGE,
60  pokeStageSvg,
61  gaugeSvg,
62  GOOD,
63  LIVE_CARD_HEIGHT,
64  liveCardSvg,
65  PAIR_SIZE,
66  pairSvg,
67  ringSvg,
68  photoSvg,
69  shimmerSvg,
70  stageSvg,
71  svgSize,
72  verdictColour,
73  WARN,
74} from './art'
75import { type Bead, type JourneyStep, type Motion, type RowChip, JOURNEY_HEIGHT, MINI_STAGE, PANE_HERO_HEIGHT, duetCardSvg, duetHeroSvg, jobStripSvg, journeySvg, miniStageSvg, moving, rowChipSvg, usageCardSvg, duetResultSvg, duetRowSvg, paneHeroSvg, wordmarkSvg } from './duet'
76import type { TileSpec, TilesProps } from './tiles'
77import type { BandHero, BandJob, BandResult, BandRow, Banter, ChipKind, ClawdMood, Family, Gauge, Growing, JobPartState, Mail, PairState, PlanView, Sprite, Stage, SteerView } from './art'
78import { describe, headline } from './parse'
79
80// ===========================================================================
81// Shared: state, host (Codex CLI, git, PowerShell helpers), usage, the run loop over Codex JSON events, and notes that wake Claude.
82// ===========================================================================
83
84// What every part of the Codex mod shares: its state, the host (the Codex
85// CLI, git, PowerShell helpers), the plan's usage, the run loop over Codex's
86// JSON events, and the notes that wake Claude when background work lands.
87
88export type Engine = EngineInterface
89
90export const PANE = 'claudex'
91/** Dev builds register a redraw tool: calling it reloads the mod first, so a turn can see its edits. */
92export const DEV = false
93
94export const consults = atom({ plugin: 'claudex', key: 'consults' } as const, [] as Consult[])
95export const images = atom({ plugin: 'claudex', key: 'images' } as const, [] as Image[])
96export const now = atom({ plugin: 'claudex', key: 'now' } as const, 0)
97export const view = atom({ plugin: 'claudex', key: 'view' } as const, {
98  tab: 'talk',
99  thread: '',
100  showActivity: '',
101  showBrief: '',
102  image: '',
103  viewing: '',
104  imagePanel: 'none',
105  pick: { aspect: '', style: '' },
106  draft: '',
107  page: 0,
108  open: [],
109  expanded: [],
110  start: '',
111  seen: '',
112  filter: '',
113  rowMenu: '',
114  renaming: '',
115  undo: '',
116  menu: '',
117  art: 0,
118  scope: '',
119  picked: [],
120  query: '',
121  show: '',
122  seenAt: 0,
123  dismissed: [],
124} as View)
125export const usage = atom({ plugin: 'claudex', key: 'usage' } as const, null as Usage | null)
126export const prefs = atom({ plugin: 'claudex', key: 'prefs' } as const, {
127  model: 'gpt-6.1-sol',
128  effort: 'high',
129  lens: 'general',
130  aspect: 'square',
131} as Prefs)
132export const worktrees = atom({ plugin: 'claudex', key: 'worktrees' } as const, null as { count: number; bytes: number } | null)
133/** Codex's version, account and models, as its app server reported them (see preflight). */
134export const info = atom({ plugin: 'claudex', key: 'info' } as const, null as Info | null)
135/** Every chat using the mod, as their files say (see Chats). */
136export const chats = atom({ plugin: 'claudex', key: 'chats' } as const, {} as Record<string, Chat>)
137export const poke = atom({ plugin: 'claudex', key: 'poke' } as const, { who: '', n: 0 } as { who: '' | 'claude' | 'codex'; n: number })
138export const minute = atom({ plugin: 'claudex', key: 'minute' } as const, 0)
139
140// Module state: a reload starts it over. What drawings read is in $.state.
141export const host = {
142  cwd: '',
143  codex: undefined as string | undefined,
144  claudeModel: 'Claude',
145  defaults: { model: 'gpt-6.1-sol', effort: 'high' },
146  turnRunning: false,
147  signedIn: false,
148  /** Codex's version, from its app server (initialize's user agent). */
149  version: '',
150  /** The system: Windows or a Mac (or another Unix), and the person's home folder. */
151  windows: true,
152  mac: false,
153  home: '',
154}
155
156// ---------------------------------------------------------------------------
157// Small helpers
158
159export const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
160
161export function clock(ms: number) {
162  const s = Math.max(0, Math.floor(ms / 1000))
163  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
164}
165
166export function seconds(ms: number) {
167  const s = Math.round(ms / 1000)
168  return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`
169}
170
171export function ago(ms: number) {
172  const s = Math.round((Date.now() - ms) / 1000)
173  if (s < 60) return 'just now'
174  if (s < 3600) return `${Math.floor(s / 60)} min ago`
175  if (s < 86400) return `${Math.floor(s / 3600)} h ago`
176  return new Date(ms).toLocaleDateString()
177}
178
179export function span(secs: number) {
180  const s = Math.max(0, Math.round(secs))
181  const d = Math.floor(s / 86400)
182  const hours = Math.floor((s % 86400) / 3600)
183  const m = Math.floor((s % 3600) / 60)
184  if (d) return `${d}d ${hours}h`
185  if (hours) return `${hours}h ${m}m`
186  // Under a minute, in seconds: a short round read "0m".
187  return m ? `${m}m` : `${s}s`
188}
189
190export function thousands(n: number) {
191  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
192  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
193}
194
195export function bytes(n?: number) {
196  if (!n) return ''
197  return n > 1e6 ? `${(n / 1e6).toFixed(1)} MB` : `${Math.round(n / 1e3)} KB`
198}
199
200export function cut(text: string, max: number) {
201  const t = text.trim()
202  return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t
203}
204
205export function plural(n: number, word: string, many = `${word}s`) {
206  return `${n} ${n === 1 ? word : many}`
207}
208
209export function spinner(at: number) {
210  return '◐◓◑◒'[Math.floor(at / 1000) % 4]
211}
212
213export function shortId() {
214  return crypto.randomUUID().replace(/-/g, '').slice(0, 10)
215}
216
217export function newThreadId(existing: Set<string>) {
218  for (;;) {
219    const id = Array.from(crypto.getRandomValues(new Uint8Array(4)), b => 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'[b % 32]).join('')
220    if (!existing.has(id)) return id
221  }
222}
223
224export function codexName(model: string) {
225  return model.replace(/^gpt-/i, 'GPT-').replace(/-(sol|astra|luna|terra)\b/i, m => `-${m.slice(1, 2).toUpperCase()}${m.slice(2)}`)
226}
227
228export function claudeName(id: string) {
229  const m = id.match(/(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i)
230  if (!m) return id ? cut(id, 18) : 'Claude'
231  const family = (m[1] as string).charAt(0).toUpperCase() + (m[1] as string).slice(1).toLowerCase()
232  return `${family} ${m[2]}${m[3] && m[3].length < 3 ? `.${m[3]}` : ''}`
233}
234
235/** A path joined in the system's own spelling: backslashes on Windows, slashes elsewhere. */
236export function joinPath(...parts: string[]) {
237  return parts
238    .map((p, i) => (i === 0 ? p.replace(/[\\/]+$/, '') : p.replace(/^[\\/]+|[\\/]+$/g, '')))
239    .filter(Boolean)
240    .join(host.windows ? '\\' : '/')
241}
242
243const isAbsolute = (p: string) => (host.windows ? /^[A-Za-z]:[\\/]/.test(p) || /^[\\/]{2}/.test(p) : p.startsWith('/'))
244
245export function resolvePath(p: string) {
246  const clean = host.windows ? p.trim().replace(/\//g, '\\') : p.trim()
247  return isAbsolute(clean) ? clean : joinPath(host.windows ? host.cwd.replace(/\//g, '\\') : host.cwd, clean.replace(/^\.[\\/]/, ''))
248}
249
250export function fileName(path: string) {
251  return path.split(/[\\/]/).pop() ?? path
252}
253
254// ---------------------------------------------------------------------------
255// Host
256
257/**
258 * Runs one of the mod's helpers (scripts/*.mjs, the same on Windows and a Mac)
259 * and reads its JSON line, a failure it reports included.
260 */
261export async function helper($: Engine, script: string, args: string[] = [], timeoutMs = 120_000) {
262  let ran: Awaited<ReturnType<Engine['process']['run']>>
263  try {
264    ran = await $.process.run(['node', joinPath($.plugin.root, 'scripts', script), ...args], { timeoutMs })
265  } catch (err) {
266    throw new Error(`Node.js is needed for this and was not found: ${message(err)}`)
267  }
268  const line = ran.stdout.trim().split(/\r?\n/).pop() ?? ''
269  try {
270    return JSON.parse(line || '{}') as Record<string, any>
271  } catch {
272    throw new Error((ran.stderr || ran.stdout || `${script} exited with ${ran.exitCode}`).trim().slice(0, 400))
273  }
274}
275
276/** Runs one of the mod's Node scripts and reads its JSON line; a script's own failure throws. */
277export async function nodeScript($: Engine, script: string, args: string[], timeoutMs = 120_000) {
278  let ran: Awaited<ReturnType<Engine['process']['run']>>
279  try {
280    ran = await $.process.run(['node', joinPath($.plugin.root, 'scripts', script), ...args], { timeoutMs })
281  } catch (err) {
282    throw new Error(`Node.js is needed for this and was not found: ${message(err)}`)
283  }
284  const line = ran.stdout.trim().split(/\r?\n/).pop() ?? ''
285  let out: Record<string, any>
286  try {
287    out = JSON.parse(line || '{}') as Record<string, any>
288  } catch {
289    throw new Error((ran.stderr || ran.stdout || `${script} exited with ${ran.exitCode}`).trim().slice(0, 300))
290  }
291  if (out.ok === false) throw new Error(String(out.error ?? `${script} failed`))
292  return out
293}
294
295export async function ensureCodex($: Engine) {
296  if (host.codex) {
297    // The Codex app updates itself, and its CLI moves with it: a path from before the update is found again.
298    let there = true
299    try {
300      there = await $.fs.exists(host.codex)
301    } catch {}
302    if (there) return host.codex
303    host.codex = undefined
304    host.signedIn = false
305  }
306  try {
307    host.codex = ((await helper($, 'find-codex.mjs')).codex as string | null) ?? undefined
308  } catch {}
309  if (!host.codex) throw new Error('The Codex app was not found. Install it and sign in with ChatGPT.')
310  return host.codex
311}
312
313/** After a failed start: when the Codex path in use is gone (the app updated), the new one; else nothing. */
314async function freshCodex($: Engine, used: string) {
315  let there = true
316  try {
317    there = await $.fs.exists(used)
318  } catch {}
319  if (there) return undefined
320  host.codex = undefined
321  host.signedIn = false
322  try {
323    const found = await ensureCodex($)
324    return found !== used ? found : undefined
325  } catch {
326    return undefined
327  }
328}
329
330/**
331 * Removes a task's worktree through worktrees.mjs, which unlinks its dependency links first:
332 * `git worktree remove` deletes THROUGH a junction or symlink into the main checkout's node_modules.
333 * Never fatal, as the plain git call it replaces was not.
334 */
335async function removeWorktree($: Engine, worktree: string) {
336  try {
337    await helper($, 'worktrees.mjs', ['-Mode', 'remove', '-Path', worktree], 180_000)
338  } catch {}
339}
340
341/** What codex-probe.mjs says: Codex's version, account and models, or why it could not be asked. */
342type Probe = {
343  ok: boolean
344  stage?: string
345  error?: string
346  stderr?: string
347  version?: string | null
348  signedIn?: boolean
349  account?: { type: string; plan?: string } | null
350  models?: Info['models']
351}
352
353/** Asks Codex's app server who is signed in, its version and its models (no model runs, no quota). */
354async function probeCodex($: Engine, codex: string): Promise<Probe> {
355  let ran: Awaited<ReturnType<typeof $.process.run>>
356  try {
357    ran = await $.process.run(['node', joinPath($.plugin.root, 'scripts', 'codex-probe.mjs'), codex], { timeoutMs: 40_000 })
358  } catch (err) {
359    return { ok: false, stage: 'node', error: `Node.js is needed for this and was not found: ${message(err)}` }
360  }
361  const line = ran.stdout.trim().split(/\r?\n/).pop() ?? ''
362  try {
363    return JSON.parse(line || '{}') as Probe
364  } catch {
365    return { ok: false, stage: 'probe', error: cut((ran.stderr || ran.stdout || `exit ${ran.exitCode}`).trim(), 300) }
366  }
367}
368
369/**
370 * Codex is there and signed in, as its app server says (account/read): checked once a session, again
371 * after a failure. A failure says what actually went wrong; only a missing account reads "signed out".
372 */
373export async function preflight($: Engine) {
374  let codex = await ensureCodex($)
375  if (host.signedIn) return codex
376  let probe = await probeCodex($, codex)
377  // It would not start because the Codex app updated under this session (its old path is gone): find it again, once.
378  if (!probe.ok && probe.stage === 'start') {
379    const again = await freshCodex($, codex)
380    if (again) {
381      codex = again
382      probe = await probeCodex($, codex)
383    }
384  }
385  if (!probe.ok) throw new Error(`Codex could not be reached (${probe.stage ?? 'probe'}): ${probe.error ?? 'no answer'}${probe.stderr ? ` · ${probe.stderr}` : ''}`)
386  host.version = probe.version ?? host.version
387  await update($, info, () => ({ version: probe.version ?? null, account: probe.account ?? null, models: probe.models ?? [], at: Date.now() }))
388  if (!probe.signedIn) throw new Error('Codex is signed out. Open the Codex app, sign in with ChatGPT, then try again.')
389  host.signedIn = true
390  return codex
391}
392
393/** A warning when a usage window is at 90% or more. */
394export async function usageWarning($: Engine) {
395  const u = await read($, usage)
396  let top: { minutes: number; percent: number } | undefined
397  for (const w of u?.windows ?? []) if (!top || w.percent > top.percent) top = w
398  return top && top.percent >= 90 ? `${windowName(top.minutes)} Codex usage is at ${Math.round(top.percent)}%` : undefined
399}
400
401/** Opens a file at a line in the person's editor (Cursor, VS Code), else its default app. */
402export async function openInEditor($: Engine, file: string, line?: number) {
403  try {
404    const out = await helper($, 'open.mjs', ['-Path', resolvePath(file), '-Line', String(line ?? 1)], 30_000)
405    if (out.ok === false) throw new Error(String(out.error ?? 'could not open it'))
406  } catch (err) {
407    $.ui.toast(`Could not open ${file}: ${cut(message(err), 80)}`)
408  }
409}
410
411export async function ensureCwd($: Engine) {
412  if (!host.cwd) host.cwd = await $.session.cwd()
413  return host.cwd
414}
415
416export async function git($: Engine, args: string[], cwd = host.cwd) {
417  try {
418    const ran = await $.process.run(['git', ...args], { cwd, timeoutMs: 60_000 })
419    return { ok: ran.exitCode === 0, out: ran.stdout, err: ran.stderr }
420  } catch (err) {
421    return { ok: false, out: '', err: message(err) }
422  }
423}
424
425export async function currentPrefs($: Engine): Promise<Prefs> {
426  return read($, prefs)
427}
428
429// ---------------------------------------------------------------------------
430// The plan's usage, as Codex last reported it
431
432/** The plan's credits as the board shows them. */
433function creditsText(credits: { unlimited?: boolean; balance?: string | number | null } | undefined) {
434  if (credits?.unlimited) return 'unlimited'
435  return credits?.balance !== undefined && credits?.balance !== null ? Number(credits.balance).toLocaleString('en-US') : undefined
436}
437
438/**
439 * Reads how much of the Codex plan is used. Live first: Codex's app server
440 * answers account/rateLimits/read from the account, as Codex's own clients
441 * do, without running a model. Else the last reading Codex wrote into a
442 * session log, which is only as fresh as the last Codex run.
443 */
444export async function refreshUsage($: Engine, asked = false) {
445  let reading: Usage | undefined
446  try {
447    const live = await nodeScript($, 'codex-limits.mjs', [await ensureCodex($)], 40_000)
448    const r = (live.rateLimits ?? {}) as Record<string, any>
449    const windows = [r.primary, r.secondary]
450      .filter(w => w && typeof w.usedPercent === 'number')
451      .map(w => ({ minutes: Number(w.windowDurationMins ?? 0), percent: Number(w.usedPercent), resetsAt: w.resetsAt ? Number(w.resetsAt) : undefined }))
452    if (windows.length) {
453      reading = {
454        at: new Date().toISOString(),
455        plan: r.planType ? String(r.planType) : undefined,
456        windows: windows.sort((a, b) => a.minutes - b.minutes),
457        credits: creditsText(r.credits),
458        reached: r.rateLimitReachedType ? String(r.rateLimitReachedType) : undefined,
459      }
460    }
461  } catch {}
462  if (reading) {
463    await update($, usage, () => reading as Usage)
464    await $.store.set('usage', reading)
465    if (asked) {
466      const top = [...reading.windows].sort((a, b) => b.percent - a.percent)[0]
467      $.ui.toast(top ? `Codex usage now: ${windowName(top.minutes)} ${top.percent}% used` : 'Codex usage updated')
468    }
469    return
470  }
471  if (asked) $.ui.toast('Could not read live Codex usage; showing the last run’s reading')
472  try {
473    const raw = await helper($, 'codex-usage.mjs', [], 60_000)
474    const windows = [raw.primary, raw.secondary]
475      .filter(w => w && typeof w.used_percent === 'number')
476      .map(w => ({ minutes: Number(w.window_minutes ?? 0), percent: Number(w.used_percent), resetsAt: w.resets_at ? Number(w.resets_at) : undefined }))
477    if (!windows.length) return
478    const credits = raw.credits?.unlimited
479      ? 'unlimited'
480      : raw.credits?.balance !== undefined && raw.credits?.balance !== null
481        ? Number(raw.credits.balance).toLocaleString('en-US')
482        : undefined
483    const reading: Usage = {
484      at: String(raw.at ?? new Date().toISOString()),
485      plan: raw.plan ? String(raw.plan) : undefined,
486      windows: windows.sort((a, b) => a.minutes - b.minutes),
487      credits,
488      reached: raw.reached ? String(raw.reached) : undefined,
489    }
490    await update($, usage, () => reading)
491    await $.store.set('usage', reading)
492  } catch {}
493}
494
495export function windowName(minutes: number) {
496  if (minutes === 10080) return 'Weekly'
497  if (minutes === 1440) return 'Daily'
498  if (minutes % 60 === 0) return `${minutes / 60} hours`
499  return `${minutes} min`
500}
501
502// ---------------------------------------------------------------------------
503// One Codex process, its JSON events handed to the caller as they come.
504// Conversations and tasks run through scripts/appserver.mjs, which speaks
505// Codex's app server and prints the events `codex exec --json` prints, plus
506// the plan, live reasoning, a task's growing diff and notes handed over
507// mid-run. Pictures run through it too, without a schema (see runImage).
508
509export type CodexEvents = {
510  thread?: (id: string) => Promise<void>
511  turnStarted?: () => Promise<void>
512  commandStarted?: (item: string, command: string) => Promise<void>
513  commandDone?: (item: string, exit?: number | null, output?: string) => Promise<void>
514  /** What Codex is writing to Claude right now (commentary), as it writes it. */
515  live?: (item: string, text: string) => Promise<void>
516  /** How much of its final answer Codex has written: an answer under a schema is not shown half-made. */
517  writing?: (chars: number) => Promise<void>
518  /** The last lines a running command printed. */
519  output?: (item: string, tail: string) => Promise<void>
520  /** A progress line from one of Codex's tools. */
521  toolProgress?: (item: string, text: string) => Promise<void>
522  /** A warning Codex raised: config, deprecation, guardian. */
523  notice?: (kind: string, text: string) => Promise<void>
524  /** Codex moved the turn to another model. */
525  rerouted?: (from: string, to: string, reason: string) => Promise<void>
526  /** What the run actually runs with, and the instruction files Codex loaded. */
527  settings?: (s: { model?: string; tier?: string; instructions: string[] }) => Promise<void>
528  /** A picture: it started, and then what Codex reports about it. */
529  imageStarted?: () => Promise<void>
530  image?: (g: { savedPath: string | null; revisedPrompt: string | null; transparent: boolean; failure: string | null }) => Promise<void>
531  fileChange?: (item: string, changes: { path: string; kind: string }[]) => Promise<void>
532  reasoning?: (item: string, text: string) => Promise<void>
533  /** The line Codex is thinking about right now, before its reasoning is complete. */
534  thinking?: (item: string, text: string) => Promise<void>
535  message?: (item: string, text: string) => Promise<void>
536  todo?: (items: { text: string; done: boolean; now?: boolean }[]) => Promise<void>
537  webSearch?: (item: string, query: string) => Promise<void>
538  tool?: (item: string, name: string) => Promise<void>
539  usage?: (u: { input: number; cached: number; output: number; reasoning: number }) => Promise<void>
540  /** The bridge is up: notes can go into its inbox now. */
541  bridge?: () => Promise<void>
542  /** A task's diff so far. */
543  diff?: (files: { path: string; added: number; removed: number }[]) => Promise<void>
544  /** A note reached Codex mid-run, or could not. */
545  steer?: (id: string, ok: boolean, error?: string) => Promise<void>
546  /** Codex asks Claude something and waits; then the answer (or the wait running out) went back. */
547  question?: (call: string, question: string, later?: boolean) => Promise<void>
548  answered?: (call: string, outcome: 'answered' | 'timeout' | 'closed') => Promise<void>
549  /** Codex told Claude something and carried on. */
550  told?: (call: string, text: string) => Promise<void>
551  /** One of Codex's tools asks the person's permission (or the bridge refused one by itself); then the answer went back. */
552  permit?: (p: Permit) => Promise<void>
553  permitSettled?: (call: string, allow: boolean, timeout: boolean, kept?: 'session' | 'always') => Promise<void>
554  /** The plan's usage, as Codex reports it during the run. */
555  limits?: (rateLimits: Record<string, any>) => Promise<void>
556}
557
558/** A permission request as the bridge printed it, for the round. */
559function permitOfEvent(ev: any): Permit {
560  const kind: Permit['kind'] = ev.kind === 'link' || ev.kind === 'access' || ev.kind === 'verify' ? ev.kind : 'form'
561  const tool = ev.tool ?? ev.server
562  return {
563    id: String(ev.call_id),
564    kind,
565    ...(tool ? { tool: cut(String(tool), 60) } : {}),
566    ...(ev.origin ? { origin: cut(String(ev.origin), 200) } : {}),
567    title: cut(String(ev.title ?? 'Permission'), 200),
568    ...(ev.message ? { message: cut(String(ev.message), 1000) } : {}),
569    ...(ev.url ? { url: cut(String(ev.url), 1000) } : {}),
570    ...(Array.isArray(ev.options) && ev.options.length ? { options: ev.options.slice(0, 6).map((o: unknown) => cut(String(o), 60)) } : {}),
571    ...(Array.isArray(ev.persist) ? { persist: ev.persist.filter((x: unknown): x is 'session' | 'always' => x === 'session' || x === 'always') } : {}),
572    at: Date.now(),
573    state: ev.refused ? 'refused' : 'waiting',
574    ...(ev.refused ? { refused: cut(String(ev.refused), 200) } : {}),
575  }
576}
577
578export type CodexOutcome = {
579  exit?: ProcessSpawnResult
580  failure?: string
581  /** A non-fatal `error` event's text: the reason when the process then fails. */
582  reported?: string
583  stderr: string
584  cancelled: boolean
585}
586
587const running = new Map<string, HookStream<ProcessSpawnChunk, ProcessSpawnResult>>()
588const cancelled = new Set<string>()
589const stoppers = new Map<string, () => void>()
590/** Rounds asked to stop, before or while they run. */
591const stopping = new Set<string>()
592/** Runs whose bridge is up, by round: their inbox takes notes and stops. */
593const bridges = new Map<string, { pid?: number }>()
594/** Each run's own inbox, by round: a new folder every time, so a retry never shares one with the run it replaces. */
595const inboxes = new Map<string, string>()
596/** How long a stopped bridge gets to end Codex's turn cleanly (it takes up to about 6.5 s) before it is killed. */
597const STOP_GRACE_MS = 9000
598
599export function isRunning(key: string) {
600  return running.has(key)
601}
602
603/** A fresh inbox for a round's next run. (Its folder name keeps only characters every system allows: a picture's
604 * round is "image:…", and Windows refuses a ":" in a folder name.) */
605function newInbox(id: string) {
606  const path = joinPath(shared.dir || joinPath(host.home, '.claude', 'claudex'), 'runs', `${id.replace(/[<>:"/\\|?*]/g, '-')}-${shortId().slice(0, 6)}`)
607  inboxes.set(id, path)
608  return path
609}
610
611/** Puts one note or stop in a running round's inbox; false when its bridge is not up. */
612async function toInbox($: Engine, id: string, body: Record<string, unknown>) {
613  const inbox = inboxes.get(id)
614  if (!bridges.has(id) || !inbox) return false
615  try {
616    await $.fs.write(joinPath(inbox, `${Date.now()}-${shortId()}.json`), JSON.stringify(body))
617    return true
618  } catch {
619    return false
620  }
621}
622
623export function cancelRun(key: string) {
624  const stream = running.get(key)
625  if (!stream) return false
626  cancelled.add(key)
627  // End the wait at once; runCodex then ends the child (a bridge gets a moment
628  // to end Codex's turn cleanly first).
629  if (!bridges.has(key)) endings.set(key, stream.return(undefined as never).then(() => undefined, () => undefined))
630  stoppers.get(key)?.()
631  return true
632}
633
634/** Children told to end with no bridge to wind them down: the run holds its round until they have (or a moment has passed). */
635const endings = new Map<string, Promise<void>>()
636const END_WAIT_MS = 5000
637
638export async function runCodex($: Engine, key: string, argv: string[], cwd: string, input: string, on: CodexEvents): Promise<CodexOutcome> {
639  const outcome: CodexOutcome = { stderr: '', cancelled: false }
640  try {
641    const stream = $.process.spawn({ argv, cwd, input })
642    running.set(key, stream)
643    let stop = new Promise<'stop'>(resolve => stoppers.set(key, () => resolve('stop')))
644    let buffer = ''
645    // One read in flight at a time: a stop that wins the race leaves it pending, and the next turn of the loop takes its result.
646    let reading: ReturnType<typeof stream.next> | undefined
647    for (;;) {
648      reading ??= stream.next()
649      const step = await Promise.race([reading, stop])
650      if (step === 'stop') {
651        // A bridge was told to stop through its inbox: it interrupts Codex's turn and leaves, and the run
652        // stays this round's until it has (so a retry never meets it). Should it not, it goes after a grace period.
653        if (!bridges.has(key)) {
654          const ended = endings.get(key) ?? stream.return(undefined as never).then(() => undefined, () => undefined)
655          const gone = await Promise.race([ended.then(() => true), new Promise<boolean>(done => $.clock.after(END_WAIT_MS, () => done(false)))])
656          // Not gone yet: the run reports Stopped now, and its round stays locked (see run) until the process has.
657          if (gone) endings.delete(key)
658          else endings.set(key, ended)
659          break
660        }
661        $.clock.after(STOP_GRACE_MS, () => void stream.return(undefined as never).catch(() => undefined))
662        stop = new Promise<'stop'>(() => undefined)
663        continue
664      }
665      reading = undefined
666      if (step.done) {
667        outcome.exit = step.value
668        break
669      }
670      if (step.value.stream === 'stderr') {
671        outcome.stderr = (outcome.stderr + step.value.text).slice(-4000)
672        continue
673      }
674      buffer += step.value.text
675      for (let nl = buffer.indexOf('\n'); nl >= 0; nl = buffer.indexOf('\n')) {
676        const line = buffer.slice(0, nl).trim()
677        buffer = buffer.slice(nl + 1)
678        if (!line.startsWith('{')) continue
679        let ev: any
680        try {
681          ev = JSON.parse(line)
682        } catch {
683          continue
684        }
685        const type = String(ev?.type ?? '')
686        const item = ev?.item ?? {}
687        const kind = String(item.type ?? '')
688        const id = String(item.id ?? Date.now())
689        if (type === 'thread.started' && ev.thread_id) await on.thread?.(String(ev.thread_id))
690        else if (type === 'bridge.started') {
691          bridges.set(key, { pid: typeof ev.pid === 'number' ? ev.pid : undefined })
692          await on.bridge?.()
693        } else if (type === 'turn.started') await on.turnStarted?.()
694        else if (kind === 'command_execution' && type === 'item.started') await on.commandStarted?.(id, String(item.command ?? ''))
695        else if (kind === 'command_execution' && type === 'item.completed') await on.commandDone?.(id, item.exit_code ?? null, item.output ? String(item.output) : undefined)
696        else if (type === 'message.live' && ev.text) await on.live?.(String(ev.item_id ?? ''), String(ev.text))
697        else if (type === 'message.writing') await on.writing?.(Number(ev.chars) || 0)
698        else if (type === 'command.output') await on.output?.(String(ev.item_id ?? ''), String(ev.tail ?? ''))
699        else if (type === 'tool.progress' && ev.message) await on.toolProgress?.(String(ev.item_id ?? ''), String(ev.message))
700        else if (type === 'notice' && ev.text) await on.notice?.(String(ev.kind ?? 'warning'), String(ev.text))
701        else if (type === 'model.rerouted') await on.rerouted?.(String(ev.from ?? ''), String(ev.to ?? ''), String(ev.reason ?? ''))
702        else if (type === 'thread.settings') {
703          await on.settings?.({
704            ...(ev.model ? { model: String(ev.model) } : {}),
705            ...(ev.tier ? { tier: String(ev.tier) } : {}),
706            instructions: Array.isArray(ev.instructions) ? ev.instructions.map(String) : [],
707          })
708        } else if (type === 'codex.version' && ev.version) host.version = String(ev.version)
709        else if (kind === 'image_generation' && type === 'item.started') await on.imageStarted?.()
710        else if (type === 'image.generated') {
711          await on.image?.({
712            savedPath: ev.saved_path ? String(ev.saved_path) : null,
713            revisedPrompt: ev.revised_prompt ? String(ev.revised_prompt) : null,
714            transparent: ev.transparent === true,
715            failure: ev.failure ? String(ev.failure) : null,
716          })
717        }
718        else if (kind === 'file_change' && type === 'item.completed') {
719          const changes = (Array.isArray(item.changes) ? item.changes : []).map((c: any) => ({ path: String(c.path ?? ''), kind: String(c.kind ?? 'update') }))
720          await on.fileChange?.(id, changes)
721        } else if (kind === 'reasoning' && type === 'item.completed') await on.reasoning?.(id, String(item.text ?? ''))
722        else if (type === 'reasoning.live' && ev.text) await on.thinking?.(String(ev.item_id ?? 'live'), String(ev.text))
723        else if (kind === 'agent_message' && type === 'item.completed') await on.message?.(id, String(item.text ?? ''))
724        else if (kind === 'todo_list') {
725          const items = Array.isArray(item.items) ? item.items : []
726          await on.todo?.(items.map((t: any) => ({ text: String(t.text ?? ''), done: Boolean(t.completed), now: t.status === 'inProgress' || undefined })))
727        } else if (type === 'diff.updated' && Array.isArray(ev.files)) {
728          await on.diff?.(ev.files.map((f: any) => ({ path: String(f.path ?? ''), added: Number(f.added) || 0, removed: Number(f.removed) || 0 })))
729        } else if (type === 'steer.sent' || type === 'steer.failed') await on.steer?.(String(ev.id ?? ''), type === 'steer.sent', ev.error ? String(ev.error) : undefined)
730        else if (type === 'question' && ev.call_id) await on.question?.(String(ev.call_id), String(ev.question ?? ''), ev.later === true)
731        else if (type === 'answered' && ev.call_id) await on.answered?.(String(ev.call_id), ev.closed ? 'closed' : ev.timeout ? 'timeout' : 'answered')
732        else if (type === 'told' && ev.text) await on.told?.(String(ev.call_id ?? Date.now()), String(ev.text))
733        else if (type === 'permit' && ev.call_id) await on.permit?.(permitOfEvent(ev))
734        else if (type === 'permit.settled' && ev.call_id) {
735          await on.permitSettled?.(String(ev.call_id), ev.allow === true, Boolean(ev.timeout), ev.persist === 'session' || ev.persist === 'always' ? ev.persist : undefined)
736        }
737        else if (type === 'limits.updated' && ev.rate_limits) await on.limits?.(ev.rate_limits)
738        else if (type === 'turn.interrupted') cancelled.add(key)
739        else if (kind === 'web_search' && type === 'item.started') await on.webSearch?.(id, String(item.query ?? ''))
740        else if (kind === 'mcp_tool_call' && type === 'item.started') await on.tool?.(id, `${item.server ?? 'tool'} · ${item.tool ?? ''}`)
741        else if (type === 'turn.completed') {
742          const u = ev.usage ?? {}
743          await on.usage?.({
744            input: Number(u.input_tokens ?? 0),
745            cached: Number(u.cached_input_tokens ?? 0),
746            output: Number(u.output_tokens ?? 0),
747            reasoning: Number(u.reasoning_output_tokens ?? 0),
748          })
749        } else if (type === 'turn.failed') outcome.failure = String(ev.error?.message ?? 'Codex could not finish the turn.')
750        else if (type === 'error') outcome.reported = String(ev.message ?? '')
751      }
752    }
753  } catch (err) {
754    outcome.failure = message(err)
755  } finally {
756    running.delete(key)
757    stoppers.delete(key)
758    bridges.delete(key)
759  }
760  outcome.cancelled = cancelled.delete(key)
761  return outcome
762}
763
764/** Why a run that produced nothing usable failed, in one line. */
765export function failureOf(o: CodexOutcome, fallback: string) {
766  if (o.failure) return o.failure
767  if (o.exit?.code === 0) return fallback
768  const lastLine = o.stderr
769    .split(/\r?\n/)
770    .map(l => l.trim())
771    .filter(l => l && !/rmcp::|WARN|INFO/.test(l))
772    .pop()
773  return o.reported || lastLine || `Codex stopped (exit ${o.exit?.code ?? o.exit?.signal}).`
774}
775
776// ---------------------------------------------------------------------------
777// A clock for the band while anything runs
778
779let ticker: Timer | undefined
780
781export function ensureTicker($: Engine, busy?: () => Promise<boolean>) {
782  void busy
783  if (ticker) return
784  ticker = $.clock.every(1000, () => {
785    void (async () => {
786      if (!(await anyRunning($)) && !(await anyImageActive($))) {
787        ticker?.cancel()
788        ticker = undefined
789        return
790      }
791      await update($, now, () => Date.now())
792      await flagStalls($)
793    })().catch(() => undefined)
794  })
795}
796
797// ---------------------------------------------------------------------------
798// Telling Claude when background work lands: appended into a running turn,
799// or submitted as a prompt once the session is idle.
800
801/** A message for Claude, and the round whose news it carries (marked delivered once Claude has it). */
802type Handoff = { text: string; about?: string }
803const notes: (Handoff & { seen: boolean })[] = []
804let pendingPrompt: Handoff[] = []
805let flushing = false
806let flushFailures = 0
807let promptTimer: Timer | undefined
808
809/**
810 * What the app did with the mod's tools at this start, in start.json beside the chats: any it refused
811 * (and why), and the descriptions it now holds, which are what Claude reads. A refusal is also a toast.
812 */
813async function noteToolsStart($: Engine, refused: string[]) {
814  let held: { name: string; description: string }[] = []
815  try {
816    held = (await $.tool.list()).filter(t => t.name.startsWith('mcp__claudex__')).map(t => ({ name: t.name, description: cut(t.description, 160) }))
817  } catch {}
818  try {
819    await $.fs.write(joinPath(shared.dir || joinPath(host.home, '.claude', 'claudex'), 'start.json'), JSON.stringify({ at: new Date().toISOString(), refused, held }, null, 1))
820  } catch {}
821  if (refused.length) $.ui.toast(`Codex: the app kept ${plural(refused.length, 'tool')} as they were (${cut(refused[0] ?? '', 90)})`, { timeoutMs: 10_000 })
822}
823
824/** What a message to Claude is, for its row in the chat: a round's news, Codex asking or telling, or a heads-up. */
825export type RowKind = 'result' | 'ask' | 'told' | 'heard' | 'plan' | 'clash' | 'quiet' | 'undo' | 'pictures'
826/** A message's round, and the question or message of Codex's it carries. */
827type RowMeta = { kind: RowKind; id: string; sub?: string }
828
829const ROW_KINDS = new Set<string>(['result', 'ask', 'told', 'heard', 'plan', 'clash', 'quiet', 'undo', 'pictures'])
830/** Where a message to Claude starts: "[claudex]", or "[claudex <kind> <round> <question or message>]". */
831const ROW_TAG = /^\[claudex(?: ([a-z]+) (\S+)(?: (\S+))?)?\] ?/
832const isCodexRow = (text: string) => ROW_TAG.test(text)
833const tagWord = (text: string) => text.replace(/[\s\]]+/g, '')
834
835/** What a message to Claude is about, read from its own first words, so its row survives restarts and other chats. */
836function rowMeta(text: string): RowMeta {
837  const m = ROW_TAG.exec(text)
838  if (!m?.[1] || !ROW_KINDS.has(m[1])) return { kind: 'result', id: '' }
839  return { kind: m[1] as RowKind, id: m[2] === '-' ? '' : (m[2] ?? ''), ...(m[3] ? { sub: m[3] } : {}) }
840}
841
842export async function tellClaude($: Engine, text: string, about?: string, row?: RowKind, sub?: string) {
843  text = text.replace(/^\[claudex\] /, `[claudex ${row ?? 'result'} ${about ? tagWord(about) : '-'}${sub ? ` ${tagWord(sub)}` : ''}] `)
844  if (host.turnRunning) {
845    try {
846      await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
847      notes.push({ text, about, seen: false })
848      await markHanded($, about ? [about] : [])
849      return
850    } catch {}
851  }
852  pendingPrompt.push({ text, about })
853  promptTimer?.cancel()
854  promptTimer = $.clock.after(1200, () => {
855    promptTimer = undefined
856    flushPrompt($)
857  })
858}
859
860/** Rounds whose news reached Claude: no longer "waiting for Claude". */
861async function markHanded($: Engine, ids: string[]) {
862  for (const id of ids) await patch($, id, c => (c.handoff === 'pending' ? { ...c, handoff: 'done' } : c))
863  if (ids.length) await persist($)
864}
865
866export function flushPrompt($: Engine) {
867  if (host.turnRunning || flushing || !pendingPrompt.length) return
868  const sent = pendingPrompt
869  pendingPrompt = []
870  flushing = true
871  void Promise.resolve()
872    .then(() => $.prompt.submit({ text: sent.map(m => m.text).join('\n\n') }))
873    .then(async entered => {
874      // Dropped on the way in counts as not delivered.
875      if (entered?.drop) throw new Error(entered.drop)
876      flushFailures = 0
877      await markHanded($, sent.flatMap(m => (m.about ? [m.about] : [])))
878    })
879    .catch(() => {
880      // Not taken (a dialog up, the composer busy): first in line again, and tried again shortly.
881      pendingPrompt = [...sent, ...pendingPrompt]
882      flushFailures++
883      if (flushFailures === 3) $.ui.toast("Codex's news hasn't reached Claude yet; it goes as soon as Claude is free", { timeoutMs: 6000 })
884      promptTimer?.cancel()
885      promptTimer = $.clock.after(Math.min(30_000, 2000 * flushFailures), () => {
886        promptTimer = undefined
887        flushPrompt($)
888      })
889    })
890    .finally(() => {
891      flushing = false
892    })
893}
894
895export function notesSeen() {
896  for (const n of notes) n.seen = true
897}
898
899/** At the end of a turn: notes Claude never read go out as a prompt. */
900export function turnEnded($: Engine) {
901  const unseen = notes.filter(n => !n.seen).map(n => ({ text: n.text, about: n.about }))
902  notes.length = 0
903  pendingPrompt.push(...unseen)
904  flushPrompt($)
905}
906
907export async function askClaude($: Engine, text: string) {
908  try {
909    const entered = await $.prompt.submit({ text, asUser: true })
910    if (entered?.drop) throw new Error(entered.drop)
911    $.ui.toast('Sent to Claude')
912  } catch (err) {
913    try {
914      $.ui.toast(`Could not send that to Claude: ${cut(message(err), 80)}`)
915    } catch {}
916  }
917}
918
919// ---------------------------------------------------------------------------
920// The pane
921
922export async function openPane($: Engine, fields?: Partial<View>) {
923  if (fields) await update($, view, v => ({ ...v, ...fields }))
924  // Opened by the person, so the pane takes the keyboard: the first click
925  // inside it then presses instead of focusing.
926  try {
927    await $.ui.open({ id: PANE, title: 'Claudex', focus: true })
928  } catch {
929    // A surface that cannot place the pane leaves the cards as they are.
930  }
931}
932
933export async function setView($: Engine, fields: Partial<View>) {
934  await update($, view, v => ({ ...v, ...fields }))
935}
936
937// ---------------------------------------------------------------------------
938// History from the two mods this one replaces (codex-review, codex-images),
939// read once from their stores beside this plugin's own.
940
941/** Claudex replaces the Codex mod (2.x): its saved history, picks and chats' files are copied over once.
942 *  Copied, never moved: the old mod's folder stays as it was. */
943export async function migrateFromCodex($: Engine) {
944  if (!host.home || (await $.store.get('fromCodex'))) return
945  const store = joinPath(host.home, '.claude', 'plugins', 'store')
946  // Safe to run again: what is here already is never copied over.
947  let ok = true
948  try {
949    const files = (await $.fs.list(store)).filter(f => f.kind === 'file' && /^codex_.+\.json$/.test(f.name))
950    // Several installs of it (one per folder it was loaded from): the one with the most history.
951    let best: Record<string, unknown> | undefined
952    for (const f of files) {
953      let text: string
954      try {
955        text = await $.fs.read(joinPath(store, f.name))
956      } catch {
957        // Unreadable now (locked, say): try again next start.
958        ok = false
959        continue
960      }
961      try {
962        const old = JSON.parse(text) as Record<string, unknown>
963        if (!best || ((old.consults as unknown[] | undefined)?.length ?? 0) > ((best.consults as unknown[] | undefined)?.length ?? 0)) best = old
964      } catch {
965        // Not JSON: never will be; skipped for good.
966      }
967    }
968    // Only once every one could be read: a smaller one taken now would block the larger one later.
969    if (ok) {
970      for (const key of ['consults', 'images', 'usage', 'prefs', 'rows', 'jobs', 'migrated']) {
971        if (best?.[key] !== undefined && (await $.store.get(key)) === undefined) await $.store.set(key, best[key])
972      }
973    }
974  } catch {
975    ok = false
976  }
977  const from = joinPath(host.home, '.claude', 'codex-mod')
978  const to = joinPath(host.home, '.claude', 'claudex')
979  try {
980    // No Codex mod folder (a fresh install): nothing to list, nothing to copy.
981    let old: Awaited<ReturnType<Engine['fs']['list']>> = []
982    try {
983      old = await $.fs.list(from)
984    } catch {}
985    const chatsThere = old.filter(f => f.kind === 'file' && /^chat-.+\.json$/.test(f.name))
986    if (chatsThere.length) {
987      if (!(await $.fs.exists(to))) await $.process.run(host.windows ? ['cmd', '/c', 'mkdir', to] : ['mkdir', '-p', to])
988      for (const f of chatsThere) {
989        if (f.kind !== 'file' || !/^chat-.+\.json$/.test(f.name)) continue
990        const target = joinPath(to, f.name)
991        try {
992          if (!(await $.fs.exists(target))) await $.fs.write(target, await $.fs.read(joinPath(from, f.name)))
993        } catch {
994          ok = false
995        }
996      }
997    }
998  } catch {
999    ok = false
1000  }
1001  if (ok) await $.store.set('fromCodex', true)
1002}
1003
1004export async function migrate($: Engine) {
1005  if (await $.store.get('migrated')) return
1006  const home = host.home
1007  if (!home) return
1008  const dir = joinPath(home, '.claude', 'plugins', 'store')
1009  try {
1010    const files = await $.fs.list(dir)
1011    for (const f of files) {
1012      if (f.kind !== 'file') continue
1013      if (f.name.startsWith('codex-review_')) {
1014        const old = JSON.parse(await $.fs.read(joinPath(dir, f.name))) as { consults?: Consult[]; usage?: Usage }
1015        if (old.consults?.length) {
1016          const known = new Set((await read($, consults)).map(c => c.id))
1017          const fresh = old.consults.filter(c => !known.has(c.id))
1018          await update($, consults, list => [...list, ...fresh])
1019        }
1020        if (old.usage && !(await read($, usage))) await update($, usage, () => old.usage as Usage)
1021      }
1022      if (f.name.startsWith('codex-images_')) {
1023        const old = JSON.parse(await $.fs.read(joinPath(dir, f.name))) as { items?: Image[] }
1024        if (old.items?.length) {
1025          const known = new Set((await read($, images)).map(i => i.id))
1026          const fresh = old.items.filter(i => !known.has(i.id))
1027          await update($, images, list => [...list, ...fresh])
1028        }
1029      }
1030    }
1031  } catch {}
1032  // Saved at once: a session may end before anything else would save them.
1033  await persist($)
1034  await persistImages($)
1035  const reading = await read($, usage)
1036  if (reading) await $.store.set('usage', reading)
1037  await $.store.set('migrated', true)
1038}
1039
1040// ===========================================================================
1041// Conversations (reviews, plans, questions, replies, re-checks) and tasks in their own worktree.
1042// ===========================================================================
1043
1044// Conversations with Codex (reviews, plan reviews, questions, replies and
1045// re-checks) and tasks, where Codex does the work in a git worktree of its
1046// own and hands back a diff to apply or discard.
1047
1048export const ASK = 'mcp__claudex__ask'
1049export const TRIAGE = 'mcp__claudex__triage'
1050export const TASK = 'mcp__claudex__task'
1051export const APPLY = 'mcp__claudex__apply'
1052export const STOP = 'mcp__claudex__stop'
1053export const TELL = 'mcp__claudex__tell'
1054
1055const KEEP_THREADS = 40
1056const KEEP_STEPS = 140
1057
1058export const RUNNING = new Set<Status>(['sending', 'thinking', 'reading'])
1059/** Running, or waiting for a free slot. */
1060export const WAITING = new Set<Status>(['queued', 'sending', 'thinking', 'reading'])
1061const MAX_RUNS = 2
1062/** How long a run may be quiet before Claude checks in on it (and again each time after). */
1063const STALL_MS = 10 * 60_000
1064
1065export const KIND_LABEL: Record<Kind, string> = {
1066  review: 'Code review',
1067  plan: 'Plan review',
1068  question: 'Question',
1069  debug: 'Debugging',
1070  joint: 'Joint review',
1071  compare: 'Comparing notes',
1072  visual: 'Visual review',
1073  reply: 'Reply',
1074  recheck: 'Re-check',
1075  task: 'Task',
1076}
1077
1078export const STATUS_TEXT: Record<Status, string> = {
1079  queued: 'Waiting for a free Codex slot',
1080  sending: 'Sending the request to Codex',
1081  thinking: 'Codex is thinking',
1082  reading: 'Codex is working',
1083  done: 'Codex answered',
1084  failed: 'Codex could not finish',
1085  stopped: 'Stopped',
1086}
1087
1088export const VERDICT_LABEL: Record<Verdict, string> = {
1089  approve: 'Approve',
1090  approve_with_nits: 'Approve with nits',
1091  changes_needed: 'Changes needed',
1092  rethink: 'Rethink',
1093  answer: 'Answer',
1094}
1095
1096export const LENSES: Record<Lens, { label: string; words: string }> = {
1097  general: { label: 'Everything', words: '' },
1098  correctness: {
1099    label: 'Correctness',
1100    words: 'Look hardest at correctness: logic errors, edge cases, broken callers, wrong data, races and error handling.',
1101  },
1102  security: {
1103    label: 'Security',
1104    words: 'Look hardest at security: injection, auth and permission gaps, secrets, unsafe input handling, data exposure.',
1105  },
1106  performance: {
1107    label: 'Performance',
1108    words: 'Look hardest at performance: needless work on hot paths, re-renders, N+1 calls, memory growth, blocking I/O.',
1109  },
1110  tests: {
1111    label: 'Tests',
1112    words: 'Look hardest at tests: what the change leaves untested, tests that cannot fail, and the cases worth adding.',
1113  },
1114  simplicity: {
1115    label: 'Simplicity',
1116    words: 'Look hardest at simplicity: code that can be removed, merged or made plainer without changing behaviour.',
1117  },
1118  ux: {
1119    label: 'UX',
1120    words: 'Look hardest at the experience: what a person sees and does, unclear states, accessibility, copy and layout.',
1121  },
1122}
1123
1124export const POSITION = { concede: 'Codex concedes', hold: 'Codex holds', refine: 'Codex refines' } as const
1125export const MATCH = { agree: 'Codex agrees', disagree: 'Codex disagrees', refine: 'Codex refines' } as const
1126/** Debate rounds Claude may start in a joint review after comparing notes. */
1127const JOINT_ROUNDS = 3
1128
1129// ---------------------------------------------------------------------------
1130// The list
1131
1132export async function all($: Engine) {
1133  return read($, consults)
1134}
1135
1136export async function find($: Engine, id: string) {
1137  return (await all($)).find(c => c.id === id)
1138}
1139
1140export async function patch($: Engine, id: string, fn: (c: Consult) => Consult) {
1141  await update($, consults, list => list.map(c => (c.id === id ? fn(c) : c)))
1142}
1143
1144export function threadOf(list: Consult[], thread: string) {
1145  return list.filter(c => c.thread === thread).sort((a, b) => a.round - b.round)
1146}
1147
1148export function threadFindings(list: Consult[], thread: string) {
1149  return threadOf(list, thread).flatMap(c => c.findings)
1150}
1151
1152/** A task's current version: its latest round (earlier ones were sent back with notes). */
1153export function latestTask(list: Consult[], thread: string) {
1154  return [...threadOf(list, thread)].reverse().find(c => c.task)
1155}
1156
1157/** The most rounds a task outside a build loop takes. */
1158const MAX_TASK_ROUNDS = 4
1159
1160/**
1161 * A build loop runs until both agree the work is done. Every this many rounds
1162 * without that, Claude checks in with the person before Codex runs again.
1163 */
1164const CHECK_IN_ROUNDS = 6
1165
1166/** Where a build loop stands: the round, whose turn it is, and whether it is over. */
1167export function loopState(list: Consult[], thread: string) {
1168  const rounds = threadOf(list, thread)
1169  const head = rounds[0]
1170  const loop = head?.loop
1171  if (!head || !loop) return undefined
1172  const last = rounds[rounds.length - 1] as Consult
1173  const running = WAITING.has(last.status)
1174  const stuck = last.status === 'failed' || last.status === 'stopped'
1175  type Turn = 'codex' | 'claude' | 'person' | 'none'
1176  if (loop.builder === 'codex') {
1177    // Codex builds: each task round is one version; Claude reviews between them.
1178    const state = last.task?.state
1179    const approved = state === 'applied'
1180    const over = approved || state === 'discarded' || state === 'undone'
1181    const round = rounds.length
1182    const turn: Turn = running ? 'codex' : over ? 'none' : stuck ? 'person' : 'claude'
1183    const step = over ? 3 : turn === 'claude' ? 1 : round > 1 ? 2 : 0
1184    return { loop, head, last, round, turn, over, approved, step }
1185  }
1186  // Claude builds: Codex's review and its re-checks are the rounds; Claude fixes between them.
1187  const reviews = rounds.filter(c => c.kind === 'review' || c.kind === 'recheck')
1188  const lastReview = reviews[reviews.length - 1]
1189  const approved = lastReview?.status === 'done' && (lastReview.verdict === 'approve' || lastReview.verdict === 'approve_with_nits')
1190  const round = reviews.length
1191  const turn: Turn = running ? 'codex' : approved ? 'none' : stuck ? 'person' : 'claude'
1192  const step = approved ? 3 : running ? 1 : 2
1193  return { loop, head, last, round, turn, over: approved, approved, step }
1194}
1195
1196type LoopState = NonNullable<ReturnType<typeof loopState>>
1197
1198/** Whether starting this round needs the person's go-ahead first: a check-in every few rounds. */
1199function checkInDue(round: number) {
1200  return round > 1 && (round - 1) % CHECK_IN_ROUNDS === 0
hooks/art.ts 2273 lines
1// The pictures of the Codex mod, drawn as SVG so they look the same on every
2// theme: a small dark stage where Claude (or you) and Codex work together,
3// one scene per kind of request (a review, a task, a picture), plus status
4// chips, usage gauges and the Claude-and-Codex mark. Each is drawn at the size
5// it is shown, never scaled down. The stage is interactive so its SMIL
6// animation runs; the rest are plain images.
7
8export const CLAUDE = '#D97757'
9export const CODEX = '#8B93F8'
10export const GOOD = '#3fb950'
11export const WARN = '#d4a72c'
12/** Tasks: Codex writing code in its own copy of the repo, apart from Codex talking (its blue). */
13export const BUILD = '#2FB7A6'
14
15/** What a job is: a conversation (Codex reads and answers) or a task (Codex writes code). */
16export type Family = 'talk' | 'build'
17
18/** A family's colour: Codex's blue for a conversation, the build teal for a task. */
19export function familyColour(family: Family | undefined) {
20  return family === 'build' ? BUILD : CODEX
21}
22
23/**
24 * A task's ground: faint blueprint squares and a build-coloured edge along
25 * the top, so a job where Codex writes code never looks like a conversation.
26 */
27function buildGround(id: string, W: number, H: number) {
28  return `<pattern id="${id}-grid" width="14" height="14" patternUnits="userSpaceOnUse"><path d="M14 0H0V14" fill="none" stroke="${BUILD}" stroke-opacity="0.12"/></pattern><rect width="${W}" height="${H}" fill="url(#${id}-grid)"/><rect width="${W}" height="2.5" fill="${BUILD}" opacity="0.9"/>`
29}
30
31/** The part of the day, by the local hour: it tints the top of every card. */
32export type Sky = 'dawn' | 'day' | 'dusk' | 'night'
33
34export function skyOf(hour = new Date().getHours()): Sky {
35  if (hour >= 5 && hour < 8) return 'dawn'
36  if (hour >= 8 && hour < 17) return 'day'
37  if (hour >= 17 && hour < 21) return 'dusk'
38  return 'night'
39}
40
41const SKY_TINT: Record<Sky, [string, number]> = { dawn: ['#7a4658', 0.42], day: ['#24507a', 0.36], dusk: ['#683a66', 0.44], night: ['#2a3478', 0.5] }
42
43/** A night sky's stars: the same scatter for the same size of card, a few of them twinkling. */
44function stars(W: number, H: number) {
45  let seed = ((W * 31 + H * 17) % 9973) + 1
46  const rand = () => (seed = (seed * 16807) % 2147483647) / 2147483647
47  const count = Math.round(W / 42)
48  let out = ''
49  for (let i = 0; i < count; i++) {
50    const x = (W * (0.04 + rand() * 0.92)).toFixed(1)
51    const y = (3 + rand() * H * 0.36).toFixed(1)
52    const r = (0.45 + rand() * 0.65).toFixed(2)
53    const o = (0.25 + rand() * 0.4).toFixed(2)
54    const twinkle = i % 4 === 1 ? `<animate attributeName="opacity" values="${o};0.08;${o}" dur="${(2.4 + rand() * 2).toFixed(1)}s" begin="${(rand() * 2).toFixed(1)}s" repeatCount="indefinite"/>` : ''
55    out += `<circle cx="${x}" cy="${y}" r="${r}" fill="#e8ecff" opacity="${o}">${twinkle}</circle>`
56  }
57  return out
58}
59
60/**
61 * The ground a card stands on, inside its rounded clip: a deep gradient, the
62 * sky of the hour across the top (stars at night), a vignette, and a soft
63 * light that sweeps across once as the card arrives. `defs` go in the SVG's
64 * defs; `ground` is drawn first, inside the card's clip.
65 */
66function cardGround(id: string, W: number, H: number, o: { sheen?: boolean; sky?: Sky } = {}) {
67  const sky = o.sky ?? skyOf()
68  const [tint, strength] = SKY_TINT[sky]
69  const defs =
70    `<linearGradient id="${id}-base" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1c1f27"/><stop offset="1" stop-color="#111317"/></linearGradient>` +
71    `<linearGradient id="${id}-sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${tint}" stop-opacity="${strength}"/><stop offset="0.6" stop-color="${tint}" stop-opacity="0"/></linearGradient>` +
72    `<radialGradient id="${id}-vig" cx="0.5" cy="0.42" r="0.78"><stop offset="0.55" stop-color="#000" stop-opacity="0"/><stop offset="1" stop-color="#000" stop-opacity="0.34"/></radialGradient>` +
73    `<linearGradient id="${id}-sheen" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.075"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>` +
74    `<linearGradient id="${id}-rim" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3c414e"/><stop offset="1" stop-color="#22252c"/></linearGradient>`
75  const sweep = o.sheen === false ? '' : `<rect x="${-W * 0.5}" y="-10" width="${(W * 0.32).toFixed(0)}" height="${H + 20}" fill="url(#${id}-sheen)" transform="skewX(-18)"><animate attributeName="x" from="${(-W * 0.5).toFixed(0)}" to="${(W * 1.35).toFixed(0)}" begin="0.15s" dur="1.5s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.4 0 0.2 1"/></rect>`
76  const ground = `<rect width="${W}" height="${H}" fill="url(#${id}-base)"/><rect width="${W}" height="${H}" fill="url(#${id}-sky)"/>${sky === 'night' ? stars(W, H) : ''}<rect width="${W}" height="${H}" fill="url(#${id}-vig)"/>`
77  return { defs, ground, sweep, rim: `url(#${id}-rim)` }
78}
79
80/**
81 * The tag naming what runs, at (x, y): round with a speech bubble for a
82 * conversation; square with a code mark for a task. Its width comes back with it.
83 */
84function tagPill(x: number, y: number, text: string, colour: string, family: Family | undefined, h = 17) {
85  const build = family === 'build'
86  const label = text.toUpperCase()
87  const mark = 13
88  const w = label.length * 6.4 + 14 + mark
89  const cy = y + h / 2
90  const glyph = build
91    ? `<path d="M${x + 9} ${cy - 3}l-2.6 3 2.6 3M${x + 13} ${cy - 3}l2.6 3-2.6 3" fill="none" stroke="${colour}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`
92    : `<path d="M${x + 7.6} ${cy - 3.6}h6a1.6 1.6 0 0 1 1.6 1.6v2.6a1.6 1.6 0 0 1-1.6 1.6h-3.4l-2.2 2v-2h-0.4a1.6 1.6 0 0 1-1.6-1.6v-2.6a1.6 1.6 0 0 1 1.6-1.6z" fill="${colour}"/>`
93  const svg =
94    `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${build ? 3.5 : h / 2}" fill="${colour}" fill-opacity="0.16" stroke="${colour}" stroke-opacity="${build ? 0.7 : 0.45}"/>` +
95    glyph +
96    `<text x="${x + mark + (w - mark) / 2}" y="${y + h / 2 + 3.4}" text-anchor="middle" font-size="9.5" font-weight="750" letter-spacing="0.6" fill="${colour}">${escape(label)}</text>`
97  return { w, svg }
98}
99
100export const BAD = '#e5534b'
101
102const STAGE = '#16181d'
103const EDGE = '#2b2f37'
104const INK = '#ecebe8'
105const MUTED = '#9aa0a8'
106const FAINT = '#5d636d'
107const FONT = 'Inter, Segoe UI, system-ui, sans-serif'
108
109const VERDICT_COLOURS: Record<string, string> = {
110  approve: GOOD,
111  approve_with_nits: '#7fbf5a',
112  changes_needed: WARN,
113  rethink: BAD,
114  answer: CODEX,
115}
116
117export function verdictColour(verdict: string | undefined) {
118  return (verdict && VERDICT_COLOURS[verdict]) || CODEX
119}
120
121export function escape(text: string) {
122  return text.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] as string)
123}
124
125export function fit(text: string, max: number) {
126  const t = text.replace(/\s+/g, ' ').trim()
127  return t.length > max ? `${t.slice(0, Math.max(1, max - 1)).trimEnd()}…` : t
128}
129
130// ---------------------------------------------------------------------------
131// Avatars and icons
132
133export function claudeAvatar(cx: number, cy: number, r: number) {
134  const ray = r * 0.42
135  const d = ray * Math.SQRT1_2
136  return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${CLAUDE}"/><g stroke="#fff" stroke-width="${(r / 8.5).toFixed(2)}" stroke-linecap="round"><path d="M${cx} ${cy - ray}V${cy + ray}M${cx - ray} ${cy}H${cx + ray}M${cx - d} ${cy - d}L${cx + d} ${cy + d}M${cx + d} ${cy - d}L${cx - d} ${cy + d}"/></g>`
137}
138
139export function personAvatar(cx: number, cy: number, r: number) {
140  return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${CLAUDE}" fill-opacity="0.85"/><circle cx="${cx}" cy="${cy - r * 0.25}" r="${r * 0.3}" fill="#fff"/><path d="M${cx - r * 0.5} ${cy + r * 0.58}a${r * 0.5} ${r * 0.42} 0 0 1 ${r} 0" fill="#fff"/>`
141}
142
143function hexagon(cx: number, cy: number, r: number) {
144  return Array.from({ length: 6 }, (_, i) => {
145    const a = (Math.PI / 3) * i - Math.PI / 2
146    return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`
147  }).join(' ')
148}
149
150export function codexAvatar(cx: number, cy: number, r: number) {
151  const s = r * 0.3
152  return `<polygon points="${hexagon(cx, cy, r * 1.04)}" fill="${CODEX}" stroke="${CODEX}" stroke-width="${(r / 5).toFixed(1)}" stroke-linejoin="round"/><g fill="none" stroke="#fff" stroke-width="${(r / 9).toFixed(2)}" stroke-linecap="round" stroke-linejoin="round"><path d="M${cx - s * 0.9} ${cy - s}l${-s} ${s} ${s} ${s}M${cx + s * 0.9} ${cy - s}l${s} ${s} ${-s} ${s}M${cx + s * 0.35} ${cy - s * 1.25}l${-s * 0.7} ${s * 2.5}"/></g>`
153}
154
155const ICONS = {
156  file: `<path d="M1.5 0.5h5.5l3 3v7.5a0.5 0.5 0 0 1-0.5 0.5h-8a0.5 0.5 0 0 1-0.5-0.5v-10a0.5 0.5 0 0 1 0.5-0.5z" stroke-linejoin="round"/><path d="M4 6h4M4 8.5h4" stroke-linecap="round"/>`,
157  search: `<circle cx="4.8" cy="4.8" r="3.8"/><path d="M7.7 7.7l3 3" stroke-linecap="round"/>`,
158  clock: `<circle cx="5.5" cy="5.5" r="4.8"/><path d="M5.5 2.8v2.9l1.9 1.2" stroke-linecap="round"/>`,
159  coin: `<ellipse cx="5.5" cy="3.2" rx="4.6" ry="2"/><path d="M0.9 3.2v4.4c0 1.1 2 2 4.6 2s4.6-0.9 4.6-2V3.2"/>`,
160  edit: `<path d="M1.2 9.8l0.6-2.6 6-6a1 1 0 0 1 1.4 0l0.6 0.6a1 1 0 0 1 0 1.4l-6 6z" stroke-linejoin="round"/>`,
161  picture: `<rect x="0.6" y="1.2" width="10" height="8.6" rx="1.4"/><circle cx="3.6" cy="4.2" r="1"/><path d="M1 8.6l3-2.6 2 1.6 1.6-1.2 2.6 2.2" stroke-linejoin="round"/>`,
162}
163
164type Icon = keyof typeof ICONS
165
166function icon(name: Icon, x: number, y: number) {
167  return `<g transform="translate(${x} ${y - 9.5})" fill="none" stroke="${MUTED}" stroke-width="1.3">${ICONS[name]}</g>`
168}
169
170/** Icon-and-text items laid out around a centre, measured roughly. */
171/**
172 * Elapsed time that counts up by itself, as m:ss: each digit a column of
173 * numerals stepping on its own beat, starting from `seconds` (the time at the
174 * picture's start). A picture is only drawn again when something changes, so
175 * a clock written as text would stand still between.
176 */
177export function liveClock(seconds: number, x: number, y: number, o: { size: number; colour: string; weight?: number; anchor?: 'start' | 'middle' | 'end'; id: string }) {
178  const t = Math.max(0, seconds)
179  const adv = o.size * 0.62
180  const colon = o.size * 0.34
181  const width = adv * 3 + colon
182  // Set from the left, a clock past ten minutes makes room for its tens of minutes.
183  const left = o.anchor === 'end' ? x - width : o.anchor === 'start' ? x + (t >= 600 ? adv : 0) : x - width / 2
184  const line = o.size * 1.4
185  const top = y - o.size * 1.02
186  const column = (cx: number, digits: string[], step: number) => {
187    const period = step * digits.length
188    const values = digits.map((_, i) => `0 ${(-i * line).toFixed(2)}`).join(';')
189    const numerals = digits.map((d, i) => `<text x="${cx.toFixed(1)}" y="${(y + i * line).toFixed(2)}" text-anchor="middle">${d}</text>`).join('')
190    return `<g><animateTransform attributeName="transform" type="translate" values="${values}" calcMode="discrete" dur="${period}s" begin="${(-(t % period)).toFixed(2)}s" repeatCount="indefinite"/>${numerals}</g>`
191  }
192  const ten = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
193  return (
194    `<clipPath id="${o.id}"><rect x="${(left - adv - 2).toFixed(1)}" y="${top.toFixed(1)}" width="${(width + adv + 4).toFixed(1)}" height="${line.toFixed(1)}"/></clipPath>` +
195    `<g clip-path="url(#${o.id})" font-size="${o.size}" font-weight="${o.weight ?? 600}" fill="${o.colour}">` +
196    // Tens of minutes appear left of the clock once it passes ten minutes.
197    column(left - adv / 2, ['', ...ten.slice(1)], 600) +
198    column(left + adv / 2, ten, 60) +
199    `<text x="${(left + adv + colon / 2).toFixed(1)}" y="${y}" text-anchor="middle">:</text>` +
200    column(left + adv + colon + adv / 2, ten.slice(0, 6), 10) +
201    column(left + adv * 2 + colon + adv / 2, ten, 1) +
202    '</g>'
203  )
204}
205
206/** A row of facts with their icons; a [icon, text, seconds] item is a clock that counts up by itself. */
207function statRow(items: [Icon, string, number?][], cx: number, y: number) {
208  const widths = items.map(([, t]) => 16 + t.length * 6.3 + 16)
209  const total = widths.reduce((a, b) => a + b, 0) - 16
210  let x = cx - total / 2
211  return items
212    .map(([name, text, live], i) => {
213      const label =
214        live !== undefined
215          ? liveClock(live, x + 16, y, { size: 11.5, colour: MUTED, weight: 400, anchor: 'start', id: `clock-${Math.round(cx)}-${Math.round(y)}` })
216          : `<text x="${x + 16}" y="${y}" font-size="11.5" fill="${MUTED}">${escape(text)}</text>`
217      const out = `${icon(name, x, y)}${label}`
218      x += widths[i] as number
219      return out
220    })
221    .join('')
222}
223
224function statWidth(items: [Icon, string, number?][]) {
225  return items.reduce((sum, [, t]) => sum + 16 + t.length * 6.3 + 16, -16)
226}
227
228// ---------------------------------------------------------------------------
229// The stage
230
231export type StagePhase = 'sending' | 'reading' | 'thinking' | 'done' | 'failed'
232
233export type Stage = {
234  kind: 'review' | 'task' | 'image'
235  phase: StagePhase
236  claudeModel: string
237  codexModel: string
238  effort: string
239  headline: string
240  sub?: string
241  subItalic?: boolean
242  /** The stamp's word(s) and colour when done (a verdict, READY TO APPLY …). */
243  stamp?: string
244  stampColour?: string
245  files: number
246  searches: number
247  edits?: number
248  time: string
249  tokens?: string
250  /** Done reviews: findings by severity, and Claude's view of them. */
251  severities?: { high: number; medium: number; low: number }
252  /** The round's findings as they are (their own ids, in order): the card's beads. */
253  findings?: { id: string; severity: 'high' | 'medium' | 'low' }[]
254  consensus?: { agree: number; disagree: number; unsure: number; total: number }
255  /** Done tasks: what the diff holds. */
256  diff?: { files: number; added: number; removed: number }
257  /** Done pictures: the image itself (a JPEG data URI) and its shape. */
258  picture?: { href: string; ratio: number }
259  /** The left seat: Claude, or the person asking from the pane. */
260  you?: boolean
261  /** The steps of this kind of run, the one under way lit. */
262  strip?: { labels: string[]; current: number; done: boolean; failed?: boolean }
263  /** While it runs: the seconds it has run so far, and what its clock shows at the picture's start (it counts on by itself). */
264  seconds?: number
265  clock?: number
266  /** Codex's latest lines, typed out in turn while it works. */
267  notes?: string[]
268  /** How the stamp lands: sparks, a wobble, a slide. */
269  moment?: 'spark' | 'wobble' | 'slide'
270  /** The mascots: the Codex companion's animation, and Clawd's mood. */
271  codexSprite?: Sprite
272  claudeMood?: ClawdMood
273  /** Once it lands: what Codex says, and Claude's answer. */
274  banter?: Banter
275  /** What runs, named in its family's tag in the corner; a task's stage has its own ground. */
276  tag?: string
277  family?: Family
278  /** While it runs: Codex's own plan, a note just handed to it, and a task's diff so far. */
279  plan?: PlanView
280  steer?: SteerView
281  growing?: Growing
282  /** Codex has asked Claude something and waits for the answer. */
283  asking?: boolean
284  /** One of Codex's tools waits on the person's permission. */
285  permit?: boolean
286  /** Drawn to be held still (a chat card while the band plays the same run): nothing that only shows between the beats of a loop. */
287  still?: boolean
288  /** What passed between them while Codex worked. */
289  mail?: Mail
290  /** The sky over the stage; the local hour's when not given. */
291  sky?: Sky
292  /** Its id in the corner (K7Q2, or K7Q2 · 2 for a later round), to refer to it by. */
293  ref?: string
294}
295
296/** Notes Codex got (from Claude or the person), and what Codex told or asked Claude, in order. */
297export type Mail = { toCodex: ('claude' | 'you')[]; toClaude: ('tell' | 'ask')[] }
298
299/** Codex's own plan while it works: each step done, under way or still to come, and the words of the one under way. */
300export type PlanView = { steps: ('done' | 'now' | 'next')[]; now?: string }
301/** A note just passed between them while Codex works: to Codex from the person or Claude, or from Codex to Claude. */
302export type SteerView = {
303  id: string
304  from: 'you' | 'claude' | 'codex'
305  text: string
306  /** Seconds since it was sent, as of the picture's start: a picture drawn again later picks its moment up there, not from the top. */
307  age?: number
308}
309/** A task's diff while Codex works on it. */
310export type Growing = { files: number; added: number; removed: number }
311
312/** The person's colour, as the pane's timeline has it. */
313const YOU_COLOUR = '#E5B94E'
314/** How long a note's moment lasts: it flies over, its line shows, then the usual line comes back. */
315const STEER_MOMENT = 7
316
317/**
318 * Codex's plan as one line: a dot a step (ticked when done, pulsing for the
319 * one under way), how far it has got, and what it is doing now. Centred on x,
320 * or starting there.
321 */
322function planLine(plan: PlanView, x: number, y: number, room: number, centre: boolean, accent = CODEX) {
323  const steps = plan.steps.slice(0, 12)
324  const gap = 13
325  const done = plan.steps.filter(s => s === 'done').length
326  const count = `${done}/${plan.steps.length}`
327  const dotsW = (steps.length - 1) * gap + 8
328  const textRoom = room - dotsW - 12 - count.length * 6.4 - 10
329  const words = plan.now ? fit(plan.now, Math.max(0, Math.floor(textRoom / 6.3))) : done === plan.steps.length ? 'every step done' : ''
330  const width = dotsW + 12 + count.length * 6.4 + (words ? 10 + words.length * 6.3 : 0)
331  let at = centre ? x - width / 2 : x
332  const cy = y - 3.6
333  let out = ''
334  steps.forEach((state, i) => {
335    const cx = at + 4 + i * gap
336    const colour = state === 'done' ? GOOD : state === 'now' ? accent : FAINT
337    out +=
338      state === 'now'
339        ? `<circle cx="${cx}" cy="${cy}" r="3.6" fill="${colour}"/><circle cx="${cx}" cy="${cy}" r="3.6" fill="none" stroke="${colour}" stroke-width="1.3"><animate attributeName="r" values="3.6;8.5" dur="1.4s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.9;0" dur="1.4s" repeatCount="indefinite"/></circle>`
340        : state === 'done'
341          ? `<circle cx="${cx}" cy="${cy}" r="3.8" fill="${colour}"/><path d="M${cx - 1.8} ${cy}l1.4 1.4 2.4-2.6" fill="none" stroke="${STAGE}" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>`
342          : `<circle cx="${cx}" cy="${cy}" r="3" fill="none" stroke="${colour}" stroke-width="1.3"/>`
343    if (i < steps.length - 1) out += `<path d="M${cx + 5} ${cy}h${gap - 10}" stroke="${state === 'done' ? GOOD : EDGE}" stroke-width="1.4" stroke-linecap="round"/>`
344  })
345  at += dotsW + 12
346  out += `<text x="${at.toFixed(1)}" y="${y}" font-size="10.5" font-weight="700" fill="${MUTED}">${count}</text>`
347  at += count.length * 6.4 + 10
348  if (words) out += `<text x="${at.toFixed(1)}" y="${y}" font-size="10.5" font-weight="650" fill="${INK}">${escape(words)}</text>`
349  return out
350}
351
352/** The usual line under the headline, held back while a note's moment plays, then faded in. */
353function afterSteer(inner: string, steer?: SteerView) {
354  const left = STEER_MOMENT - (steer?.age ?? 0)
355  if (!steer || left <= 0) return inner
356  return `<g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="${left.toFixed(2)}s" dur="0.4s" fill="freeze"/>${inner}</g>`
357}
358
359/** A note's moment as of its age: picked up where it is, not played again from the top; gone once over. */
360function aged(fragment: string, steer: SteerView) {
361  const age = steer.age ?? 0
362  return age >= STEER_MOMENT ? '' : continueAnimations(fragment, age)
363}
364
365/** A note's colour and its line's lead: who sent it to whom. */
366function steerWords(steer: SteerView) {
367  if (steer.from === 'codex') return { colour: CODEX, lead: 'Codex → Claude: ' }
368  return steer.from === 'claude' ? { colour: CLAUDE, lead: 'Claude → Codex: ' } : { colour: YOU_COLOUR, lead: 'You → Codex: ' }
369}
370
371/**
372 * A note passed mid-run: it flies from the sender's seat to the other, who
373 * catches it ("!"), and the line under the headline says what it was and who
374 * sent it, for a few seconds.
375 */
376function steerMoment(g: Geo, steer: SteerView, y: number, id: string) {
377  const { colour, lead } = steerWords(steer)
378  const toClaude = steer.from === 'codex'
379  const room = g.W - 2 * (g.LX + g.R + 18)
380  const words = fit(steer.text, Math.max(8, Math.floor((room - 34) / 6.3) - lead.length - 2))
381  const w = Math.min(room, (lead.length + words.length + 2) * 6.3 + 34)
382  const x = g.W / 2 - w / 2
383  const line =
384    `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.06;0.9;1" dur="${STEER_MOMENT}s" fill="freeze"/>` +
385    `<rect x="${x.toFixed(1)}" y="${y - 13}" width="${w.toFixed(1)}" height="20" rx="10" fill="${BUBBLE}" stroke="${colour}" stroke-opacity="0.55" stroke-width="1.1"/>` +
386    `<g transform="translate(${(x + 15).toFixed(1)} ${y - 3})"><rect x="-6" y="-4" width="12" height="8" rx="1.5" fill="${colour}"/><path d="M-6 -4L0 0.6L6 -4" fill="none" stroke="${STAGE}" stroke-width="1.1" stroke-linejoin="round"/></g>` +
387    `<clipPath id="${id}"><rect x="${(x + 26).toFixed(1)}" y="${y - 14}" width="0" height="22"><animate attributeName="width" from="0" to="${(w - 30).toFixed(1)}" begin="0.2s" dur="0.9s" fill="freeze"/></rect></clipPath>` +
388    `<text x="${(x + 26).toFixed(1)}" y="${y + 1}" font-size="11.5" clip-path="url(#${id})"><tspan font-weight="700" fill="${colour}">${escape(lead)}</tspan><tspan fill="${INK}">“${escape(words)}”</tspan></text></g>`
389  // The other catches it (kept inside a short strip's top edge): Codex in its colour, or Clawd in Codex's.
390  const cx = toClaude ? g.LX + g.R * 0.55 : g.RX + g.R * 0.55
391  const cy = Math.max(12, g.AY - g.R - 10)
392  const caught = `<g transform="translate(${cx.toFixed(1)} ${cy.toFixed(1)})"><g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.08;0.8;1" begin="1.05s" dur="2.2s" fill="freeze"/><animateTransform attributeName="transform" type="scale" values="0.3;1.2;1;1" keyTimes="0;0.1;0.18;1" begin="1.05s" dur="2.2s" fill="freeze"/><rect x="-9.5" y="-9" width="19" height="16" rx="8" fill="${CODEX}"/><path d="M-3 7l-2 5 6-5z" fill="${CODEX}"/><text y="3.5" text-anchor="middle" font-size="11.5" font-weight="800" fill="${STAGE}">!</text></g></g>`
393  return aged(flight(g, toClaude ? 'right' : 'left', colour, { launch: 0.15, travel: 0.9, once: true }) + caught + line, steer)
394}
395
396const STRIP = 24
397/** The stage's extra height for the pair's two lines when a round lands. */
398const BANTER_ROOM = 36
399
400type Geo = { W: number; H: number; AY: number; R: number; LX: number; RX: number; narrow: boolean }
401
402function geometry(width: number, s: Stage): Geo {
403  const W = Math.round(Math.max(300, Math.min(560, width)))
404  const narrow = W < 460
405  const R = narrow ? 21 : 25
406  const LX = narrow ? 42 : 58
407  if (s.kind === 'image' && s.phase === 'done' && s.picture) {
408    // Room for the picture itself between the two seats.
409    const room = W - 2 * (LX + R + 22)
410    const tall = Math.min(narrow ? 190 : 250, Math.round(room / Math.max(0.6, s.picture.ratio)))
411    const H = Math.max(narrow ? 170 : 180, tall + 96) + STRIP
412    return { W, H, AY: 44 + (H - 44 - 40 - STRIP) / 2, R, LX, RX: W - LX, narrow }
413  }
414  const talk = (s.phase === 'done' || s.phase === 'failed') && s.banter ? BANTER_ROOM : 0
415  return { W, H: (narrow ? 164 : 174) + STRIP + talk, AY: narrow ? 72 : 74, R, LX, RX: W - LX, narrow }
416}
417
418function wire(g: Geo, phase: StagePhase, hidden = false) {
419  if (hidden) return ''
420  const a = g.LX + g.R + 11
421  const b = g.RX - g.R - 11
422  const path = `M${a} ${g.AY}H${b}`
423  if (phase === 'done') return `<path d="${path}" stroke="url(#wireDone-${g.W})" stroke-width="2" stroke-linecap="round" opacity="0.85"/>`
424  if (phase === 'failed') {
425    const m = g.W / 2
426    return `<path d="M${a} ${g.AY}H${m - 16}M${m + 16} ${g.AY}H${b}" stroke="${BAD}" stroke-width="2" stroke-dasharray="5 6" stroke-linecap="round" opacity="0.7"/><path d="M${m - 6} ${g.AY - 11}l5 8-6 3 8 10" fill="none" stroke="${BAD}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
427  }
428  const flow = phase === 'sending' ? '-20' : '20'
429  return `<path d="${path}" stroke="${FAINT}" stroke-width="2" stroke-dasharray="2 8" stroke-linecap="round"><animate attributeName="stroke-dashoffset" from="0" to="${flow}" dur="0.9s" repeatCount="indefinite"/></path>`
430}
431
432/** The send cycle: Clawd winds up and throws, the note arcs over, Codex catches it. */
433const SEND = { period: 2.4, launch: 0.95, travel: 0.95 }
434
435/** A note flying between the seats on a low arc, with a short trail, and a pop where it lands. */
436function flight(g: Geo, from: 'left' | 'right', colour: string, timing: { period?: number; launch: number; travel: number; once?: boolean }) {
437  const a = from === 'left' ? g.LX + g.R * 0.9 : g.RX - g.R * 0.9
438  const b = from === 'left' ? g.RX - g.R * 0.95 : g.LX + g.R * 0.95
439  const y = g.AY - 4
440  const path = `M${a.toFixed(1)} ${y}Q${(g.W / 2).toFixed(1)} ${g.AY - 26} ${b.toFixed(1)} ${y}`
441  const note = `<rect x="-8" y="-5.5" width="16" height="11" rx="2" fill="${INK}"/><path d="M-8 -5.5L0 1.5L8 -5.5" fill="none" stroke="${STAGE}" stroke-width="1.3" stroke-linejoin="round"/><circle cx="5" cy="3" r="2" fill="${colour}"/>`
442  const piece = (body: string, delay: number, scale: number, fade: number) => {
443    if (timing.once) {
444      const begin = (timing.launch + delay).toFixed(2)
445      return `<g opacity="0"><animate attributeName="opacity" values="0;${fade};${fade};0" keyTimes="0;0.08;0.85;1" begin="${begin}s" dur="${timing.travel}s" fill="freeze"/><animateMotion path="${path}" begin="${begin}s" dur="${timing.travel}s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.35 0 0.25 1"/><g transform="scale(${scale})">${body}</g></g>`
446    }
447    const p = timing.period as number
448    const t0 = ((timing.launch + delay) / p).toFixed(3)
449    const t1 = ((timing.launch + delay + timing.travel) / p).toFixed(3)
450    const t0b = (Number(t0) + 0.01).toFixed(3)
451    const t1a = (Number(t1) - 0.01).toFixed(3)
452    return `<g opacity="0"><animate attributeName="opacity" values="0;0;${fade};${fade};0;0" keyTimes="0;${t0};${t0b};${t1a};${t1};1" dur="${p}s" repeatCount="indefinite"/><animateMotion path="${path}" keyPoints="0;0;1;1" keyTimes="0;${t0};${t1};1" calcMode="linear" dur="${p}s" repeatCount="indefinite"/><g transform="scale(${scale})">${body}</g></g>`
453  }
454  const spin = `<g><animateTransform attributeName="transform" type="rotate" values="-14;12;-14" dur="0.6s" repeatCount="indefinite"/>${note}</g>`
455  const trail = [0.07, 0.14, 0.21].map((d, i) => piece(`<circle r="${2.6 - i * 0.6}" fill="${colour}"/>`, d, 1, 0.75 - i * 0.2)).join('')
456  // Where it lands: a ring opens.
457  const landAt = timing.launch + timing.travel
458  const ring = timing.once
459    ? `<circle cx="${b.toFixed(1)}" cy="${y}" r="4" fill="none" stroke="${colour}" stroke-width="2" opacity="0"><animate attributeName="opacity" values="0.9;0" begin="${landAt.toFixed(2)}s" dur="0.5s" fill="freeze"/><animate attributeName="r" values="4;20" begin="${landAt.toFixed(2)}s" dur="0.5s" fill="freeze"/></circle>`
460    : (() => {
461        const p = timing.period as number
462        const k = (landAt / p).toFixed(3)
463        const k2 = Math.min(0.999, (landAt + 0.5) / p).toFixed(3)
464        return `<circle cx="${b.toFixed(1)}" cy="${y}" r="4" fill="none" stroke="${colour}" stroke-width="2" opacity="0"><animate attributeName="opacity" values="0;0;0.9;0;0" keyTimes="0;${k};${(Number(k) + 0.001).toFixed(3)};${k2};1" dur="${p}s" repeatCount="indefinite"/><animate attributeName="r" values="4;4;4;20;20" keyTimes="0;${k};${(Number(k) + 0.001).toFixed(3)};${k2};1" dur="${p}s" repeatCount="indefinite"/></circle>`
465      })()
466  return trail + piece(spin, 0, 1, 1) + ring
467}
468
469function plane(g: Geo) {
470  return flight(g, 'left', CLAUDE, SEND)
471}
472
473function pages(g: Geo, count: number) {
474  const shown = Math.max(1, Math.min(3, count || 1))
475  const start = g.W / 2 + (g.narrow ? 0 : 24)
476  return Array.from({ length: shown }, (_, i) => {
477    const begin = (i * 0.55).toFixed(2)
478    return `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.2;0.72;1" dur="1.65s" begin="${begin}s" repeatCount="indefinite"/><animateMotion path="M${start} ${g.AY + 20}Q${g.RX - g.R - 60} ${g.AY - 22} ${g.RX - g.R - 9} ${g.AY - 4}" dur="1.65s" begin="${begin}s" repeatCount="indefinite" rotate="auto"/><g transform="translate(-6 -8)"><rect width="12" height="15" rx="2" fill="${INK}"/><path d="M3 5h6M3 8h6M3 11h4" stroke="${STAGE}" stroke-width="1.2" stroke-linecap="round"/></g></g>`
479  }).join('')
480}
481
482function halo(g: Geo) {
483  return [0, 0.9]
484    .map(
485      begin =>
486        `<g transform="translate(${g.RX} ${g.AY})"><polygon points="${hexagon(0, 0, g.R + 2)}" fill="none" stroke="${CODEX}" stroke-width="1.6" stroke-linejoin="round" opacity="0"><animate attributeName="opacity" values="0.75;0" dur="1.8s" begin="${begin}s" repeatCount="indefinite"/><animateTransform attributeName="transform" type="scale" values="1;1.6" dur="1.8s" begin="${begin}s" repeatCount="indefinite"/></polygon></g>`,
487    )
488    .join('')
489}
490
491/** A thought cloud over Codex, its glyph changing as it thinks. */
492function thought(g: Geo) {
493  const bx = g.RX - g.R * 0.2
494  const by = g.AY - g.R - 22
495  const glyphs = ['?', '…', '{ }', '!']
496  const cycle = 4.8
497  const words = glyphs
498    .map((glyph, i) => {
499      const a = (i / glyphs.length).toFixed(3)
500      const b = ((i + 1) / glyphs.length).toFixed(3)
501      const a2 = (Number(a) + 0.02).toFixed(3)
502      const b2 = (Number(b) - 0.02).toFixed(3)
503      const values = i === 0 ? '1;1;0;0' : `0;0;1;1;0;0`
504      const keys = i === 0 ? `0;${b2};${b};1` : `0;${a};${a2};${b2};${b};1`
505      return `<text x="${bx}" y="${by + 4.5}" text-anchor="middle" font-size="12" font-weight="800" fill="${STAGE}" opacity="${i === 0 ? 1 : 0}"><animate attributeName="opacity" values="${values}" keyTimes="${keys}" dur="${cycle}s" repeatCount="indefinite"/>${escape(glyph)}</text>`
506    })
507    .join('')
508  return `<g><animateTransform attributeName="transform" type="translate" values="0 0;0 -2;0 0" dur="2.4s" repeatCount="indefinite"/><ellipse cx="${bx}" cy="${by}" rx="17" ry="12" fill="${INK}"/><circle cx="${bx - 9}" cy="${by + 15}" r="3.6" fill="${INK}"/><circle cx="${bx - 13}" cy="${by + 22}" r="2.2" fill="${INK}"/>${words}</g>`
509}
510
511/** A small editor between the seats, its lines typing themselves. */
512function editor(g: Geo, busy: boolean) {
513  const w = g.narrow ? 108 : 146
514  const h = g.narrow ? 50 : 56
515  const x = g.W / 2 - w / 2
516  const y = g.AY - h / 2 + 7
517  const lines = [0.78, 0.52, 0.66, 0.38]
518  const colours = [CODEX, MUTED, '#6cb6ff', GOOD]
519  const rows = lines
520    .map((share, i) => {
521      const full = (w - 30) * share
522      const ly = y + 16 + i * 9
523      const anim = busy
524        ? `<animate attributeName="width" values="0;${full.toFixed(1)};${full.toFixed(1)};0" keyTimes="0;0.45;0.9;1" dur="2.6s" begin="${(i * 0.35).toFixed(2)}s" repeatCount="indefinite"/>`
525        : ''
526      return `<rect x="${x + 22}" y="${ly - 2.5}" width="${busy ? 0 : full.toFixed(1)}" height="5" rx="2.5" fill="${colours[i]}" fill-opacity="0.85">${anim}</rect><text x="${x + 8}" y="${ly + 2.5}" font-size="7" fill="${FAINT}">${i + 1}</text>`
527    })
528    .join('')
529  const caret = busy
530    ? `<rect x="${x + 22}" y="${y + h - 12}" width="1.6" height="8" fill="${INK}"><animate attributeName="opacity" values="1;0;1" dur="0.9s" repeatCount="indefinite"/></rect>`
531    : ''
532  return `<g><rect x="${x}" y="${y}" width="${w}" height="${h}" rx="9" fill="#0f1115" stroke="${EDGE}"/><circle cx="${x + 9}" cy="${y + 7}" r="2" fill="${BAD}" fill-opacity="0.8"/><circle cx="${x + 16}" cy="${y + 7}" r="2" fill="${WARN}" fill-opacity="0.8"/><circle cx="${x + 23}" cy="${y + 7}" r="2" fill="${GOOD}" fill-opacity="0.8"/>${rows}${caret}</g>`
533}
534
535/** A canvas between the seats, a brush painting colour onto it. */
536function easel(g: Geo) {
537  const w = g.narrow ? 100 : 124
538  const h = g.narrow ? 56 : 66
539  const x = g.W / 2 - w / 2
540  const y = g.AY - h / 2 + 10
541  const stroke = (d: string, colour: string, begin: number, width = 7) =>
542    `<path d="${d}" fill="none" stroke="${colour}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="320" stroke-dashoffset="320" opacity="0.92"><animate attributeName="stroke-dashoffset" values="320;0;0;320" keyTimes="0;0.4;0.85;1" dur="3.4s" begin="${begin}s" repeatCount="indefinite"/></path>`
543  const pad = 12
544  const strokes = [
545    stroke(`M${x + pad} ${y + h - 18}C${x + w * 0.3} ${y + h * 0.35} ${x + w * 0.55} ${y + h - 12} ${x + w - pad} ${y + h * 0.4}`, CLAUDE, 0, 8),
546    stroke(`M${x + pad} ${y + 22}C${x + w * 0.35} ${y + 10} ${x + w * 0.6} ${y + 34} ${x + w - pad - 8} ${y + 18}`, CODEX, 0.5),
547    stroke(`M${x + w * 0.25} ${y + h * 0.55}Q${x + w * 0.5} ${y + h * 0.25} ${x + w * 0.72} ${y + h * 0.62}`, WARN, 1, 5),
548  ].join('')
549  // The brush rides the first stroke.
550  const brush = `<g><animateMotion path="M${x + pad} ${y + h - 18}C${x + w * 0.3} ${y + h * 0.35} ${x + w * 0.55} ${y + h - 12} ${x + w - pad} ${y + h * 0.4}" dur="3.4s" keyPoints="0;1;1;0" keyTimes="0;0.4;0.85;1" calcMode="linear" repeatCount="indefinite"/><g transform="rotate(-35)"><rect x="-2.5" y="-24" width="5" height="16" rx="2" fill="#c8a27a"/><rect x="-3.5" y="-9" width="7" height="5" rx="1" fill="${MUTED}"/><path d="M-3.5 -4h7l-1.5 6a2 2 0 0 1-4 0z" fill="${CLAUDE}"/></g></g>`
551  return `<g><path d="M${x + w * 0.2} ${y + h}l-8 16M${x + w * 0.8} ${y + h}l8 16M${g.W / 2} ${y + h}v14" stroke="#6b5a48" stroke-width="3" stroke-linecap="round"/><rect x="${x}" y="${y}" width="${w}" height="${h}" rx="4" fill="#f6f1e7"/><rect x="${x}" y="${y}" width="${w}" height="${h}" rx="4" fill="none" stroke="#c9bba5" stroke-width="2"/>${strokes}${brush}</g>`
552}
553
554function canvasPicture(g: Geo, picture: { href: string; ratio: number }) {
555  const room = g.W - 2 * (g.LX + g.R + 22)
556  const maxH = g.H - 44 - 50
557  let w = room
558  let h = w / picture.ratio
559  if (h > maxH) {
560    h = maxH
561    w = h * picture.ratio
562  }
563  const x = g.W / 2 - w / 2
564  const y = 44 + (g.H - 44 - 40 - h) / 2
565  return `<g opacity="0"><animate attributeName="opacity" from="0" to="1" dur="0.6s" begin="0.15s" fill="freeze"/><animateTransform attributeName="transform" type="translate" from="0 8" to="0 0" dur="0.6s" begin="0.15s" fill="freeze"/><rect x="${x - 4}" y="${y - 4}" width="${w + 8}" height="${h + 8}" rx="12" fill="#0d0e11" stroke="${EDGE}"/><clipPath id="pic-${g.W}-${g.H}"><rect x="${x}" y="${y}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" rx="9"/></clipPath><image href="${picture.href}" x="${x}" y="${y}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" preserveAspectRatio="xMidYMid slice" clip-path="url(#pic-${g.W}-${g.H})"/></g>`
566}
567
568/** Codex's answer flies back to Claude, once, before the stamp lands. */
569function replyPackets(g: Geo, colour: string) {
570  return flight(g, 'right', colour, { launch: 0.15, travel: 0.85, once: true })
571}
572
573function sparks(w: number, h: number, colour: string) {
574  // A ring of short rays that flares once the stamp has landed.
575  const rays = Array.from({ length: 12 }, (_, i) => {
576    const a = (Math.PI * 2 * i) / 12
577    const rx = w / 2 + 8
578    const ry = h / 2 + 8
579    const x1 = Math.cos(a) * rx
580    const y1 = Math.sin(a) * ry
581    const x2 = Math.cos(a) * (rx + 12)
582    const y2 = Math.sin(a) * (ry + 10)
583    return `<path d="M${x1.toFixed(1)} ${y1.toFixed(1)}L${x2.toFixed(1)} ${y2.toFixed(1)}" stroke="${i % 3 ? colour : INK}" stroke-width="2" stroke-linecap="round"/>`
584  }).join('')
585  return `<g opacity="0"><animate attributeName="opacity" values="0;1;0" keyTimes="0;0.25;1" begin="1.47s" dur="0.75s" fill="freeze"/><animateTransform attributeName="transform" type="scale" values="0.7;1.3" begin="1.47s" dur="0.75s" fill="freeze"/>${rays}</g>`
586}
587
588function stampMark(g: Geo, label: string, colour: string, moment?: Stage['moment']) {
589  const size = g.narrow ? 13 : 15.5
590  const w = Math.max(g.narrow ? 100 : 124, label.length * size * 0.62 + 36)
591  const h = g.narrow ? 32 : 38
592  const arrive =
593    moment === 'slide'
594      ? `<animate attributeName="opacity" from="0" to="1" begin="1.05s" dur="0.2s" fill="freeze"/><animateTransform attributeName="transform" type="translate" values="80 0;-6 0;0 0" keyTimes="0;0.75;1" begin="1.05s" dur="0.5s" fill="freeze"/>`
595      : `<animate attributeName="opacity" from="0" to="1" begin="1.05s" dur="0.15s" fill="freeze"/><animateTransform attributeName="transform" type="scale" values="1.8;0.93;1" keyTimes="0;0.7;1" begin="1.05s" dur="0.42s" fill="freeze"/>`
596  const wobble = moment === 'wobble' ? `<animateTransform attributeName="transform" type="rotate" values="-5;-12;2;-9;-5" begin="1.5s" dur="0.65s" fill="freeze"/>` : ''
597  return `<g transform="translate(${g.W / 2} ${g.AY})">${moment === 'spark' ? sparks(w, h, colour) : ''}<g opacity="0">${arrive}<g transform="rotate(-5)">${wobble}<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="9" fill="${STAGE}"/><rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="9" fill="${colour}" fill-opacity="0.15" stroke="${colour}" stroke-width="2.2"/><rect x="${-w / 2 + 4}" y="${-h / 2 + 4}" width="${w - 8}" height="${h - 8}" rx="6.5" fill="none" stroke="${colour}" stroke-opacity="0.45" stroke-dasharray="3 3"/><text y="${size * 0.37}" text-anchor="middle" font-size="${size}" font-weight="800" letter-spacing="0.8" fill="${colour}">${escape(label.toUpperCase())}</text></g></g></g>`
598}
599
600function pill(x: number, y: number, text: string, colour: string, dot = true) {
601  const w = text.length * 6.4 + (dot ? 24 : 16)
602  return {
603    w,
604    svg: `<g transform="translate(${x} ${y - 13})"><rect width="${w}" height="19" rx="9.5" fill="${colour}" fill-opacity="0.16"/>${dot ? `<circle cx="10.5" cy="9.5" r="3" fill="${colour}"/>` : ''}<text x="${dot ? 18 : 8}" y="13" font-size="11" font-weight="650" fill="${colour}">${escape(text)}</text></g>`,
605  }
606}
607
608function pills(sev: { high: number; medium: number; low: number }, x: number, y: number, compact: boolean) {
609  const items = (
610    [
611      ['high', sev.high, BAD],
612      [compact ? 'med' : 'medium', sev.medium, WARN],
613      ['low', sev.low, MUTED],
614    ] as const
615  ).filter(([, n]) => n > 0)
616  if (!items.length) return pill(x, y, 'no findings', GOOD, false).svg
617  let at = x
618  return items
619    .map(([name, n, colour]) => {
620      const p = pill(at, y, `${n} ${name}`, colour)
621      at += p.w + 5
622      return p.svg
623    })
624    .join('')
625}
626
627function consensus(c: { agree: number; disagree: number; unsure: number; total: number }, x: number, y: number, width: number) {
628  const seg = (n: number) => (c.total ? (n / c.total) * width : 0)
629  let at = x
630  const bars = (
631    [
632      [seg(c.agree), GOOD],
633      [seg(c.disagree), BAD],
634      [seg(c.unsure), WARN],
635    ] as [number, string][]
636  )
637    .filter(([w]) => w > 0)
638    .map(([w, colour]) => {
639      const out = `<rect x="${at.toFixed(1)}" y="${y - 8}" width="${Math.max(3, w - 2).toFixed(1)}" height="6" rx="3" fill="${colour}"/>`
640      at += w
641      return out
642    })
643    .join('')
644  const triaged = c.agree + c.disagree + c.unsure
645  const label = triaged ? `Claude agrees ${c.agree}/${c.total}` : 'Claude is checking…'
646  return `${claudeAvatar(x - 12, y - 5, 6)}<rect x="${x}" y="${y - 8}" width="${width}" height="6" rx="3" fill="${EDGE}"/>${bars}<text x="${x + width + 8}" y="${y - 2}" font-size="11" fill="${MUTED}">${label}</text>`
647}
648
649function stepStrip(g: Geo, strip: NonNullable<Stage['strip']>) {
650  const y = g.H - 36 - 12
651  const n = strip.labels.length
652  const letters = strip.labels.reduce((sum, l) => sum + l.length, 0)
653  let size = g.narrow ? 10 : 10.5
654  let gap = g.narrow ? 10 : 18
655  // Long labels (a build loop's) on a narrow stage: tighter links and smaller type, so the strip stays inside.
656  const room = g.W - 24
657  if (letters * size * 0.6 + 16 * n + gap * (n - 1) > room) {
658    gap = 6
659    size = Math.max(7.5, Math.min(size, (room - 16 * n - gap * (n - 1)) / (letters * 0.6)))
660  }
661  const widths = strip.labels.map(l => l.length * size * 0.6 + 16)
662  const total = widths.reduce((a, b) => a + b, 0) + gap * (n - 1)
663  let x = g.W / 2 - total / 2
664  return strip.labels
665    .map((label, i) => {
666      const state = strip.failed && i === strip.current ? 'failed' : strip.done || i < strip.current ? 'done' : i === strip.current ? 'now' : 'next'
667      const colour = state === 'done' ? GOOD : state === 'now' ? CODEX : state === 'failed' ? BAD : FAINT
668      const cx = x + 5
669      const cy = y - 3.5
670      const dot =
671        state === 'now'
672          ? `<circle cx="${cx}" cy="${cy}" r="4" fill="${colour}"/><circle cx="${cx}" cy="${cy}" r="4" fill="none" stroke="${colour}" stroke-width="1.5"><animate attributeName="r" values="4;10" dur="1.4s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.9;0" dur="1.4s" repeatCount="indefinite"/></circle>`
673          : state === 'done'
674            ? `<circle cx="${cx}" cy="${cy}" r="4.5" fill="${colour}"/><path d="M${cx - 2.2} ${cy}l1.6 1.6 2.8-3" fill="none" stroke="${STAGE}" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`
675            : state === 'failed'
676              ? `<circle cx="${cx}" cy="${cy}" r="4.5" fill="${colour}"/>`
677              : `<circle cx="${cx}" cy="${cy}" r="3.5" fill="none" stroke="${colour}" stroke-width="1.4"/>`
678      const text = `<text x="${x + 13}" y="${y}" font-size="${size}" font-weight="${state === 'now' ? 700 : 500}" fill="${state === 'next' ? FAINT : state === 'now' ? INK : MUTED}">${escape(label)}</text>`
679      const width = widths[i] as number
680      const link =
681        i < strip.labels.length - 1
682          ? `<path d="M${x + width + 2} ${cy}h${gap - 4}" stroke="${state === 'done' ? GOOD : EDGE}" stroke-width="1.5" stroke-linecap="round" stroke-opacity="0.8"/>`
683          : ''
684      x += width + gap
685      return dot + text + link
686    })
687    .join('')
688}
689
690function typewriter(g: Geo, notes: string[], room: number, y = 47) {
691  // Codex's latest lines take turns: each types itself out, holds, then
692  // gives way to the next.
693  const lines = notes.slice(-3).map(n => fit(n, room))
694  const slot = 4.5
695  const cycle = slot * lines.length
696  return lines
697    .map((text, i) => {
698      const w = Math.min(g.W - 40, text.length * 6.1 + 10)
699      const x = g.W / 2 - w / 2
700      const id = `tw-${g.W}-${i}`
701      const reveal = (1.4 / cycle).toFixed(3)
702      const fade = ((slot - 0.35) / cycle).toFixed(3)
703      const gone = (slot / cycle).toFixed(3)
704      const single = lines.length === 1
705      const opacity = single
706        ? '<animate attributeName="opacity" from="0" to="1" dur="0.01s" fill="freeze"/>'
707        : `<animate attributeName="opacity" values="1;1;0;0" keyTimes="0;${fade};${gone};1" dur="${cycle}s" begin="${i * slot}s" repeatCount="indefinite"/>`
708      const width = single
709        ? `<animate attributeName="width" from="0" to="${w}" dur="1.4s" fill="freeze"/>`
710        : `<animate attributeName="width" values="0;${w};${w}" keyTimes="0;${reveal};1" dur="${cycle}s" begin="${i * slot}s" repeatCount="indefinite"/>`
711      return `<clipPath id="${id}"><rect x="${x}" y="${y - 12}" width="0" height="18">${width}</rect></clipPath><g opacity="0" clip-path="url(#${id})">${opacity}<text x="${g.W / 2}" y="${y}" text-anchor="middle" font-size="12" font-style="italic" fill="${MUTED}">${escape(text)}</text></g>`
712    })
713    .join('')
714}
715
716/** Two bubbles sharing a row: the room split by how much each says, neither squeezed below a short line's worth. */
717function shareRoom(total: number, first: string, second?: string) {
718  if (!second) return [total, 0] as const
719  const a = Math.max(100, Math.min(total - 100, Math.round((total * first.length) / (first.length + second.length))))
720  return [a, total - a] as const
721}
722
723/** The stage's two lines when a round lands, under the pair: Codex first, then Claude. */
724function stageBanter(g: Geo, b: Banter, begin: number) {
725  const row = g.AY + g.R + 49
726  const [says, answers] = shareRoom(g.RX - g.LX - 20, b.codex, b.claude)
727  return (
728    speech(b.codex, g.RX, { y: row, from: 'right', tint: CODEX, begin, room: says, id: `st-says-${g.W}` }) +
729    (b.claude ? speech(b.claude, g.LX, { y: row, from: 'left', tint: CLAUDE, begin: begin + 1.1, room: answers, id: `st-answers-${g.W}` }) : '')
730  )
731}
732
733export function stageSvg(s: Stage, width = 560) {
734  const g = geometry(width, s)
735  const { W, H, AY, R, LX, RX, narrow } = g
736  const colour = s.stampColour ?? CODEX
737  const working = s.phase === 'reading' || s.phase === 'thinking'
738  const showPicture = s.kind === 'image' && s.phase === 'done' && s.picture
739
740  let middle = ''
741  let around = ''
742  if (s.phase === 'sending') middle = plane(g)
743  else if (working) {
744    around = s.codexSprite ? rings(RX, AY, R, CODEX) : halo(g)
745    if (s.kind === 'task') middle = editor(g, true)
746    else if (s.kind === 'image') middle = easel(g)
747    else middle = s.phase === 'reading' ? pages(g, s.files) : ''
748    // Waiting on Claude's answer, or on the person's OK: a mark over Codex in theirs instead of its thinking (held up, on a still card).
749    if (s.asking || s.permit) {
750      const [glyph, colour] = s.permit ? ['!', YOU_COLOUR] : ['?', CLAUDE]
751      around += bubbleSvg(RX + R * 0.55, AY - R - 10, glyph, s.still ? { colour } : { loop: 2.4, show: 1.8, colour })
752    } else if (s.phase === 'thinking' && s.kind === 'review') around += thought(g)
753  } else if (s.phase === 'done') {
754    if (showPicture && s.picture) middle = canvasPicture(g, s.picture)
755    else {
756      middle = replyPackets(g, colour)
757      if (s.stamp) middle += stampMark(g, s.stamp, colour, s.moment)
758    }
759  }
760
761  const subRoom = Math.floor((W - 2 * (LX + R + 6)) / 6.2)
762  // Codex's lines take turns; a still card shows the latest one.
763  const usual = working && s.notes?.length
764    ? typewriter(g, s.still ? s.notes.slice(-1) : s.notes, subRoom)
765    : s.sub
766    ? `<text x="${W / 2}" y="47" text-anchor="middle" font-size="12" fill="${MUTED}"${s.subItalic ? ' font-style="italic"' : ''}>${escape(fit(s.sub, subRoom))}</text>`
767    : ''
768  const steer = working && s.steer ? s.steer : undefined
769  const sub = afterSteer(usual, steer) + (steer ? steerMoment(g, steer, 47, `steer-${W}`) : '')
770  const base = H - 14
771  const time = s.time
772
773  let bottom: string
774  if (s.phase === 'done' && s.kind === 'review' && s.severities) {
775    const total = s.severities.high + s.severities.medium + s.severities.low
776    const right: [Icon, string][] = [['clock', time]]
777    if (s.tokens && !narrow) right.push(['coin', s.tokens])
778    bottom = pills(s.severities, 16, base, narrow) + statRow(right, W - 16 - statWidth(right) / 2, base)
779    if (s.consensus && s.consensus.total && total) {
780      const barWidth = narrow ? 60 : 92
781      bottom += consensus(s.consensus, W / 2 - (barWidth + 104) / 2 + 12, AY + R + 19, barWidth)
782    }
783  } else if (s.phase === 'done' && s.kind === 'task' && s.diff) {
784    const left = s.diff.files
785      ? pill(16, base, `+${s.diff.added}`, GOOD, false).svg + pill(16 + `+${s.diff.added}`.length * 6.4 + 22, base, `−${s.diff.removed}`, BAD, false).svg
786      : pill(16, base, 'no changes', MUTED, false).svg
787    const right: [Icon, string][] = [['file', `${s.diff.files} file${s.diff.files === 1 ? '' : 's'}`], ['clock', time]]
788    if (s.tokens && !narrow) right.push(['coin', s.tokens.split(' ')[0] as string])
789    bottom = left + statRow(right, W - 16 - statWidth(right) / 2, base)
790  } else {
791    const items: [Icon, string, number?][] = []
792    if (s.kind === 'image') items.push(['picture', s.phase === 'done' ? 'saved' : 'painting'])
793    else if (s.kind === 'task' && working && s.growing) {
794      // The diff as it grows.
795      items.push(['edit', `${s.growing.files} file${s.growing.files === 1 ? '' : 's'} · +${s.growing.added} −${s.growing.removed}`])
796    } else {
797      items.push(['file', `${s.files} file${s.files === 1 ? '' : 's'}`])
798      if (s.kind === 'task' && s.edits) items.push(['edit', `${s.edits} edit${s.edits === 1 ? '' : 's'}`])
799      else if (!narrow) items.push(['search', `${s.searches} search${s.searches === 1 ? '' : 'es'}`])
800    }
801    items.push(s.clock !== undefined ? ['clock', time, s.clock] : ['clock', time])
802    if (s.tokens) items.push(['coin', narrow ? (s.tokens.split(' ')[0] as string) : s.tokens])
803    bottom = statRow(items, W / 2, base)
804  }
805
806  const leftName = s.you ? 'You' : 'Claude'
807  const leftModel = s.you ? 'from the pane' : s.claudeModel
808  const rightModel = narrow ? s.codexModel : `${s.codexModel} · ${s.effort}`
809  const labelRoom = narrow ? 13 : 22
810  // The stage is drawn in a frame of its own: following the page's colour
811  // scheme keeps the frame see-through where the stage does not reach (a
812  // frame on its own default scheme paints white there). Its light sweeps
813  // across as a round lands, not on every step of a run.
814  const bg = cardGround(`st${W}-${H}`, W, H, { sheen: s.phase === 'done' || s.phase === 'failed', sky: s.sky })
815  const landed = s.phase === 'done' && !showPicture ? `<rect y="${H - 37}" width="${W}" height="1.5" fill="${colour}" opacity="0.5"/>` : ''
816  const drawn = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${FONT}">
817<style>:root{color-scheme:light dark;background:transparent}</style>
818<defs>
819<linearGradient id="wireDone-${W}" x1="${LX}" x2="${RX}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${CLAUDE}"/><stop offset="1" stop-color="${CODEX}"/></linearGradient>
820<clipPath id="card-${W}-${H}"><rect width="${W}" height="${H}" rx="16"/></clipPath>
821${bg.defs}
822</defs>
823<g clip-path="url(#card-${W}-${H})">${bg.ground}${s.family === 'build' ? buildGround(`st${W}-${H}`, W, H - 36) : ''}<rect y="${H - 36}" width="${W}" height="36" fill="#0b0c10" opacity="0.45"/><path d="M0 ${H - 36}H${W}" stroke="${EDGE}"/>${landed}</g>
824<rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="15.5" fill="none" stroke="${bg.rim}"/>
825${s.codexSprite || s.claudeMood ? motes(g) : ''}
826${wire(g, s.phase, Boolean(showPicture))}
827${middle}
828<g>${leftSeat(s.you, LX, AY, R, s.claudeMood, s.phase === 'done' ? 1.05 : 0)}<title>${escape(leftName)}</title></g>
829${around}
830<g>${rightSeat(s.codexSprite, RX, AY, R)}<title>${escape(`Codex · ${s.codexModel} · ${s.effort} reasoning`)}</title></g>
831${codexReaction(g, s)}
832<text x="${LX}" y="${AY + R + 16}" text-anchor="middle" font-size="12.5" font-weight="650" fill="${INK}">${leftName}</text>
833<text x="${RX}" y="${AY + R + 16}" text-anchor="middle" font-size="12.5" font-weight="650" fill="${INK}">Codex</text>
834<text x="${LX}" y="${AY + R + 29}" text-anchor="middle" font-size="9.5" fill="${MUTED}">${escape(fit(leftModel, labelRoom))}</text>
835<text x="${RX}" y="${AY + R + 29}" text-anchor="middle" font-size="9.5" fill="${MUTED}">${escape(fit(rightModel, labelRoom))}</text>
836${(s.phase === 'done' || s.phase === 'failed') && s.banter ? stageBanter(g, s.banter, s.phase === 'done' ? 1.8 : 0.7) : ''}
837${s.tag && !narrow ? tagPill(12, 10, s.tag, familyColour(s.family), s.family, 16).svg : ''}
838${s.ref && !narrow ? `<text x="${W - 14}" y="22" text-anchor="end" font-size="10.5" font-weight="700" letter-spacing="0.9" fill="${FAINT}">${escape(s.ref)}</text>` : ''}
839<text x="${W / 2}" y="29" text-anchor="middle" font-size="${narrow ? 14.5 : 15.5}" font-weight="700" fill="${INK}">${escape(fit(s.headline, Math.floor((W - 30) / 9)))}</text>
840${sub}
841${working && s.plan ? planLine(s.plan, W / 2, H - 48, W - 40, true) : s.strip ? stepStrip(g, s.strip) : ''}
842${s.mail ? mailboxes(g, s.mail) : ''}
843${bottom}
844<g clip-path="url(#card-${W}-${H})">${bg.sweep}</g>
845</svg>`
846  // Finished: its loops come to rest after the landing (see settle).
847  return s.phase === 'done' || s.phase === 'failed' ? settle(drawn) : drawn
848}
849
850/**
851 * What passed between them while Codex worked, as envelopes by whoever got
852 * them: the notes Codex got from Claude (or the person) by Codex, and what
853 * Codex told or asked Claude by Clawd. The newest pops in.
854 */
855function mailboxes(g: Geo, mail: Mail) {
856  const y = g.AY + g.R - 3
857  const pile = (items: string[], x: number, dir: 1 | -1, key: string) => {
858    if (!items.length) return ''
859    const shown = items.slice(-4)
860    let out = shown
861      .map((colour, i) => {
862        const cx = x + dir * i * 10.5
863        const body =
864          colour === ASK_MARK
865            ? `<rect x="-6.3" y="-4.6" width="12.6" height="9.2" rx="2.2" fill="${CODEX}"/><text y="3.2" text-anchor="middle" font-size="8.5" font-weight="800" fill="${STAGE}">?</text>`
866            : `<rect x="-6.3" y="-4.3" width="12.6" height="8.6" rx="1.6" fill="${colour}"/><path d="M-6.3 -4.3L0 0.8L6.3 -4.3" fill="none" stroke="${STAGE}" stroke-width="1.1"/>`
867        const pop = i === shown.length - 1 ? `<animateTransform attributeName="transform" type="scale" values="0.2;1.25;1" dur="0.4s" begin="0.2s" fill="freeze" additive="sum"/>` : ''
868        return `<g transform="translate(${cx.toFixed(1)} ${y}) rotate(${((i % 2 ? 1 : -1) * 7).toFixed(0)})"><g>${pop}${body}</g></g>`
869      })
870      .join('')
871    if (items.length > shown.length) out += `<text x="${(x + dir * shown.length * 10.5 + (dir > 0 ? 0 : -2)).toFixed(1)}" y="${y + 3.5}" text-anchor="${dir > 0 ? 'start' : 'end'}" font-size="9.5" font-weight="700" fill="${MUTED}">+${items.length - shown.length}</text>`
872    return `<g><title>${escape(key)}</title>${out}</g>`
873  }
874  const toCodex = mail.toCodex.map(from => (from === 'you' ? YOU_COLOUR : CLAUDE))
875  const toClaude = mail.toClaude.map(kind => (kind === 'ask' ? ASK_MARK : CODEX))
876  return (
877    pile(toCodex, g.RX - g.R - 16, -1, `${mail.toCodex.length} note${mail.toCodex.length === 1 ? '' : 's'} to Codex`) +
878    pile(toClaude, g.LX + g.R + 16, 1, `${mail.toClaude.length} message${mail.toClaude.length === 1 ? '' : 's'} from Codex`)
879  )
880}
881
882/** A question among the envelopes. */
883const ASK_MARK = '?'
884
885// ---------------------------------------------------------------------------
886// Small pictures for the pane (plain images at their own size)
887
888/** A conversation's chips are round; a task's (task_*) are square, like its tag. */
889export type ChipKind =
890  | 'live'
891  | 'approve'
892  | 'approve_with_nits'
893  | 'changes_needed'
894  | 'rethink'
895  | 'answer'
896  | 'failed'
897  | 'applied'
898  | 'picture'
899  | 'task'
900  | 'task_live'
901  | 'task_applied'
902  | 'task_failed'
903
904export function chipSvg(kind: ChipKind) {
905  const colour =
906    kind === 'live'
907      ? CODEX
908      : kind === 'task' || kind === 'task_live'
909        ? BUILD
910        : kind === 'failed' || kind === 'task_failed'
911          ? BAD
912          : kind === 'applied' || kind === 'task_applied'
913            ? GOOD
914            : kind === 'picture'
915              ? CLAUDE
916              : verdictColour(kind)
917  const tick = `<path d="M6.5 11.3l3 3 6-6.4" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
918  const glyphs: Record<ChipKind, string> = {
919    live: `<circle cx="7" cy="11" r="1.8" fill="#fff"/><circle cx="11" cy="11" r="1.8" fill="#fff"/><circle cx="15" cy="11" r="1.8" fill="#fff"/>`,
920    approve: tick,
921    approve_with_nits: tick,
922    applied: tick,
923    changes_needed: `<path d="M11 6v6.2" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/><circle cx="11" cy="15.6" r="1.4" fill="#fff"/>`,
924    rethink: `<path d="M15.2 9.2a4.6 4.6 0 1 0 0.4 4" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="M15.6 5.8v3.6h-3.6" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
925    answer: `<path d="M8.6 8.8a2.5 2.5 0 1 1 3.4 2.3c-0.7 0.3-1 0.8-1 1.5v0.6" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"/><circle cx="11" cy="16" r="1.3" fill="#fff"/>`,
926    failed: `<path d="M7.8 7.8l6.4 6.4M14.2 7.8l-6.4 6.4" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/>`,
927    task: `<path d="M8.6 7.6L5.4 11l3.2 3.4M13.4 7.6l3.2 3.4-3.2 3.4" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
928    task_live: `<circle cx="7" cy="11" r="1.8" fill="#fff"/><circle cx="11" cy="11" r="1.8" fill="#fff"/><circle cx="15" cy="11" r="1.8" fill="#fff"/>`,
929    task_applied: tick,
930    task_failed: `<path d="M7.8 7.8l6.4 6.4M14.2 7.8l-6.4 6.4" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/>`,
931    picture: `<rect x="5.5" y="6.5" width="11" height="9" rx="1.6" fill="none" stroke="#fff" stroke-width="1.6"/><path d="M6.2 14.6l3-3 2 1.8 1.6-1.4 2.6 2.4" fill="none" stroke="#fff" stroke-width="1.5" stroke-linejoin="round"/>`,
932  }
933  const ground = kind.startsWith('task') ? `<rect x="1" y="1" width="20" height="20" rx="5" fill="${colour}"/>` : `<circle cx="11" cy="11" r="10.5" fill="${colour}"/>`
934  return `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">${ground}${glyphs[kind]}</svg>`
935}
936
937export const DUO_SIZE = { width: 72, height: 30 }
938export function duoSvg(sprite?: Sprite, mood: ClawdMood = 'idle', busy = false) {
939  if (sprite) {
940    // At work, the arrows between them run in the colours of both.
941    const arrows = busy
942      ? `<path d="M31 11.5h10l-3-3" fill="none" stroke="${CLAUDE}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><animate attributeName="opacity" values="1;0.3;1" dur="1.2s" repeatCount="indefinite"/></path><path d="M41 18.5H31l3 3" fill="none" stroke="${CODEX}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><animate attributeName="opacity" values="0.3;1;0.3" dur="1.2s" repeatCount="indefinite"/></path>`
943      : `<path d="M31 11.5h10l-3-3M41 18.5H31l3 3" fill="none" stroke="${MUTED}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>`
944    return `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="30" viewBox="0 0 72 30">${clawd(15, 17, 1.45, mood)}${arrows}${codexSprite(sprite, 58, 15, 32)}</svg>`
945  }
946  return `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="30" viewBox="0 0 72 30">${claudeAvatar(14, 15, 12)}<path d="M31 11.5h10l-3-3M41 18.5H31l3 3" fill="none" stroke="${MUTED}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>${codexAvatar(58, 15, 12)}</svg>`
947}
948
949export const POKE_STAGE = { width: 184, height: 80 }
950
951export type Poke = { who: 'claude' | 'codex'; n: number; lines: [string, string] }
952
953/**
954 * Claude and Codex in the pane's header, big enough to poke. The one poked
955 * jumps with a "!" and a burst and says its line; its bubble steps aside and
956 * the other answers. Every motion of the poke runs once.
957 */
958export function pokeStageSvg(o: { sprite?: Sprite; mood: ClawdMood; busy: boolean; poke?: Poke }) {
959  const { width: W, height: H } = POKE_STAGE
960  const LX = 38
961  const RX = 146
962  const AY = 27
963  const p = o.poke
964  const id = `poke${p?.n ?? 0}`
965  const jump = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -11;0 0;0 -4;0 0" keyTimes="0;0.28;0.55;0.78;1" dur="0.7s" fill="freeze"/>`
966  const bang = (x: number, colour: string) =>
967    `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.1;0.75;1" dur="1.3s" fill="freeze"/><text x="${x + 17}" y="${AY - 12}" text-anchor="middle" font-size="16" font-weight="800" fill="${colour}">!</text></g>`
968  const burst = (x: number, colour: string) =>
969    [0, 1, 2, 3, 4, 5]
970      .map(i => {
971        const a = (Math.PI / 3) * i + Math.PI / 6
972        const at = (r: number) => `${(x + Math.cos(a) * r).toFixed(1)} ${(AY + Math.sin(a) * r).toFixed(1)}`
973        return `<path d="M${at(17)}L${at(25)}" stroke="${colour}" stroke-width="2" stroke-linecap="round" opacity="0"><animate attributeName="opacity" values="0;1;0" dur="0.45s" begin="0.05s" fill="freeze"/></path>`
974      })
975      .join('')
976  const claudePoked = p?.who === 'claude'
977  const codexPoked = p?.who === 'codex'
978  const left = `<g>${clawd(LX, AY, clawdUnitFor(46), o.mood)}${claudePoked ? jump : ''}</g>`
979  const right = `<g>${o.sprite ? codexSprite(o.sprite, RX, AY - 1, 46) : codexAvatar(RX, AY, 15)}${codexPoked ? jump : ''}</g>`
980  // The poked one speaks first; its bubble fades as the other answers in its own.
981  let talk = ''
982  if (p) {
983    const first = codexPoked ? RX : LX
984    const second = codexPoked ? LX : RX
985    const side = (x: number): 'left' | 'right' => (x === LX ? 'left' : 'right')
986    const tint = (x: number) => (x === LX ? CLAUDE : CODEX)
987    const room = W - 30
988    talk =
989      `<g><animate attributeName="opacity" values="1;1;0" keyTimes="0;0.86;1" begin="0.3s" dur="1.5s" fill="freeze"/>${speech(p.lines[0], first, { y: 65, from: side(first), tint: tint(first), begin: 0.3, room, id: `${id}-a` })}</g>` +
990      speech(p.lines[1], second, { y: 65, from: side(second), tint: tint(second), begin: 1.65, room, id: `${id}-b` })
991  }
992  // At work, the arrows between them run in the colours of both.
993  const mid = (LX + RX) / 2
994  const arrows = o.busy
995    ? `<path d="M${mid - 14} ${AY - 4}h20l-4-4" fill="none" stroke="${CLAUDE}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><animate attributeName="opacity" values="1;0.3;1" dur="1.2s" repeatCount="indefinite"/></path><path d="M${mid + 6} ${AY + 5}h-20l4 4" fill="none" stroke="${CODEX}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><animate attributeName="opacity" values="0.3;1;0.3" dur="1.2s" repeatCount="indefinite"/></path>`
996    : `<path d="M${mid - 14} ${AY - 4}h20l-4-4M${mid + 6} ${AY + 5}h-20l4 4" fill="none" stroke="${MUTED}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
997  const bg = cardGround(`poke${p?.n ?? 0}`, W, H, { sheen: false })
998  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${FONT}">
999<defs><clipPath id="${id}-card"><rect width="${W}" height="${H}" rx="13"/></clipPath>${bg.defs}</defs><g clip-path="url(#${id}-card)">${bg.ground}</g><rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="12.5" fill="none" stroke="${bg.rim}"/>
1000${arrows}
1001${claudePoked ? burst(LX, CLAUDE) : ''}${codexPoked ? burst(RX, CODEX) + bang(RX, CODEX) : ''}
1002${left}
1003${right}
1004${talk}
1005</svg>`
1006}
1007
1008export type Gauge = { label: string; percent: number; resets?: string }
1009
1010function gaugeColour(percent: number) {
1011  if (percent >= 90) return BAD
1012  if (percent >= 70) return WARN
1013  return CODEX
1014}
1015
1016export const GAUGE_SIZE = { width: 104, height: 112 }
1017export function gaugeSvg(g: Gauge) {
1018  const { width, height } = GAUGE_SIZE
1019  const cx = width / 2
1020  const cy = 44
1021  const r = 27
1022  const c = 2 * Math.PI * r
1023  const p = Math.max(0, Math.min(100, g.percent))
1024  const colour = gaugeColour(p)
1025  const label = p >= 10 || p === 0 ? `${Math.round(p)}%` : `${p.toFixed(1)}%`
1026  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="${FONT}"><rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="14" fill="${STAGE}" stroke="${EDGE}"/><circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${EDGE}" stroke-width="7"/><circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${colour}" stroke-width="7" stroke-linecap="round" stroke-dasharray="${((p / 100) * c).toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})"/><text x="${cx}" y="${cy + 5}" text-anchor="middle" font-size="14" font-weight="750" fill="${INK}">${label}</text><text x="${cx}" y="${cy + 46}" text-anchor="middle" font-size="12" font-weight="650" fill="${INK}">${escape(g.label)}</text>${g.resets ? `<text x="${cx}" y="${cy + 61}" text-anchor="middle" font-size="10" fill="${MUTED}">${escape(fit(g.resets, 18))}</text>` : ''}</svg>`
1027}
1028
1029// ---------------------------------------------------------------------------
1030// Picture tiles (cards and gallery): the JPEG inside an SVG, or a shimmer
1031
1032export function photoSvg(base64: string, w: number, h: number) {
1033  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><defs><clipPath id="r"><rect width="${w}" height="${h}" rx="10"/></clipPath></defs><image href="data:image/jpeg;base64,${base64}" width="${w}" height="${h}" preserveAspectRatio="xMidYMid slice" clip-path="url(#r)"/></svg>`
1034}
1035
1036export function shimmerSvg(w: number, h: number, state: 'active' | 'failed' | 'idle') {
1037  const base = `<rect width="${w}" height="${h}" rx="10" fill="#8a8f98" fill-opacity="0.12"/>`
1038  if (state === 'failed') {
1039    return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${base}<rect x="1" y="1" width="${w - 2}" height="${h - 2}" rx="9" fill="none" stroke="#d9534f" stroke-opacity="0.55" stroke-dasharray="6 5"/></svg>`
1040  }
1041  if (state === 'idle') return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${base}</svg>`
1042  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><defs><linearGradient id="s" x1="0" y1="0" x2="1" y2="0.35"><stop offset="0.3" stop-color="#8a8f98" stop-opacity="0"/><stop offset="0.5" stop-color="#8a8f98" stop-opacity="0.22"/><stop offset="0.7" stop-color="#8a8f98" stop-opacity="0"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="1.8s" repeatCount="indefinite"/></linearGradient></defs>${base}<rect width="${w}" height="${h}" rx="10" fill="url(#s)"/></svg>`
1043}
1044
1045// ---------------------------------------------------------------------------
1046// The band above the prompt: a live strip per running job, a slim line at rest
1047
1048/** Room on a strip's right end for the Buttons the band draws over it. */
1049export const BAND_CONTROLS = 192
1050export const BAND_ROW_HEIGHT = 78
1051
1052export type BandRow = {
1053  /** What runs: a review, a joint review, a task, pictures. */
1054  tag: string
1055  title: string
1056  /** What Codex is doing right now; `thought` when it is one of its own lines. */
1057  doing: string
1058  thought?: boolean
1059  phase: 'sending' | 'reading' | 'thinking'
1060  stalled?: boolean
1061  queued?: boolean
1062  time: string
1063  /** Seconds so far; and, to count on by itself, what the clock shows at the picture's start. */
1064  elapsed: number
1065  clock?: number
1066  strip?: { labels: string[]; current: number }
1067  /** The left seat: the person, when they started it from the pane. */
1068  you?: boolean
1069  /** The mascots: the Codex companion's animation, and Clawd's mood. */
1070  codexSprite?: Sprite
1071  claudeMood?: ClawdMood
1072  /** A conversation or a task: each has its own look. */
1073  family?: Family
1074  /** Whose move it is (Codex at work, Claude's turn, or both at once), and which way a note is travelling. */
1075  turn?: Turn
1076  toward?: 'codex' | 'claude' | 'both'
1077  /** Codex's own plan, and a note just handed to it. */
1078  plan?: PlanView
1079  steer?: SteerView
1080  /** Codex has asked Claude something and waits for the answer. */
1081  asking?: boolean
1082  /** One of Codex's tools waits on the person's permission (what it wants is `doing`). */
1083  permit?: boolean
1084  /** Questions Codex asked without waiting, still open; and what is queued for its next round. */
1085  tickets?: number
1086  nextUp?: number
1087}
1088
1089export type Turn = 'codex' | 'claude' | 'both'
1090
1091/** Both at work: Claude's colour and Codex's, mixed. */
1092const BOTH = '#B285A7'
1093
1094/** The colour of whoever has the move. */
1095export function turnColour(turn: Turn | undefined) {
1096  return turn === 'claude' ? CLAUDE : turn === 'both' ? BOTH : CODEX
1097}
1098
1099/** Whose move it is, in two words. */
1100export function turnWords(turn: Turn | undefined) {
1101  return turn === 'claude' ? "Claude's turn" : turn === 'both' ? 'Both at work' : "Codex's turn"
1102}
1103
1104/** The strip's ground: the card under the sky of the hour, the dashboard's panel, and the beam in the mover's colour. */
1105function bandFrame(W: number, H: number, id: string, turn: Turn = 'codex', family?: Family, controls = BAND_CONTROLS) {
1106  const mover = turn === 'codex' ? familyColour(family) : turnColour(turn)
1107  const beam = turn === 'both' ? [CLAUDE, CODEX] : [mover, mover]
1108  const bg = cardGround(id, W - controls, H)
1109  return `<defs>
1110<linearGradient id="${id}-beam" x1="0" x2="1"><stop offset="0" stop-color="${beam[0]}" stop-opacity="${turn === 'both' ? 1 : 0.55}"/><stop offset="1" stop-color="${beam[1]}"/></linearGradient>
1111<linearGradient id="${id}-shine" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
1112<linearGradient id="${id}-panel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1e2129"/><stop offset="1" stop-color="#16181d"/></linearGradient>
1113<clipPath id="${id}-card"><rect width="${W}" height="${H}" rx="14"/></clipPath>
1114${bg.defs}
1115</defs>
1116<g clip-path="url(#${id}-card)">${bg.ground}${family === 'build' ? buildGround(id, W - controls, H) : ''}<rect x="${W - controls}" width="${controls}" height="${H}" fill="url(#${id}-panel)"/><path d="M${W - controls + 0.5} 0V${H}" stroke="${EDGE}"/></g>
1117<rect x="0.5" y="0.5" width="${W - 1}" height="${H - 1}" rx="13.5" fill="none" stroke="${bg.rim}"/>`
1118}
1119
1120/** The light sweeping once across a strip as its news lands (over everything, inside the card). */
1121function bandSweep(W: number, H: number, id: string) {
1122  return `<g clip-path="url(#${id}-card)"><rect x="${(-W * 0.5).toFixed(0)}" y="-10" width="${(W * 0.3).toFixed(0)}" height="${H + 20}" fill="url(#${id}-sheen)" transform="skewX(-18)"><animate attributeName="x" from="${(-W * 0.5).toFixed(0)}" to="${(W * 1.35).toFixed(0)}" begin="0.2s" dur="1.6s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.4 0 0.2 1"/></rect></g>`
1123}
1124
1125function miniStrip(strip: { labels: string[]; current: number }, x: number, y: number, maxRight: number, now = CODEX) {
1126  const size = 10.5
1127  let at = x
1128  let out = ''
1129  strip.labels.forEach((label, i) => {
1130    const width = label.length * size * 0.62 + 18
1131    if (at + width > maxRight) return
1132    const state = i < strip.current ? 'done' : i === strip.current ? 'now' : 'next'
1133    const colour = state === 'done' ? GOOD : state === 'now' ? now : FAINT
1134    const cy = y - 3.6
1135    out +=
1136      state === 'now'
1137        ? `<circle cx="${at + 4}" cy="${cy}" r="3.4" fill="${colour}"/><circle cx="${at + 4}" cy="${cy}" r="3.4" fill="none" stroke="${colour}" stroke-width="1.3"><animate attributeName="r" values="3.4;8" dur="1.4s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.9;0" dur="1.4s" repeatCount="indefinite"/></circle>`
1138        : state === 'done'
1139          ? `<circle cx="${at + 4}" cy="${cy}" r="3.8" fill="${colour}"/><path d="M${at + 2.2} ${cy}l1.4 1.4 2.4-2.6" fill="none" stroke="${STAGE}" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>`
1140          : `<circle cx="${at + 4}" cy="${cy}" r="3" fill="none" stroke="${colour}" stroke-width="1.3"/>`
1141    out += `<text x="${at + 11}" y="${y}" font-size="${size}" font-weight="${state === 'now' ? 700 : 500}" fill="${state === 'next' ? FAINT : state === 'now' ? INK : MUTED}">${escape(label)}</text>`
1142    if (i < strip.labels.length - 1) out += `<path d="M${at + width - 4} ${cy}h7" stroke="${state === 'done' ? GOOD : EDGE}" stroke-width="1.4" stroke-linecap="round"/>`
1143    at += width + 7
1144  })
1145  return out
1146}
1147
1148/** A note on its way along the strip's foot, from one of them to the other. */
1149function courier(from: number, to: number, y: number, colour: string, begin = 0) {
1150  return `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;0.12;0.86;1" dur="2.1s" begin="${begin}s" repeatCount="indefinite"/><animateMotion path="M${from.toFixed(1)} ${y}H${to.toFixed(1)}" dur="2.1s" begin="${begin}s" repeatCount="indefinite" calcMode="spline" keyTimes="0;1" keySplines="0.45 0 0.25 1"/><g><animateTransform attributeName="transform" type="rotate" values="-8;8;-8" dur="0.7s" repeatCount="indefinite"/><rect x="-6.5" y="-4.2" width="13" height="8.4" rx="1.6" fill="${INK}"/><path d="M-6.5 -4.2L0 0.8L6.5 -4.2" fill="none" stroke="${STAGE}" stroke-width="1.1" stroke-linejoin="round"/><circle cx="4.2" cy="2.2" r="1.7" fill="${colour}"/></g></g>`
1151}
1152
1153/** The couriers for a strip: toward Codex, toward Claude, or both ways at once. */
1154function couriers(toward: BandRow['toward'], lx: number, rx: number, y: number) {
1155  if (toward === 'codex') return courier(lx, rx, y, CLAUDE)
1156  if (toward === 'claude') return courier(rx, lx, y, CODEX)
1157  if (toward === 'both') return courier(lx, rx, y - 3, CLAUDE) + courier(rx, lx, y + 3, CODEX, 1.05)
1158  return ''
1159}
1160
1161/** One running job: Claude and Codex at either end, what Codex does between them, its time counting. */
1162export function bandRowSvg(r: BandRow, width: number) {
1163  const W = Math.round(Math.max(420, width))
1164  const H = BAND_ROW_HEIGHT
1165  const R = 15
1166  const LX = 30
1167  const RX = W - BAND_CONTROLS - 32
1168  const AY = 31
1169  const MX = LX + R + 20
1170  // Room before Codex for its thought bubble.
1171  const ME = RX - R - 46
1172  const id = `b${W}`
1173  const room = Math.max(10, Math.floor((ME - MX) / 6.6))
1174  const working = !r.queued
1175  const turn = r.turn ?? 'codex'
1176  const accent = r.stalled ? WARN : turn === 'codex' ? familyColour(r.family) : turnColour(turn)
1177  // The tag (its family's), the title, and the time so far.
1178  const pill = tagPill(MX, 11, r.tag, familyColour(r.family), r.family)
1179  const tagW = pill.w
1180  const time = r.time
1181  const timeW = Math.max(4, time.length) * 6.6
1182  const titleRoom = Math.max(6, Math.floor((ME - MX - tagW - 10 - timeW - 12) / 7.2))
1183  const head =
1184    pill.svg +
1185    `<text x="${MX + tagW + 9}" y="24" font-size="13.5" font-weight="650" fill="${r.queued ? MUTED : INK}">${escape(fit(r.title, titleRoom))}</text>` +
1186    (r.clock !== undefined
1187      ? liveClock(r.clock, ME, 24, { size: 12, colour: MUTED, weight: 400, anchor: 'end', id: `${id}-clock` })
1188      : `<text x="${ME}" y="24" text-anchor="end" font-size="12" fill="${MUTED}">${escape(time)}</text>`)
1189  // What Codex is doing, typed out.
1190  const doingText = r.thought ? `“${fit(r.doing, room - 2)}”` : fit(r.doing, room)
1191  const doingW = Math.min(ME - MX, doingText.length * 6.7 + 12)
1192  const typed =
1193    `<clipPath id="${id}-type"><rect x="${MX}" y="34" width="0" height="18"><animate attributeName="width" from="0" to="${doingW.toFixed(0)}" dur="1.1s" fill="freeze"/></rect></clipPath>` +
1194    `<text x="${MX}" y="47" font-size="12.5"${r.thought ? ' font-style="italic"' : ''} fill="${r.stalled ? WARN : r.permit ? YOU_COLOUR : r.thought ? '#c9c6ff' : '#d2d5db'}" clip-path="url(#${id}-type)">${escape(doingText)}</text>`
1195  const steer = working ? r.steer : undefined
1196  const doing = afterSteer(typed, steer) + (steer ? steerMoment({ W: W - BAND_CONTROLS, H, AY, R, LX, RX, narrow: false }, steer, 47, `${id}-steer`) : '')
1197  // The person's OK waiting: the line under it stays clear for its answers, which the band draws there.
1198  const strip = r.permit ? '' : working && r.plan ? planLine(r.plan, MX, 68, ME - MX, false, accent) : r.strip ? miniStrip(r.strip, MX, 68, ME, accent) : ''
1199  // The beam along the foot, in the mover's colour, with a shine running along it while it works.
1200  const bx = 14
hooks/duet.ts 1171 lines
1// Claudex's look: "the stage". One card language for the band above the
2// prompt, the cards in the chat and the pane's hero, after Branchline's: a
3// dark ground with a dot grid and a wash of colour, a tracked kicker that says
4// who is doing what, one big line that says it plainly.
5//
6// Under the words, Claude and Codex stand on one floor, a spotlight on
7// whoever has the move. What Codex does lands on the floor between them as
8// beads, one a step (a file read, a search, a command, an edit, a thought),
9// each popping out of Codex and pushing the older ones along toward Claude:
10// the work so far, at a glance. Notes travel the floor as envelopes; a
11// question waits there for Claude; findings land on it in their colours.
12import {
13  BAND_CONTROLS,
14  CLAUDE,
15  CODEX,
16  type BandHero,
17  type BandResult,
18  type BandRow,
19  type Banter,
20  type ClawdMood,
21  type Sprite,
22  type Stage as CardStage,
23  clawd,
24  codexAvatar,
25  codexSprite,
26  confetti,
27  escape,
28  fit,
29  liveClock,
30  settle,
31} from './art'
32
33// ---------------------------------------------------------------------------
34// Tokens
35
36export const D = {
37  ground: '#14141c',
38  ink: '#f4f2ee',
39  claude: '#E0875F',
40  codex: '#8B93F8',
41  you: '#fbbf24',
42  good: '#34d399',
43  warn: '#fbbf24',
44  bad: '#fb7185',
45  violet: '#a78bfa',
46  dim: '#8b8b98',
47} as const
48
49export const SANS = `Inter, 'Segoe UI Variable Text', 'Segoe UI', system-ui, sans-serif`
50export const MONO = `'JetBrains Mono', 'Cascadia Code', Consolas, ui-monospace, monospace`
51
52/** Who holds the move. */
53export type Actor = 'codex' | 'claude' | 'you' | 'both'
54
55export function actorColour(a: Actor) {
56  return a === 'claude' ? D.claude : a === 'you' ? D.you : a === 'both' ? D.violet : D.codex
57}
58
59/** Roughly how wide a line of text sets, so lines are cut before they run into something. */
60const width = (text: string, size: number, mono = false) => text.length * size * (mono ? 0.6 : 0.52)
61const room = (px: number, size: number, mono = false) => Math.max(4, Math.floor(px / (size * (mono ? 0.6 : 0.52))))
62const n1 = (v: number) => v.toFixed(1)
63
64/** A short stable id for one drawing's defs (several drawings share a page). */
65function idOf(...parts: (string | number | undefined)[]) {
66  let h = 2166136261
67  for (const ch of parts.join('|')) h = Math.imul(h ^ ch.charCodeAt(0), 16777619)
68  return `d${(h >>> 0).toString(36)}`
69}
70
71const EASE = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
72
73// ---------------------------------------------------------------------------
74// Ground
75
76/** The card: ink, a faint dot grid, the mover's colour washing in from its side, and a rim. */
77function ground(id: string, W: number, H: number, colour: string, o: { controls?: number; rx?: number; from?: 'left' | 'right' } = {}) {
78  const rx = o.rx ?? 16
79  const controls = o.controls ?? 0
80  const DW = W - controls
81  const fromRight = o.from === 'right'
82  return {
83    defs: `<linearGradient id="${id}-wash" x1="${fromRight ? 1 : 0}" y1="0" x2="${fromRight ? 0 : 1}" y2=".7"><stop offset="0" stop-color="${colour}" stop-opacity=".22"/><stop offset=".5" stop-color="${colour}" stop-opacity=".05"/><stop offset="1" stop-color="${colour}" stop-opacity="0"/></linearGradient>
84<pattern id="${id}-dots" width="14" height="14" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r=".8" fill="#fff" fill-opacity=".055"/></pattern>
85<linearGradient id="${id}-panel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1b1b25"/><stop offset="1" stop-color="#15151d"/></linearGradient>
86<clipPath id="${id}-card"><rect width="${W}" height="${H}" rx="${rx}"/></clipPath>`,
87    body: `<g clip-path="url(#${id}-card)"><rect width="${W}" height="${H}" fill="${D.ground}"/><rect width="${DW}" height="${H}" fill="url(#${id}-dots)"/><rect width="${DW}" height="${H}" fill="url(#${id}-wash)"/>${
88      controls ? `<rect x="${DW}" width="${controls}" height="${H}" fill="url(#${id}-panel)"/><path d="M${DW + 0.5} 0V${H}" stroke="#fff" stroke-opacity=".07"/>` : ''
89    }</g><rect x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="${rx - 0.5}" fill="none" stroke="#fff" stroke-opacity=".09"/>`,
90  }
91}
92
93/** Fades and lifts a line into place as it changes: the news of the moment. */
94function arrive(inner: string, delay = 0) {
95  return `<g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/><animateTransform attributeName="transform" type="translate" from="0 5" to="0 0" begin="${delay}s" dur="0.45s" fill="freeze" ${EASE}/>${inner}</g>`
96}
97
98// ---------------------------------------------------------------------------
99// Words
100
101/** How wide a kicker sets: capitals, tracked. */
102const kickerWidth = (words: string) => words.length * (10.5 * 0.66 + 1.5)
103
104/** A kicker cut to its room: the part after the last dot goes first. */
105function fitKicker(words: string, px: number) {
106  if (kickerWidth(words) <= px) return words
107  const head = words.split(' · ')[0] as string
108  if (kickerWidth(head) <= px) return head
109  return fit(head, Math.max(4, Math.floor(px / (10.5 * 0.66 + 1.5))))
110}
111
112/** The kicker: who does what, in tracked capitals, in the mover's colour. */
113function kicker(x: number, y: number, words: string, colour: string) {
114  return `<text x="${x}" y="${y}" font-size="10.5" font-weight="750" letter-spacing="1.5" fill="${colour}">${escape(words.toUpperCase())}</text>`
115}
116
117const VERB = /^(Reading|Searching|Listing|Running|Editing|Planning|Checking git:|Getting|Setting up|Handing|Waiting for)\s+(.+)$/
118
119/** The headline: "Reading <file>" sets the verb quiet and the file in mono; Codex's own words go in quotes. */
120function headline(x: number, y: number, maxW: number, text: string, o: { thought?: boolean; size?: number; colour?: string; quote?: string } = {}) {
121  const size = o.size ?? 19
122  if (o.thought) {
123    const q = o.quote ?? D.codex
124    const said = fit(text, room(maxW - 18, size * 0.88))
125    return `<text x="${x}" y="${y}" font-size="${n1(size * 0.88)}" font-weight="560" fill="${o.colour ?? '#ebe8ff'}"><tspan fill="${q}" font-weight="800">“</tspan>${escape(said)}<tspan fill="${q}" font-weight="800">”</tspan></text>`
126  }
127  const m = VERB.exec(text)
128  if (m && /[./\\]|^[\w-]+$/.test(m[2] as string)) {
129    const verb = m[1] as string
130    const vw = width(verb, size) + size * 0.42
131    const what = fit(m[2] as string, room(maxW - vw, size * 0.86, true))
132    return `<text x="${x}" y="${y}" font-size="${size}" font-weight="500" fill="#fff" fill-opacity=".5">${escape(verb)}</text><text x="${n1(x + vw)}" y="${y}" font-size="${n1(size * 0.86)}" font-family="${MONO}" font-weight="650" fill="${o.colour ?? D.ink}">${escape(what)}</text>`
133  }
134  return `<text x="${x}" y="${y}" font-size="${size}" font-weight="720" letter-spacing="-.25" fill="${o.colour ?? D.ink}">${escape(fit(text, room(maxW, size)))}</text>`
135}
136
137/** A long line over two, cut at a word. */
138function twoLines(text: string, perLine: number): [string, string?] {
139  const t = text.replace(/\s+/g, ' ').trim()
140  if (t.length <= perLine) return [t]
141  let cut = t.lastIndexOf(' ', perLine)
142  if (cut < perLine * 0.5) cut = perLine
143  return [t.slice(0, cut).trim(), fit(t.slice(cut).trim(), perLine)]
144}
145
146/** The facts line: a small progress rail first when there is one, then facts with soft dots between. */
147function facts(x: number, y: number, maxW: number, items: (string | [string, string])[], progress?: { steps: number; current: number; label: string; colour: string; live: boolean; id: string }) {
148  let out = ''
149  let at = x
150  if (progress && progress.steps > 1) {
151    const segW = Math.min(18, Math.max(10, 90 / progress.steps))
152    for (let i = 0; i < progress.steps; i++) {
153      const sx = at + i * (segW + 3)
154      const done = i < progress.current
155      const now = i === progress.current
156      out += `<rect x="${n1(sx)}" y="${y - 7}" width="${n1(segW)}" height="5" rx="2.5" fill="${done || now ? progress.colour : '#fff'}" fill-opacity="${done ? 1 : now ? 0.35 : 0.12}"/>`
157      if (now && progress.live) out += `<rect x="${n1(sx)}" y="${y - 7}" width="${n1(segW)}" height="5" rx="2.5" fill="${progress.colour}"><animate attributeName="opacity" values=".2;1;.2" dur="1.6s" repeatCount="indefinite"/></rect>`
158    }
159    at += progress.steps * (segW + 3) + 6
160    const count = `${Math.min(progress.current + 1, progress.steps)}/${progress.steps}`
161    const name = fit(progress.label, room(Math.min(maxW - (at - x), 240) - 30, 11.5))
162    out += `<text x="${n1(at)}" y="${y}" font-size="11.5" fill="#fff" fill-opacity=".72"><tspan fill="${progress.colour}" font-weight="750">${count}</tspan><tspan dx="6">${escape(name)}</tspan></text>`
163    at += width(count, 11.5) + width(name, 11.5) + 16
164  }
165  const start = at
166  const parts: string[] = []
167  for (const f of items) {
168    const [text, colour] = Array.isArray(f) ? f : [f, '']
169    if (!text) continue
170    const w = width(text, 11.5) + 16
171    if (at + w > x + maxW) break
172    // A dot between facts, and before the first when the rail comes first.
173    const dot = parts.length || start > x
174    parts.push(`<tspan${dot && parts.length ? ' dx="8"' : ''}>${dot ? '<tspan fill-opacity=".35">•</tspan> ' : ''}<tspan${colour ? ` fill="${colour}" fill-opacity="1"` : ''}>${escape(text)}</tspan></tspan>`)
175    at += w
176  }
177  if (parts.length) out += `<text x="${n1(start)}" y="${y}" font-size="11.5" fill="#fff" fill-opacity=".5">${parts.join('')}</text>`
178  return out
179}
180
181/** The clock in the corner: a live dot (or quiet), the time counting by itself. */
182function clockCorner(right: number, y: number, r: { clock?: number; time: string; stalled?: boolean; live: boolean; id: string; label?: string }) {
183  const clockW = 4 * 13 * 0.62 + 6
184  const dot = r.stalled ? D.warn : r.live ? D.good : '#fff'
185  const word = r.label ?? (r.stalled ? 'quiet' : r.live ? 'live' : '')
186  const ww = word.length * 7.2 + 4
187  return (
188    (word
189      ? `<circle cx="${n1(right - clockW - ww - 10)}" cy="${y - 4}" r="3" fill="${dot}"${r.live || r.stalled ? '' : ' fill-opacity=".5"'}>${r.live ? '<animate attributeName="opacity" values="1;.25;1" dur="1.6s" repeatCount="indefinite"/>' : ''}</circle><text x="${n1(right - clockW - ww - 3)}" y="${y}" font-size="10" font-weight="700" letter-spacing="1" fill="${dot}" fill-opacity="${r.live || r.stalled ? 0.9 : 0.45}">${escape(word.toUpperCase())}</text>`
190      : '') +
191    (r.clock !== undefined
192      ? liveClock(r.clock, right, y, { size: 13, colour: D.ink, weight: 650, anchor: 'end', id: `${r.id}-clock` })
193      : `<text x="${right}" y="${y}" text-anchor="end" font-size="13" font-weight="650" fill="${D.ink}">${escape(r.time)}</text>`)
194  )
195}
196
197// ---------------------------------------------------------------------------
198// The stage: the floor, the two of them, the spotlight, the beads
199
200/** One step of the work, as a bead on the floor. */
201export type Bead = {
202  kind: 'Read' | 'Search' | 'Run' | 'Edit' | 'Note' | 'Think' | 'Git' | 'List' | 'Plan' | 'claude' | 'you' | 'finding' | 'ticket' | 'answer'
203  label?: string
204  /** A finding's weight, which colours it. */
205  severity?: 'high' | 'medium' | 'low'
206  /** A step of Claude's own (its tools, while Codex works): Claude's colour, on Claude's side of the floor. */
207  claude?: boolean
208}
209
210const GLYPH: Record<Bead['kind'], string> = {
211  Read: '<path d="M-3.2 -4.2h4.2l2.4 2.4v6h-6.6z" fill="none" stroke-width="1.3" stroke-linejoin="round"/><path d="M-1.6 .2h3.4M-1.6 2.2h3.4" stroke-width="1.2" stroke-linecap="round"/>',
212  Search: '<circle cx="-.8" cy="-.8" r="2.9" fill="none" stroke-width="1.4"/><path d="M1.4 1.4l2.4 2.4" stroke-width="1.5" stroke-linecap="round"/>',
213  Run: '<path d="M-3.6 -2.6l2.4 2.2-2.4 2.2" fill="none" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/><path d="M.4 2.6h3.4" stroke-width="1.4" stroke-linecap="round"/>',
214  Edit: '<path d="M-3.4 3.4l.5-2.1 4.6-4.6a.8.8 0 0 1 1.1 0l.5.5a.8.8 0 0 1 0 1.1l-4.6 4.6z" fill="none" stroke-width="1.2" stroke-linejoin="round"/>',
215  Note: '<path d="M-3.4 .6c0-2 1-3 2.4-3.4M.6 .6c0-2 1-3 2.4-3.4" fill="none" stroke-width="1.4" stroke-linecap="round"/><circle cx="-2.2" cy="1.6" r="1.3"/><circle cx="1.8" cy="1.6" r="1.3"/>',
216  Think: '<circle cx="-2.4" cy="0" r="1.1"/><circle cx="0" cy="0" r="1.1"/><circle cx="2.4" cy="0" r="1.1"/>',
217  Git: '<circle cx="-2" cy="-2.6" r="1.3" fill="none" stroke-width="1.2"/><circle cx="-2" cy="2.8" r="1.3" fill="none" stroke-width="1.2"/><circle cx="2.4" cy="-.6" r="1.3" fill="none" stroke-width="1.2"/><path d="M-2 -1.3v2.8M2.4 .7c0 1.4-1.4 1.8-3.2 2" fill="none" stroke-width="1.2"/>',
218  List: '<path d="M-3.4 -2.6h6.8M-3.4 0h6.8M-3.4 2.6h4.4" stroke-width="1.3" stroke-linecap="round"/>',
219  Plan: '<path d="M-3.6 -2.2l1 1 1.6-1.8M-3.6 2l1 1 1.6-1.8M.8 -1.6h3M.8 2.6h3" fill="none" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>',
220  claude: '<rect x="-3.6" y="-2.6" width="7.2" height="5.2" rx=".8" fill="none" stroke-width="1.2"/><path d="M-3.6 -2.6L0 .4l3.6-3" fill="none" stroke-width="1.2" stroke-linejoin="round"/>',
221  you: '<rect x="-3.6" y="-2.6" width="7.2" height="5.2" rx=".8" fill="none" stroke-width="1.2"/><path d="M-3.6 -2.6L0 .4l3.6-3" fill="none" stroke-width="1.2" stroke-linejoin="round"/>',
222  finding: '',
223  ticket: '<circle r="4.4" fill="none" stroke-width="1.3"/><path d="M-1.5 -1.2a1.6 1.6 0 1 1 2.2 1.5c-.6.3-.7.6-.7 1.1" fill="none" stroke-width="1.2" stroke-linecap="round"/><circle cx="0" cy="2.9" r=".6"/>',
224  answer: '<path d="M-3.4 .2l2.1 2.1 4.6-4.8" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
225}
226
227const SEV = { high: D.bad, medium: D.warn, low: D.dim } as const
228
229function beadColour(b: Bead) {
230  if (b.claude) return D.claude
231  return b.kind === 'claude' || b.kind === 'ticket' || b.kind === 'answer' ? D.claude : b.kind === 'you' ? D.you : b.kind === 'finding' ? SEV[b.severity ?? 'low'] : D.codex
232}
233
234function beadBody(b: Bead, r: number) {
235  const c = beadColour(b)
236  if (b.kind === 'finding') return `<circle r="${r}" fill="${c}" fill-opacity=".2" stroke="${c}" stroke-width="1.5"/><text y="3.4" text-anchor="middle" font-size="${n1(r * 0.95)}" font-weight="800" fill="${c}">${escape(b.label ?? '')}</text>`
237  return `<circle r="${r}" fill="${D.ground}"/><circle r="${r}" fill="${c}" fill-opacity=".18" stroke="${c}" stroke-opacity=".85" stroke-width="1.3"/><g stroke="${c}" fill="${c}">${GLYPH[b.kind]}</g>`
238}
239
240/** A chip's words: a file's name, a search, a command; whole, unless it is very long. Notes go by icon. */
241function chipText(b: Bead) {
242  if (b.kind === 'ticket') return 'for Claude'
243  if (b.kind === 'answer') return 'answered'
244  if (!b.label || b.kind === 'Note' || b.kind === 'Think' || b.kind === 'Plan' || b.kind === 'claude' || b.kind === 'you') return ''
245  const t = b.label.split('\\').join('/').trim()
246  const name = b.kind === 'Read' || b.kind === 'Edit' || b.kind === 'List' ? (t.split('/').filter(Boolean).pop() ?? t) : b.kind === 'Run' ? t.split(/\s+/).slice(0, 2).join(' ') : t
247  return name.length > 22 ? `${name.slice(0, 21)}…` : name
248}
249
250/** A short name for a bead's label: a file's name, a command's first word. */
251function beadLabel(b: Bead) {
252  // Codex's own words and notes go by their icon alone: a sentence cut short reads as noise.
253  if (!b.label || b.kind === 'finding' || b.kind === 'Note' || b.kind === 'Think' || b.kind === 'Plan' || b.kind === 'claude' || b.kind === 'you') return ''
254  const t = b.label.replace(/\\/g, '/')
255  const name = b.kind === 'Read' || b.kind === 'Edit' ? (t.split('/').filter(Boolean).pop() ?? t) : t
256  return fit(name, 15)
257}
258
259/**
260 * A speech bubble beside whoever speaks, its tail toward them: three typing
261 * dots, then the words typing themselves in.
262 */
263function bubble(text: string, x: number, y: number, o: { side: 'left' | 'right'; tint: string; begin: number; room: number; id: string }) {
264  const size = 11.5
265  const words = fit(text.replace(/\s+/g, ' ').trim(), Math.max(6, Math.floor((o.room - 24) / (size * 0.53))))
266  const w = Math.round(Math.max(44, words.length * size * 0.53 + 24))
267  const h = 24
268  // `side`: which side of the speaker the bubble opens on.
269  const bx = o.side === 'right' ? x : x - w
270  const tail = o.side === 'right' ? `M${bx + 1} ${y + 3}L${bx - 7} ${y + 9}L${bx + 1} ${y + 9}Z` : `M${bx + w - 1} ${y + 3}L${bx + w + 7} ${y + 9}L${bx + w - 1} ${y + 9}Z`
271  const b = o.begin
272  const dots = [0, 1, 2].map(i => `<circle cx="${n1(bx + w / 2 - 7 + i * 7)}" cy="${y}" r="2" fill="#fff" fill-opacity=".6"><animate attributeName="cy" values="${y};${y - 2.5};${y}" dur=".6s" begin="${(i * 0.12).toFixed(2)}s" repeatCount="indefinite"/></circle>`).join('')
273  const body =
274    `<path d="${tail}" fill="#262632" stroke="${o.tint}" stroke-opacity=".55" stroke-linejoin="round"/>` +
275    `<rect x="${bx}" y="${y - h / 2}" width="${w}" height="${h}" rx="12" fill="#262632" stroke="${o.tint}" stroke-opacity=".55"/>` +
276    `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.1;.8;1" begin="${b.toFixed(2)}s" dur=".65s" fill="freeze"/>${dots}</g>` +
277    `<clipPath id="${o.id}"><rect x="${bx + 8}" y="${y - h / 2}" width="0" height="${h}"><animate attributeName="width" from="0" to="${w - 14}" begin="${(b + 0.6).toFixed(2)}s" dur="${Math.min(0.9, 0.25 + words.length * 0.025).toFixed(2)}s" fill="freeze"/></rect></clipPath>` +
278    `<text x="${bx + 12}" y="${y + 4}" font-size="${size}" font-weight="620" fill="${D.ink}" clip-path="url(#${o.id})">${escape(words)}</text>`
279  const ox = o.side === 'right' ? bx - 7 : bx + w + 7
280  return `<g transform="translate(${n1(ox)} ${y + 9})"><g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="${b.toFixed(2)}s" dur=".12s" fill="freeze"/><animateTransform attributeName="transform" type="scale" values=".4;1.08;1" keyTimes="0;.6;1" begin="${b.toFixed(2)}s" dur=".32s" fill="freeze"/><g transform="translate(${n1(-ox)} ${-(y + 9)})">${body}</g></g></g>`
281}
282
283export type Stage = {
284  actor: Actor
285  codexSprite?: Sprite
286  claudeMood?: ClawdMood
287  /** The person sits in Claude's place (they started it from the pane). */
288  you?: boolean
289  /** The steps so far, oldest first; the newest pops out of Codex. */
290  beads?: Bead[]
291  /** Work goes on: beads slide, the spotlight breathes. Still while blocked. */
292  live?: boolean
293  /** A question waits on the floor for Claude; the person's OK; a note on its way. */
294  waiting?: 'ask' | 'permit'
295  note?: 'toCodex' | 'toClaude' | 'both'
296  /** Findings land one by one rather than the newest alone. */
297  landing?: boolean
298  party?: boolean
299  verdictColour?: string
300  /** What they say as it lands: Codex first, then Claude. */
301  banter?: Banter
302  /** Codex thinks aloud: dots over it. */
303  thinking?: boolean
304  /** Gone quiet: a sweat drop and a slow sway. */
305  stalled?: boolean
306  /** Nothing runs: now and then one tosses the other a note. */
307  idle?: boolean
308  /** Claude's own recent steps while Codex works: from Claude's end of the floor, toward the middle. */
309  claudeBeads?: Bead[]
310}
311
312/**
313 * The floor across the card's foot, Claude standing at its left end and
314 * Codex at its right, the spotlight on the mover, and between them the beads.
315 */
316function stage(s: Stage, x0: number, x1: number, floor: number, id: string) {
317  const colour = actorColour(s.actor)
318  const lx = x0 + 32
319  const rx = x1 - 36
320  const u = 3.1
321  let out = ''
322
323  // The spotlight: a soft cone from above onto whoever has the move, and its pool on the floor.
324  const spots = s.actor === 'both' ? [lx, rx] : [s.actor === 'codex' ? rx : lx]
325  const spotW = 54
326  out += `<defs><linearGradient id="${id}-cone" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${colour}" stop-opacity="0"/><stop offset="1" stop-color="${colour}" stop-opacity=".2"/></linearGradient><radialGradient id="${id}-pool"><stop offset="0" stop-color="${colour}" stop-opacity=".5"/><stop offset="1" stop-color="${colour}" stop-opacity="0"/></radialGradient>
327<linearGradient id="${id}-floor" x1="${lx}" x2="${rx}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${s.you ? D.you : D.claude}" stop-opacity=".55"/><stop offset=".5" stop-color="#fff" stop-opacity=".12"/><stop offset="1" stop-color="${D.codex}" stop-opacity=".55"/></linearGradient></defs>`
328  for (const spotX of spots)
329    out += `<g><path d="M${n1(spotX - 10)} 0L${n1(spotX + 10)} 0L${n1(spotX + spotW)} ${floor}L${n1(spotX - spotW)} ${floor}Z" fill="url(#${id}-cone)">${s.live ? `<animate attributeName="opacity" values=".75;1;.75" dur="3s" repeatCount="indefinite"/>` : ''}</path><ellipse cx="${n1(spotX)}" cy="${floor}" rx="${n1(spotW)}" ry="7" fill="url(#${id}-pool)"/></g>`
330
331  // The floor itself.
332  out += `<path d="M${x0} ${floor}H${x1}" stroke="url(#${id}-floor)" stroke-width="1.5"/>`
333
334  // Claude (or the person) on the left, Codex on the right; the one without the move a little back.
335  const claudeBack = s.actor === 'codex' || s.actor === 'you'
336  const codexBack = s.actor === 'claude' || s.actor === 'you'
337  // Clawd always stands at the left (the person's moves are told by the amber light and what waits on the floor).
338  const claudeFig = clawd(lx, floor - 5 * u - 1, u, s.claudeMood ?? (claudeBack ? 'watch' : 'idle'))
339  const youFig = ''
340  const codexSize = 62
341  const codexFig = s.codexSprite ? codexSprite(s.codexSprite, rx, floor - codexSize * 0.46, codexSize) : codexAvatar(rx, floor - 17, 15)
342  out += `<ellipse cx="${lx}" cy="${floor + 1}" rx="24" ry="3" fill="#000" opacity=".5"/><ellipse cx="${rx}" cy="${floor + 1}" rx="22" ry="3" fill="#000" opacity=".5"/>`
343  // A little life: Codex bobs at its work; on a win both hop, twice, as the result lands.
344  const hop = (begin: number) => `<animateTransform attributeName="transform" type="translate" values="0 0;0 -13;0 0" keyTimes="0;.45;1" begin="${begin}s" dur="0.5s" repeatCount="2" fill="freeze" calcMode="spline" keySplines=".2 .7 .4 1;.5 0 .8 .4"/>`
345  const bob = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -2.5;0 0" dur="1.3s" repeatCount="indefinite" calcMode="spline" keyTimes="0;.5;1" keySplines=".4 0 .6 1;.4 0 .6 1"/>`
346  // Handing over a new step: a quick hop as the chip leaves its hands.
347  const handoff = `<animateTransform attributeName="transform" type="translate" values="0 0;0 -7;0 0" keyTimes="0;.4;1" dur="0.4s" fill="freeze" calcMode="spline" keySplines=".2 .7 .4 1;.5 0 .8 .4"/>`
348  const sway = `<animateTransform attributeName="transform" type="rotate" values="-3 ${rx} ${floor};3 ${rx} ${floor};-3 ${rx} ${floor}" dur="3.2s" repeatCount="indefinite"/>`
349  const codexMove = s.party ? hop(0.7) : s.stalled ? sway : s.live && s.actor !== 'claude' ? bob : ''
350  const codexHop = s.live && !s.party && (s.beads?.length ?? 0) > 0 ? handoff : ''
351  out += youFig
352  out += `<g opacity="${claudeBack ? 0.62 : 1}"><g>${s.party ? hop(0.55) : ''}${claudeFig}</g></g><g opacity="${codexBack ? 0.62 : 1}"><g>${codexMove}<g>${codexHop}${codexFig}</g></g></g>`
353  // Thinking: three dots rising in a bubble over Codex.
354  if (s.thinking && !s.stalled) {
355    const bx = rx + 20
356    const by = floor - 66
357    out += `<g transform="translate(${bx} ${by})"><circle cx="-14" cy="16" r="2.4" fill="#2a2a38" stroke="${D.codex}" stroke-opacity=".5"/><rect x="-12" y="-9" width="30" height="18" rx="9" fill="#2a2a38" stroke="${D.codex}" stroke-opacity=".5"/>${[0, 1, 2]
358      .map(i => `<circle cx="${-3 + i * 7}" cy="0" r="2.2" fill="${D.codex}"><animate attributeName="cy" values="0;-2.6;0" dur=".9s" begin="${(i * 0.15).toFixed(2)}s" repeatCount="indefinite"/><animate attributeName="opacity" values=".4;1;.4" dur=".9s" begin="${(i * 0.15).toFixed(2)}s" repeatCount="indefinite"/></circle>`)
359      .join('')}</g>`
360  }
361  // Gone quiet: a sweat drop sliding down Codex's side.
362  if (s.stalled) out += `<path d="M${rx + 18} ${floor - 52}q3 5 0 7q-3-2 0-7z" fill="#7dd3fc"><animateTransform attributeName="transform" type="translate" values="0 0;0 10" dur="1.6s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;1;0" dur="1.6s" repeatCount="indefinite"/></path>`
363  // Idle: every so often one tosses the other a note, which the other catches.
364  if (s.idle) {
365    const arc = (from: number, to: number, begin: number, tint: string) =>
366      `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.1;.9;1" dur="1.4s" begin="${begin}s" fill="freeze"/><animateMotion path="M${from} ${floor - 30}Q${(from + to) / 2} ${floor - 78} ${to} ${floor - 30}" dur="1.4s" begin="${begin}s" fill="freeze" ${EASE}/><g><animateTransform attributeName="transform" type="rotate" values="0;360" dur="1.4s" begin="${begin}s" fill="freeze"/><rect x="-6" y="-4" width="12" height="8" rx="1.5" fill="${D.ink}"/><circle cx="3.8" cy="2.2" r="1.6" fill="${tint}"/></g></g>`
367    out += arc(lx + 22, rx - 24, 1.2, D.claude) + arc(rx - 24, lx + 22, 4.2, D.codex)
368  }
369
370  // The person's place: over Claude, a call for them.
371  if (s.waiting === 'permit') {
372    const gate = lx + 78
373    out += `<g transform="translate(${gate} ${floor})"><g><animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0" dur="1.8s" repeatCount="indefinite"/><rect x="-13" y="-30" width="26" height="22" rx="6" fill="${D.you}" fill-opacity=".18" stroke="${D.you}" stroke-width="1.5"/><path d="M-5.5 -30v-5a5.5 5.5 0 0 1 11 0v5" fill="none" stroke="${D.you}" stroke-width="1.8"/><circle cy="-19" r="2.4" fill="${D.you}"/></g><ellipse cy="1" rx="16" ry="2.2" fill="${D.you}" opacity=".35"/></g>`
374    out += `<path d="M${gate + 22} ${floor - 4}H${rx - 34}" stroke="${D.you}" stroke-opacity=".5" stroke-dasharray="2 5"/>`
375  }
376
377  // A question: the envelope sits on the floor before Claude, sealed with a question mark, nudging it.
378  if (s.waiting === 'ask') {
379    const ex = lx + 74
380    out += `<path d="M${ex + 16} ${floor - 9}H${rx - 34}" stroke="${D.codex}" stroke-opacity=".45" stroke-dasharray="2 5"/>`
381    out += `<g transform="translate(${ex} ${floor - 9})"><g><animateTransform attributeName="transform" type="translate" values="0 0;-3 -2;0 0" dur="1.4s" repeatCount="indefinite"/><rect x="-11" y="-7.5" width="22" height="15" rx="2.5" fill="${D.ink}"/><path d="M-11 -7.5L0 1.5L11 -7.5" fill="none" stroke="${D.ground}" stroke-width="1.3" stroke-linejoin="round"/><circle cx="8" cy="5" r="6" fill="${D.codex}"/><text x="8" y="8.4" text-anchor="middle" font-size="9" font-weight="800" fill="${D.ground}">?</text></g></g>`
382    out += `<g transform="translate(${rx + 18} ${floor - 70})"><g><animateTransform attributeName="transform" type="scale" values="0;1.15;1" keyTimes="0;.6;1" dur=".4s" fill="freeze"/><circle r="9" fill="${D.codex}"/><text y="4" text-anchor="middle" font-size="12" font-weight="800" fill="${D.ground}">?</text></g></g>`
383  }
384
385  // Claude's own steps, from its end toward the middle (newest nearest the middle), never past it.
386  let claudeEnd = lx + 40
387  if (s.claudeBeads?.length && !s.waiting) {
388    const ch = 20
389    const top = floor - ch - 3
390    const limit = (lx + rx) / 2 - 10
391    let at = lx + 42
392    const placed: string[] = []
393    for (const b of s.claudeBeads.slice(-4)) {
394      const text = chipText(b)
395      const w = text ? 24 + width(text, 10.5, true) + 8 : 22
396      if (at + w > limit) break
397      const newest = b === s.claudeBeads[s.claudeBeads.length - 1]
398      placed.push(
399        `<g opacity="${newest ? 1 : 0.7}"><rect x="${n1(at)}" y="${top}" width="${n1(w)}" height="${ch}" rx="${ch / 2}" fill="${D.claude}" fill-opacity="${newest ? 0.2 : 0.07}" stroke="${D.claude}" stroke-opacity="${newest ? 0.9 : 0.4}"/><g transform="translate(${n1(at + 11)} ${n1(top + ch / 2)}) scale(.95)" stroke="${D.claude}" fill="${D.claude}">${GLYPH[b.kind]}</g>${text ? `<text x="${n1(at + 22)}" y="${top + 14}" font-size="10.5" font-family="${MONO}" fill="#fff" fill-opacity="${newest ? 0.95 : 0.6}">${escape(text)}</text>` : ''}</g>`,
400      )
401      at += w + 6
402    }
403    out += placed.join('')
404    claudeEnd = Math.max(claudeEnd, at + 8)
405  }
406
407  // The trail: each step a chip standing on the floor, its icon and its name inside, laid out
408  // from Codex leftward by the room each really needs; what does not fit drops off the far end.
409  const beads = s.beads ?? []
410  if (beads.length && !s.waiting) {
411    const ch = 20
412    const top = floor - ch - 3
413    const startX = rx - 44
414    const endX = claudeEnd
415    const chips: { b: Bead; x: number; w: number; text: string }[] = []
416    let at = startX
417    for (let i = beads.length - 1; i >= 0 && chips.length < 10; i--) {
418      const b = beads[i] as Bead
419      const text = chipText(b)
420      const w = b.kind === 'finding' ? 30 : text ? 24 + width(text, 10.5, true) + 8 : 22
421      if (at - w < endX) break
422      at -= w
423      chips.unshift({ b, x: at, w, text })
424      at -= 6
425    }
426    const chip = (c: (typeof chips)[number], newest: boolean) => {
427      const colour = beadColour(c.b)
428      const cy = top + ch / 2
429      if (c.b.kind === 'finding')
430        return `<rect x="${n1(c.x)}" y="${top}" width="${c.w}" height="${ch}" rx="${ch / 2}" fill="${colour}" fill-opacity=".18" stroke="${colour}" stroke-width="1.3"/><text x="${n1(c.x + c.w / 2)}" y="${top + 14}" text-anchor="middle" font-size="10.5" font-weight="800" fill="${colour}">${escape(c.b.label ?? '')}</text>`
431      return (
432        `<rect x="${n1(c.x)}" y="${top}" width="${n1(c.w)}" height="${ch}" rx="${ch / 2}" fill="${newest ? colour : '#fff'}" fill-opacity="${newest ? 0.2 : 0.06}" stroke="${colour}" stroke-opacity="${newest ? 0.9 : 0.35}"/>` +
433        `<g transform="translate(${n1(c.x + 11)} ${n1(cy)}) scale(.95)" stroke="${colour}" fill="${colour}">${GLYPH[c.b.kind]}</g>` +
434        (c.text ? `<text x="${n1(c.x + 22)}" y="${top + 14}" font-size="10.5" font-family="${MONO}" font-weight="${newest ? 650 : 500}" fill="#fff" fill-opacity="${newest ? 0.95 : 0.6}">${escape(c.text)}</text>` : '')
435      )
436    }
437    let rest = ''
438    let newest = ''
439    chips.forEach((c, i) => {
440      const last = i === chips.length - 1
441      if (s.landing) {
442        const begin = (0.35 + i * 0.12).toFixed(2)
443        rest += `<g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="${begin}s" dur=".15s" fill="freeze"/><animateTransform attributeName="transform" type="translate" values="0 -22;0 2;0 0" keyTimes="0;.7;1" begin="${begin}s" dur=".5s" fill="freeze"/>${chip(c, false)}</g>`
444      } else if (last && s.live) {
445        // The newest pops out of Codex and settles into its place; a ring marks the landing.
446        newest = `<g opacity="0"><animate attributeName="opacity" from="0" to="1" dur=".2s" fill="freeze"/><animateTransform attributeName="transform" type="translate" from="${n1(rx - 20 - c.x)} -18" to="0 0" dur=".6s" fill="freeze" ${EASE}/>${chip(c, true)}</g>`
447      } else {
448        rest += `<g opacity="${n1(Math.max(0.45, 1 - (chips.length - 1 - i) * 0.08))}">${chip(c, last)}</g>`
449      }
450    })
451    // Live, the older chips slide along by the newest one's room: the trail moves on.
452    const shift = chips.length ? (chips[chips.length - 1] as (typeof chips)[number]).w + 6 : 0
453    out += s.live && !s.landing && chips.length > 1 ? `<g><animateTransform attributeName="transform" type="translate" from="${n1(shift)} 0" to="0 0" dur=".6s" fill="freeze" ${EASE}/>${rest}</g>${newest}` : rest + newest
454    // A faint pulse runs along the floor from Codex toward Claude while work goes on.
455    if (s.live) out += `<circle cy="${floor}" r="2" fill="${colour}"><animate attributeName="cx" from="${rx - 30}" to="${lx + 30}" dur="2.8s" repeatCount="indefinite"/><animate attributeName="opacity" values="0;.9;0" dur="2.8s" repeatCount="indefinite"/></circle>`
456  }
457
458  // A note on its way along the floor.
459  const envelope = (from: number, to: number, tint: string, begin = 0) =>
460    `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.12;.85;1" dur="2.2s" begin="${begin}s" repeatCount="indefinite"/><animateMotion path="M${from} ${floor - 26}Q${(from + to) / 2} ${floor - 46} ${to} ${floor - 26}" dur="2.2s" begin="${begin}s" repeatCount="indefinite" ${EASE}/><rect x="-7" y="-4.6" width="14" height="9.2" rx="1.8" fill="${D.ink}"/><path d="M-7 -4.6L0 .8L7 -4.6" fill="none" stroke="${D.ground}" stroke-width="1.1" stroke-linejoin="round"/><circle cx="4.6" cy="2.6" r="1.9" fill="${tint}"/></g>`
461  if (s.note === 'toCodex') out += envelope(lx + 20, rx - 22, s.you ? D.you : D.claude)
462  if (s.note === 'toClaude') out += envelope(rx - 22, lx + 20, D.codex)
463  if (s.note === 'both') out += envelope(lx + 20, rx - 22, D.claude) + envelope(rx - 22, lx + 20, D.codex, 1.1)
464
465  if (s.banter) {
466    const half = (rx - lx) / 2 - 40
467    out += bubble(s.banter.codex, rx - 40, floor - 36, { side: 'left', tint: D.codex, begin: 0.95, room: half, id: `${id}-says` })
468    if (s.banter.claude) out += bubble(s.banter.claude, lx + 36, floor - 36, { side: 'right', tint: s.you ? D.you : D.claude, begin: 2.05, room: half, id: `${id}-answers` })
469  }
470  if (s.party) out += confetti((lx + rx) / 2, floor - 30, 0.5, [s.verdictColour ?? D.good, D.claude, D.codex, '#ffd54a', D.ink])
471  return out
472}
473
474// ---------------------------------------------------------------------------
475// The band: one job, large
476
477export const DUET_HERO_HEIGHT = 144
478export const DUET_TALL_HEIGHT = 170
479
480export type DuetHero = BandHero & { beads?: Bead[]; claudeBeads?: Bead[]; askedAgo?: string; checks?: string; controls?: number; ref?: string }
481
482function actorOf(r: BandHero): Actor {
483  if (r.permit) return 'you'
484  if (r.turn === 'claude' || r.asking) return 'claude'
485  if (r.turn === 'both') return 'both'
486  return 'codex'
487}
488
489function kickerOf(r: BandHero, actor: Actor) {
490  if (r.permit) return 'Needs your OK'
491  if (r.asking) return 'Codex asks Claude · paused'
492  if (r.queued) return 'Codex · queued'
493  if (r.stalled) return 'Codex · gone quiet'
494  if (actor === 'claude') return `Claude's turn · ${r.headline}`
495  if (actor === 'both') return `Both at work · ${r.headline}`
496  return `${r.you ? 'Codex, for you' : 'Codex'} · ${r.headline}`
497}
498
499function progressOf(r: BandHero) {
500  if (r.plan?.steps.length && r.kind !== 'image') {
501    const steps = r.plan.steps
502    const current = steps.findIndex(s => s !== 'done')
503    return { steps: steps.length, current: current < 0 ? steps.length : current, label: r.plan.now ?? '' }
504  }
505  if (r.strip) return { steps: r.strip.labels.length, current: r.strip.current, label: r.strip.labels[r.strip.current] ?? r.strip.labels[r.strip.labels.length - 1] ?? '' }
506  return undefined
507}
508
509/**
510 * One job live on the band: who does what and how long (the kicker and the
511 * clock), what is happening now (the headline), progress and facts, and the
512 * stage under them. The band draws its Buttons over the panel on the right,
513 * and a permission's answers in the middle of a tall one's floor.
514 */
515export function duetHeroSvg(r: DuetHero, width_: number) {
516  const W = Math.round(Math.max(560, width_))
517  const tall = Boolean(r.permit || r.asking)
518  const H = tall ? DUET_TALL_HEIGHT : DUET_HERO_HEIGHT
519  const controls = r.controls ?? BAND_CONTROLS
520  const DW = W - controls
521  const actor = actorOf(r)
522  const colour = r.stalled ? D.warn : actorColour(actor)
523  const id = idOf('hero', W, r.title, r.kind, actor)
524  const live = !r.queued && !r.permit && !r.asking && !r.codexDone && !r.stalled
525  const g = ground(id, W, H, colour, { controls, from: actor === 'codex' ? 'right' : 'left' })
526
527  const x = 24
528  const right = DW - 22
529  const textW = right - x
530  const clockRoom = 124
531  const k = fitKicker(kickerOf(r, actor), textW - clockRoom)
532  const kw = kickerWidth(k) + 10
533  const titleRoom = textW - kw - clockRoom
534  const line1 =
535    kicker(x, 30, k, colour) +
536    (titleRoom > 50 ? `<text x="${n1(x + kw)}" y="30" font-size="12" font-weight="560" fill="#fff" fill-opacity=".55">${escape(fit(r.title, room(titleRoom, 12)))}</text>` : '') +
537    clockCorner(right, 30, { clock: r.clock, time: r.time, stalled: r.stalled, live, id, label: r.queued ? 'queued' : r.codexDone ? (r.timeLabel ?? 'waiting') : undefined })
538
539  let line2 = ''
540  let next = 82
541  if (r.asking || r.permit) {
542    const [a, b] = twoLines(r.doing.replace(/^🔒\s*/, ''), room(textW, 17))
543    const q = (t: string, open: boolean, close: boolean) => `${open && r.asking ? `<tspan fill="${D.codex}" font-weight="800">“</tspan>` : ''}${escape(t)}${close && r.asking ? `<tspan fill="${D.codex}" font-weight="800">”</tspan>` : ''}`
544    line2 = arrive(`<text x="${x}" y="58" font-size="17" font-weight="640" fill="${D.ink}">${q(a, true, !b)}</text>` + (b ? `<text x="${x}" y="80" font-size="17" font-weight="640" fill="${D.ink}">${q(b, false, true)}</text>` : ''))
545    next = b ? 102 : 82
546  } else {
547    const what = r.codexDone || r.queued ? r.doing : r.doing || r.headline
548    line2 = arrive(headline(x, 60, textW, what, { thought: r.thought, colour: r.stalled ? D.warn : undefined }))
549  }
550
551  let items: (string | [string, string])[] = []
552  const async: [string, string][] = [
553    ...(r.tickets ? [[`? ${r.tickets} question${r.tickets === 1 ? '' : 's'} for Claude · Codex carries on`, D.claude] as [string, string]] : []),
554    ...(r.nextUp ? [[`⏭ ${r.nextUp} queued for the next round`, D.violet] as [string, string]] : []),
555  ]
556  if (r.asking) items = [[`Waiting for Claude's answer${r.askedAgo ? ` · asked ${r.askedAgo}` : ''}`, D.claude], 'Codex carries on once it has it']
557  else if (r.permit) items = [['Codex waits for you', D.you], 'Allow it once, always for this site, or deny']
558  else if (r.steer) {
559    const who = r.steer.from === 'codex' ? 'Codex told Claude' : `${r.steer.from === 'claude' ? 'Claude' : 'You'} told Codex`
560    items = [[`✉ ${who}: ${fit(r.steer.text, 70)}`, r.steer.from === 'codex' ? D.codex : r.steer.from === 'claude' ? D.claude : D.you]]
561  } else if (r.growing && r.kind === 'task') items = [`${r.growing.files} file${r.growing.files === 1 ? '' : 's'} changed`, [`+${r.growing.added}`, D.good], [`−${r.growing.removed}`, D.bad], r.checks ?? '', r.codexModel]
562  else
563    items = [
564      r.counts.files ? `${r.counts.files} file${r.counts.files === 1 ? '' : 's'} read` : '',
565      r.counts.searches ? `${r.counts.searches} search${r.counts.searches === 1 ? '' : 'es'}` : '',
566      r.counts.edits ? `${r.counts.edits} edit${r.counts.edits === 1 ? '' : 's'}` : '',
567      r.scope ?? '',
568      r.codexModel,
569    ]
570  if (async.length && !r.asking && !r.permit) items = [...async, ...items]
571  const pr = r.asking || r.permit || async.length ? undefined : progressOf(r)
572  const line3 = facts(x, next, textW, items, pr ? { ...pr, colour, live, id } : undefined)
573
574  const floor = H - 16
575  const scene = stage(
576    {
577      actor,
578      codexSprite: r.codexSprite,
579      claudeMood: r.claudeMood,
580      you: r.you || r.permit,
581      beads: r.beads,
582      live,
583      waiting: r.permit ? 'permit' : r.asking ? 'ask' : undefined,
584      note: r.permit || r.asking ? undefined : r.toward === 'codex' ? 'toCodex' : r.toward === 'claude' ? 'toClaude' : r.toward === 'both' ? 'both' : undefined,
585      thinking: live && actor === 'codex' && (r.thought || r.phase === 'thinking') && r.kind !== 'task',
586      stalled: r.stalled,
587      claudeBeads: r.claudeBeads,
588    },
589    14,
590    DW - 14,
591    floor,
592    id,
593  )
594
595  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${SANS}">
596<defs>${g.defs}</defs>
597${g.body}
598<g clip-path="url(#${id}-card)">${scene}</g>
599${line1}
600${line2}
601${line3}
602</svg>`
603}
604
605// ---------------------------------------------------------------------------
606// The band: several jobs, one row each
607
608export const DUET_ROW_HEIGHT = 62
609
610export function duetRowSvg(r: BandRow & { controls?: number }, width_: number) {
611  const W = Math.round(Math.max(r.controls === 0 ? 260 : 480, width_))
612  const H = DUET_ROW_HEIGHT
613  const controls = r.controls ?? BAND_CONTROLS
614  const DW = W - controls
615  const actor: Actor = r.permit ? 'you' : r.asking || r.turn === 'claude' ? 'claude' : r.turn === 'both' ? 'both' : 'codex'
616  const colour = r.stalled ? D.warn : actorColour(actor)
617  const id = idOf('row', W, r.title, actor)
618  const live = !r.queued && !r.permit && !r.asking && !r.stalled
619  const g = ground(id, W, H, colour, { controls, rx: 14 })
620  // The mover alone, standing on a short floor at the left.
621  const fx = 36
622  const floor = H - 12
623  const who =
624    actor === 'claude'
625      ? clawd(fx, floor - 13, 2.6, r.claudeMood ?? 'think')
626      : actor === 'you'
627        ? `<g transform="translate(${fx} ${floor - 15})"><circle r="12" fill="${D.you}" fill-opacity=".2" stroke="${D.you}" stroke-width="1.4"/><circle cy="-3" r="3.6" fill="${D.you}"/><path d="M-6.6 7.4a6.6 5.6 0 0 1 13.2 0" fill="${D.you}"/></g>`
628        : r.codexSprite
629          ? codexSprite(r.codexSprite, fx, floor - 19, 40)
630          : codexAvatar(fx, floor - 15, 13)
631  const scene = `<ellipse cx="${fx}" cy="${floor - 2}" rx="26" ry="6" fill="${colour}" opacity=".25"/><path d="M12 ${floor}H62" stroke="${colour}" stroke-opacity=".5"/>${who}`
632  const x = 76
633  const right = DW - 18
634  const textW = right - x
635  const extra = [r.tickets ? `${r.tickets} for Claude` : '', r.nextUp ? `${r.nextUp} next` : ''].filter(Boolean).join(' · ')
636  const k = fitKicker(r.permit ? 'Needs your OK' : r.asking ? 'Codex asks Claude' : r.stalled ? 'Gone quiet' : r.queued ? 'Queued' : extra ? `${r.tag} · ${extra}` : r.tag, textW - 124)
637  const kw = kickerWidth(k) + 10
638  const line1 = kicker(x, 25, k, colour) + `<text x="${n1(x + kw)}" y="25" font-size="12" font-weight="560" fill="#fff" fill-opacity=".55">${escape(fit(r.title, room(textW - kw - 124, 12)))}</text>` + clockCorner(right, 25, { clock: r.clock, time: r.time, stalled: r.stalled, live, id })
639  const line2 = arrive(headline(x, 47, textW, r.asking || r.permit ? r.doing.replace(/^🔒\s*/, '') : r.doing, { thought: r.thought || r.asking, size: 14.5 }))
640  const pulse = live ? `<rect x="0" y="${H - 2}" width="80" height="2" fill="${colour}" opacity=".8"><animate attributeName="x" from="-80" to="${DW}" dur="2.4s" repeatCount="indefinite"/></rect>` : ''
641  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${SANS}">
642<defs>${g.defs}</defs>
643${g.body}
644<g clip-path="url(#${id}-card)">${scene}${pulse}</g>
645${line1}
646${line2}
647</svg>`
648}
649
650// ---------------------------------------------------------------------------
651// The band: a result as it lands
652
653export const DUET_RESULT_HEIGHT = 160
654
655export type DuetResult = BandResult & { severities?: { high: number; medium: number; low: number }; ago?: string; findings?: { id: string; severity: 'high' | 'medium' | 'low' }[] }
656
657/** Open findings by weight, as pills. */
658function severityPills(x: number, y: number, sev: { high: number; medium: number; low: number }) {
659  let at = x
660  let out = ''
661  for (const [key, colour] of [['high', D.bad], ['medium', D.warn], ['low', D.dim]] as const) {
662    const count = sev[key]
663    if (!count) continue
664    const text = `${count} ${key}`
665    // Bold, so wider than plain text sets: measured generously, so the word never runs out of its pill.
666    const w = text.length * 11 * 0.64 + 26
667    out += `<rect x="${n1(at)}" y="${y - 13}" width="${n1(w)}" height="19" rx="9.5" fill="${colour}" fill-opacity=".16"/><circle cx="${n1(at + 9.5)}" cy="${y - 3.5}" r="3" fill="${colour}"/><text x="${n1(at + 17)}" y="${y}" font-size="11" font-weight="700" fill="${colour}">${escape(text)}</text>`
668    at += w + 6
669  }
670  return { svg: out, end: at }
671}
672
673export function duetResultSvg(r: DuetResult, width_: number) {
674  const W = Math.round(Math.max(480, width_))
675  const H = DUET_RESULT_HEIGHT
676  const controls = r.controls ?? BAND_CONTROLS
677  const DW = W - controls
678  const colour = r.colour
679  const id = idOf('result', W, r.title, r.stamp)
680  const g = ground(id, W, H, colour, { controls })
681  const good = r.kind === 'approve' || r.kind === 'approve_with_nits' || r.kind === 'task' || r.kind === 'task_applied'
682  const failed = r.kind === 'failed' || r.kind === 'task_failed'
683
684  const x = 24
685  const right = DW - 22
686  const textW = right - x
687  const k = fitKicker(`${r.tag} · ${r.ago ?? 'just now'}`, textW / 2)
688  const kw = kickerWidth(k) + 10
689  const line1 = arrive(kicker(x, 30, k, colour) + `<text x="${n1(x + kw)}" y="30" font-size="12" font-weight="560" fill="#fff" fill-opacity=".55">${escape(fit(r.title, room(textW - kw, 12)))}</text>`, 0.05)
690  // The verdict, large, stamped in; the gist beside it.
691  const stampSize = 22
692  const sw = width(r.stamp, stampSize) * 1.04 + 16
693  const summary = r.summary ? fit(r.summary, room(textW - sw - 6, 13.5)) : ''
694  const stamp = `<g transform="translate(${x} 62)"><g opacity="0"><animate attributeName="opacity" from="0" to="1" begin=".15s" dur=".1s" fill="freeze"/><animateTransform attributeName="transform" type="scale" values="1.6;.96;1" keyTimes="0;.7;1" begin=".15s" dur=".4s" fill="freeze"/>${
695    r.moment === 'wobble' ? `<g><animateTransform attributeName="transform" type="rotate" values="0;-4;3;-1;0" begin=".55s" dur=".5s" fill="freeze"/>` : '<g>'
696  }<text font-size="${stampSize}" font-weight="800" letter-spacing="-.4" fill="${colour}">${escape(r.stamp)}</text></g></g></g>`
697  const line2 = stamp + (summary ? arrive(`<text x="${n1(x + sw)}" y="61" font-size="13.5" fill="#fff" fill-opacity=".8">${escape(summary)}</text>`, 0.45) : '')
698  const pills = r.severities ? severityPills(x, 88, r.severities) : { svg: '', end: x }
699  const rest = r.facts.filter(f => !/\bhigh$/.test(f))
700  const line3 = arrive(pills.svg + facts(pills.end + (pills.svg ? 4 : 0), 88, right - pills.end, rest), 0.6)
701
702  const floor = H - 16
703  const beads: Bead[] = (r.findings ?? []).map(f => ({ kind: 'finding', label: f.id, severity: f.severity }))
704  const scene = stage(
705    {
706      actor: 'both',
707      codexSprite: r.codexSprite,
708      claudeMood: r.claudeMood ?? (failed ? 'sad' : good ? 'happy' : 'nod'),
709      beads,
710      landing: true,
711      banter: r.banter,
712      party: r.moment === 'spark',
713      verdictColour: colour,
714    },
715    14,
716    DW - 14,
717    floor,
718    id,
719  )
720
721  const drawn = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${SANS}">
722<defs>${g.defs}</defs>
723${g.body}
724<g clip-path="url(#${id}-card)"><rect width="${DW}" height="3" fill="${colour}" opacity=".9"/>${scene}</g>
725${line1}
726${line2}
727${line3}
728</svg>`
729  return settle(drawn)
730}
731
732// ---------------------------------------------------------------------------
733// The mark
734
735/** "Claudex" as a wordmark: Claude's colour running into Codex's. */
736export function wordmarkSvg(size = 15) {
737  const w = Math.round(width('Claudex', size) * 1.12 + 4)
738  const h = Math.round(size * 1.35)
739  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" font-family="${SANS}"><defs><linearGradient id="wm${size}" x1="0" x2="1"><stop offset="0" stop-color="${D.claude}"/><stop offset=".55" stop-color="${D.violet}"/><stop offset="1" stop-color="${D.codex}"/></linearGradient></defs><text x="1" y="${(size * 1.05).toFixed(1)}" font-size="${size}" font-weight="800" letter-spacing="-.3" fill="url(#wm${size})">Claudex</text></svg>`
740}
741
742export { CLAUDE, CODEX }
743
744// ---------------------------------------------------------------------------
745// The pane's hero: the project, what is going on, the two of them on the floor
746
747export type PaneHero = {
748  project: string
749  /** Who has the move now, across this chat's work. */
750  actor: Actor | 'idle'
751  /** The big line: what is going on. */
752  title: string
753  /** One line of story under it. */
754  story: string
755  running: number
756  needYou: number
757  elsewhere: number
758  usage?: { percent: number; window: string }
759  codexSprite?: Sprite
760  claudeMood?: ClawdMood
761  beads?: Bead[]
762  /** A win just landed: they cheer. */
763  party?: boolean
764  /** Codex's tool waits on the person's OK (the lock), rather than other things waiting on them. */
765  permit?: boolean
766  /** A poke: who was poked, and what they trade. */
767  banter?: Banter
768}
769
770export const PANE_HERO_HEIGHT = 196
771
772export function paneHeroSvg(h: PaneHero, width_: number) {
773  const W = Math.max(300, Math.round(width_))
774  const H = PANE_HERO_HEIGHT
775  const actor: Actor = h.actor === 'idle' ? 'both' : h.actor
776  const colour = h.actor === 'idle' ? D.violet : actorColour(h.actor)
777  const id = idOf('pane', W, h.actor, h.title)
778  const g = ground(id, W, H, colour, { rx: 18, from: h.actor === 'codex' ? 'right' : 'left' })
779  const x0 = 22
780  const xEnd = W - 22
781
782  // Line one: the mark and project, the status on the right (the most important first; what does not fit goes).
783  const mark = `<text x="${x0}" y="32" font-size="11" font-weight="800" letter-spacing="2.2"><tspan fill="${D.claude}">CLAUD</tspan><tspan fill="${D.codex}">EX</tspan><tspan fill="#fff" fill-opacity=".45" font-weight="650"> · ${escape(fit(h.project, 22).toUpperCase())}</tspan></text>`
784  const status: [string, string][] = []
785  if (h.needYou) status.push([`● ${h.needYou} NEED${h.needYou === 1 ? 'S' : ''} YOU`, D.you])
786  if (h.running) status.push([`● ${h.running} RUNNING`, D.codex])
787  if (h.elsewhere) status.push([`${h.elsewhere} IN OTHER CHATS`, D.dim])
788  if (h.usage) status.push([`${h.usage.window.toUpperCase()} ${Math.round(h.usage.percent)}%`, h.usage.percent >= 90 ? D.bad : h.usage.percent >= 75 ? D.warn : D.dim])
789  const headRoom = xEnd - x0 - (8 + h.project.length) * 9 - 10
790  const wide = (list: [string, string][]) => list.reduce((w, [t]) => w + t.length * 8 + 14, 0)
791  while (status.length && wide(status) > headRoom) status.pop()
792  const spans = status
793    .slice()
794    .reverse()
795    .map(([t, c], i) => `<tspan fill="${c}"${i ? ' dx="14"' : ''}>${escape(t)}</tspan>`)
796    .join('')
797  const right = status.length ? `<text x="${xEnd}" y="32" text-anchor="end" font-size="10.5" font-weight="750" letter-spacing="1.2">${spans}</text>` : ''
798
799  // The big line and the story.
800  const fs = Math.max(19, Math.min(30, (xEnd - x0) / (Math.max(8, h.title.length) * 0.55)))
801  const title = arrive(`<text x="${x0 - 1}" y="${n1(46 + fs * 0.85)}" font-size="${n1(fs)}" font-weight="780" letter-spacing="-.6" fill="${D.ink}">${escape(fit(h.title, room(xEnd - x0, fs)))}</text>`)
802  const story = arrive(`<text x="${x0}" y="${n1(46 + fs * 0.85 + 22)}" font-size="12.5" fill="#fff" fill-opacity=".58">${escape(fit(h.story, room(xEnd - x0, 12.5)))}</text>`, 0.12)
803
804  const floor = H - 18
805  const scene = stage(
806    {
807      actor,
808      codexSprite: h.codexSprite,
809      claudeMood: h.claudeMood,
810      beads: h.beads,
811      you: h.actor === 'you',
812      idle: h.actor === 'idle',
813      live: h.actor === 'codex' || h.actor === 'both',
814      waiting: h.permit ? 'permit' : undefined,
815      landing: h.actor === 'you' && !h.permit,
816      banter: h.banter,
817      party: h.party,
818      verdictColour: D.good,
819    },
820    10,
821    W - 10,
822    floor,
823    id,
824  )
825  const drawn = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${SANS}">
826<defs>${g.defs}</defs>
827${g.body}
828<g clip-path="url(#${id}-card)">${scene}</g>
829${mark}
830${right}
831${title}
832${story}
833</svg>`
834  return h.actor === 'idle' || h.party || h.banter ? settle(drawn, 10) : drawn
835}
836
837// ---------------------------------------------------------------------------
838// The card in the chat: the band's look at the card's width, with no panel
839
840/** A round's card: live, it is the band's hero held still (the band plays it); landed, the result. */
841export function duetCardSvg(s: CardStage & { clock?: number }, width_: number) {
842  const W = Math.round(Math.max(420, width_))
843  const tag = s.tag ?? (s.kind === 'task' ? 'Task' : s.kind === 'image' ? 'Picture' : 'Review')
844  if (s.phase === 'done' || s.phase === 'failed') {
845    const sev = s.severities
846    const findings = s.findings?.slice(0, 9)
847    const facts = [
848      s.consensus ? `Claude agrees ${s.consensus.agree}/${s.consensus.total}` : '',
849      s.diff ? `${s.diff.files} file${s.diff.files === 1 ? '' : 's'} +${s.diff.added} −${s.diff.removed}` : '',
850      s.time,
851      s.tokens ?? '',
852      s.codexModel,
853    ].filter(Boolean)
854    return duetResultSvg(
855      {
856        tag,
857        title: s.headline,
858        stamp: s.stamp ?? (s.phase === 'failed' ? 'Could not finish' : 'Answered'),
859        kind: s.phase === 'failed' ? 'failed' : s.moment === 'spark' ? 'approve' : 'answer',
860        colour: s.stampColour ?? (s.phase === 'failed' ? D.bad : D.codex),
861        moment: s.moment,
862        facts,
863        summary: s.sub,
864        codexSprite: s.codexSprite,
865        claudeMood: s.claudeMood,
866        banter: s.banter,
867        controls: 0,
868        severities: sev && sev.high + sev.medium + sev.low ? sev : undefined,
869        findings,
870        ago: s.ref,
871      },
872      W,
873    )
874  }
875  return duetHeroSvg(
876    {
877      tag,
878      title: s.ref ?? '',
879      doing: s.sub ?? s.headline,
880      thought: s.subItalic,
881      phase: s.phase === 'reading' ? 'reading' : s.phase === 'sending' ? 'sending' : 'thinking',
882      time: s.time,
883      elapsed: s.seconds ?? 0,
884      clock: s.clock,
885      strip: s.strip,
886      you: s.you,
887      codexSprite: s.codexSprite,
888      claudeMood: s.claudeMood,
889      family: s.family,
890      turn: s.asking ? 'claude' : 'codex',
891      plan: s.plan,
892      steer: s.steer,
893      asking: s.asking,
894      permit: s.permit,
895      kind: s.kind === 'image' ? 'image' : s.kind === 'task' ? 'task' : 'review',
896      headline: s.headline,
897      files: [],
898      counts: { files: s.files, searches: s.searches, edits: s.edits },
899      codexModel: s.codexModel,
900      claudeModel: s.claudeModel,
901      growing: s.growing,
902      controls: 0,
903    },
904    W,
905  )
906}
907
908// ---------------------------------------------------------------------------
909// Motion: one policy for every scene
910
911/** A clock's digit column (see liveClock): it must keep counting, whatever the motion policy. */
912function isClock(attrs: string) {
913  return /calcMode="discrete"/.test(attrs) && /attributeName="transform"/.test(attrs) && /dur="(10|60|600|6000)s"/.test(attrs)
914}
915
916/** How much a scene moves: all of it, a burst on each change then still (the default), or nothing but clocks. */
917export type Motion = 'full' | 'calm' | 'still'
918
919/**
920 * A scene's loops under the motion policy. "calm" lets them run a few seconds
921 * after each change (every new step, note or verdict draws the scene again,
922 * so motion follows real events) and then rest; "still" stops them at once.
923 * Clocks (discrete digit columns) are never stopped, a clock that stops lies;
924 * nor are the mascots' own frames in "calm" (a sprite frozen mid-frame looks broken).
925 */
926export function moving(svg: string, motion: Motion = 'calm', seconds = 6) {
927  if (motion === 'full') return svg
928  if (motion === 'still') {
929    return svg.replace(/<(animate|animateTransform|animateMotion|set)\b([^>]*?)(\/?)>/g, (all, tag: string, attrs: string, close: string) => {
930      if (isClock(attrs)) return all
931      const rest = attrs.replace(/\s(begin|repeatCount|repeatDur|fill)="[^"]*"/g, '')
932      // Landed just short of its end (as continueAnimations does): an image that only ever saw an ended
933      // animation never shows its frozen end state in Chromium, and the words it reveals stay invisible.
934      const dur = Number(attrs.match(/\sdur="(\d*\.?\d+)s"/)?.[1] ?? Number.NaN)
935      const begin = Number.isFinite(dur) && dur > 0 ? -Math.max(0, dur - 0.02) : 0
936      return `<${tag}${rest} begin="${begin.toFixed(2)}s" repeatCount="1" fill="freeze"${close}>`
937    })
938  }
939  return svg.replace(/<(animate|animateTransform|animateMotion)\b([^>]*?)\srepeatCount="indefinite"([^>]*?)(\/?)>/g, (all, tag: string, a: string, b: string, close: string) => {
940    const attrs = a + b
941    if (isClock(attrs)) return all
942    // A mascot's own frames keep going: one frozen mid-step looks broken.
943    if (/calcMode="discrete"/.test(attrs)) return all
944    const dur = Number(attrs.match(/\sdur="(\d*\.?\d+)s"/)?.[1] ?? Number.NaN)
945    if (!Number.isFinite(dur) || dur <= 0) return all
946    const begin = Math.max(0, Number(attrs.match(/\sbegin="(-?\d*\.?\d+)s"/)?.[1] ?? 0))
947    const count = Math.max(1, Math.ceil((seconds - begin) / dur))
948    return `<${tag}${a} repeatCount="${count}"${/\sfill="/.test(attrs) ? '' : ' fill="freeze"'}${b}${close}>`
949  })
950}
951
952// ---------------------------------------------------------------------------
953// Status chips for list rows: tinted, ringed, the glyph in the status colour
954
955export type RowChip = 'live' | 'approve' | 'approve_with_nits' | 'changes_needed' | 'rethink' | 'answer' | 'failed' | 'applied' | 'picture' | 'task' | 'task_live' | 'task_applied' | 'task_failed'
956
957export function rowChipSvg(kind: RowChip, size = 22) {
958  const c =
959    kind === 'live'
960      ? D.codex
961      : kind === 'task' || kind === 'task_live'
962        ? '#2dd4bf'
963        : kind === 'failed' || kind === 'task_failed' || kind === 'rethink'
964          ? D.bad
965          : kind === 'applied' || kind === 'task_applied' || kind === 'approve' || kind === 'approve_with_nits'
966            ? D.good
967            : kind === 'picture'
968              ? D.claude
969              : kind === 'changes_needed'
970                ? D.warn
971                : D.codex
972  const spin = `<circle cx="11" cy="11" r="6" fill="none" stroke="${c}" stroke-width="1.8" stroke-dasharray="9 29" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" from="0 11 11" to="360 11 11" dur="1.2s" repeatCount="indefinite"/></circle>`
973  const tick = `<path d="M7 11.4l2.8 2.8 5.4-5.8" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`
974  const cross = `<path d="M8 8l6 6M14 8l-6 6" stroke="${c}" stroke-width="2" stroke-linecap="round"/>`
975  const glyph: Record<RowChip, string> = {
976    live: spin,
977    task_live: spin,
978    approve: tick,
979    approve_with_nits: tick,
980    applied: tick,
981    task_applied: tick,
982    changes_needed: `<path d="M11 6.6v5.6" stroke="${c}" stroke-width="2.2" stroke-linecap="round"/><circle cx="11" cy="15.4" r="1.3" fill="${c}"/>`,
983    rethink: `<path d="M15 9.4a4.4 4.4 0 1 0 .4 3.8" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round"/><path d="M15.4 6.2v3.4H12" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`,
984    answer: `<path d="M8.8 9a2.3 2.3 0 1 1 3.2 2.1c-.6.3-.9.7-.9 1.3v.5" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round"/><circle cx="11" cy="15.6" r="1.2" fill="${c}"/>`,
985    failed: cross,
986    task_failed: cross,
987    task: `<path d="M8.8 7.8L6 11l2.8 3.2M13.2 7.8L16 11l-2.8 3.2" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`,
988    picture: `<rect x="6" y="7" width="10" height="8" rx="1.6" fill="none" stroke="${c}" stroke-width="1.5"/><path d="M6.6 14.2l2.8-2.8 1.8 1.6 1.5-1.3 2.5 2.3" fill="none" stroke="${c}" stroke-width="1.4" stroke-linejoin="round"/>`,
989  }
990  const ground = kind.startsWith('task')
991    ? `<rect x="1" y="1" width="20" height="20" rx="6.5" fill="${c}" fill-opacity=".16" stroke="${c}" stroke-opacity=".55"/>`
992    : `<circle cx="11" cy="11" r="10" fill="${c}" fill-opacity=".16" stroke="${c}" stroke-opacity=".55"/>`
993  return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 22 22">${ground}${glyph[kind]}</svg>`
994}
995
996// ---------------------------------------------------------------------------
997// A conversation's journey: who moved, round by round, on two lanes
998
999export type JourneyStep = { who: 'codex' | 'claude' | 'you' | 'done'; text: string }
1000
1001export const JOURNEY_HEIGHT = 112
1002
1003/**
1004 * Claude (and the person) on the upper lane, Codex on the lower: each move a
1005 * dot on its lane, joined in order, so the back-and-forth reads at a glance.
1006 * The newest moves get their words; older ones are dots.
1007 */
1008export function journeySvg(steps: JourneyStep[], width_: number) {
1009  const W = Math.max(280, Math.round(width_))
1010  const H = JOURNEY_HEIGHT
1011  const shown = steps.slice(-Math.max(3, Math.min(10, Math.floor((W - 40) / 40))))
1012  const earlier = steps.length - shown.length
1013  const x0 = earlier ? 44 : 20
1014  const x1 = W - 20
1015  const up = 42
1016  const down = 74
1017  const gap = shown.length > 1 ? (x1 - x0) / (shown.length - 1) : 0
1018  const colour = (w: JourneyStep['who']) => (w === 'codex' ? D.codex : w === 'you' ? D.you : w === 'done' ? D.good : D.claude)
1019  const yOf = (w: JourneyStep['who']) => (w === 'codex' ? down : w === 'done' ? (up + down) / 2 : up)
1020  const pts = shown.map((s, i) => ({ x: x0 + i * gap, y: yOf(s.who), s }))
1021  let out = `<rect x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="14" fill="${D.ground}" stroke="#fff" stroke-opacity=".08"/>`
1022  out += `<path d="M${x0 - 10} ${up}H${x1 + 10}" stroke="${D.claude}" stroke-opacity=".22" stroke-dasharray="2 4"/><path d="M${x0 - 10} ${down}H${x1 + 10}" stroke="${D.codex}" stroke-opacity=".22" stroke-dasharray="2 4"/>`
1023  if (earlier) out += `<text x="12" y="${(up + down) / 2 + 4}" font-size="10" font-weight="700" fill="#fff" fill-opacity=".45">+${earlier}</text>`
1024  for (let i = 1; i < pts.length; i++) {
1025    const a = pts[i - 1] as (typeof pts)[number]
1026    const b = pts[i] as (typeof pts)[number]
1027    out += `<path d="M${n1(a.x)} ${a.y}C${n1(a.x + gap / 2)} ${a.y} ${n1(b.x - gap / 2)} ${b.y} ${n1(b.x)} ${b.y}" fill="none" stroke="${colour(b.s.who)}" stroke-opacity=".6" stroke-width="1.6"/>`
1028  }
1029  // Words on two levels a lane, newest first, each only where it does not touch a neighbour's.
1030  const taken: Record<string, [number, number][]> = {}
1031  const order = pts.map((p, i) => ({ p, i })).reverse()
1032  const words: string[] = []
1033  for (const { p, i } of order) {
1034    const c = colour(p.s.who)
1035    const last = i === pts.length - 1
1036    const label = fit(p.s.text, 22)
1037    const w = label.length * 5.7 + 6
1038    const anchor = i === 0 ? 'start' : last ? 'end' : 'middle'
1039    const left = anchor === 'start' ? p.x : anchor === 'end' ? p.x - w : p.x - w / 2
1040    const above = p.s.who !== 'codex'
1041    for (const level of [0, 1]) {
1042      const key = `${above ? 'u' : 'd'}${level}`
1043      const spans = (taken[key] ??= [])
1044      if (spans.some(([a, b]) => left < b + 6 && left + w > a - 6)) continue
1045      spans.push([left, left + w])
1046      const ty = above ? p.y - 11 - level * 13 : p.y + 19 + level * 13
1047      words.push(`<text x="${n1(p.x)}" y="${ty}" text-anchor="${anchor}" font-size="10" font-weight="${last ? 700 : 500}" fill="${c}" fill-opacity="${last ? 1 : 0.85 - level * 0.15}">${escape(label)}</text>`)
1048      break
1049    }
1050  }
1051  pts.forEach((p, i) => {
1052    const c = colour(p.s.who)
1053    const last = i === pts.length - 1
1054    out += `<circle cx="${n1(p.x)}" cy="${p.y}" r="${last ? 5.5 : 4}" fill="${D.ground}" stroke="${c}" stroke-width="2"/>`
1055    if (last) out += `<circle cx="${n1(p.x)}" cy="${p.y}" r="2.4" fill="${c}"/>`
1056  })
1057  out += words.join('')
1058  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${SANS}">${out}</svg>`
1059}
1060
1061// ---------------------------------------------------------------------------
1062// A job worked on together: its parts as chips on the stage's floor
1063
1064export type JobStrip = {
1065  title: string
1066  time: string
1067  clock?: number
1068  parts: { state: 'queued' | 'codex' | 'claude' | 'done' | 'dropped' }[]
1069  codexSprite?: Sprite
1070  claudeMood?: ClawdMood
1071}
1072
1073export const JOB_STRIP_HEIGHT = 124
1074
1075export function jobStripSvg(j: JobStrip & { controls?: number }, width_: number) {
1076  const W = Math.round(Math.max(j.controls === 0 ? 280 : 480, width_))
1077  const H = JOB_STRIP_HEIGHT
1078  const controls = j.controls ?? BAND_CONTROLS
1079  const DW = W - controls
1080  const n = (s: JobStrip['parts'][number]['state']) => j.parts.filter(p => p.state === s).length
1081  const withCodex = n('codex')
1082  const withClaude = n('claude')
1083  const actor: Actor = withCodex && withClaude ? 'both' : withClaude ? 'claude' : 'codex'
1084  const colour = actorColour(actor)
1085  const id = idOf('job', W, j.title)
1086  const g = ground(id, W, H, colour, { controls, from: actor === 'codex' ? 'right' : 'left' })
1087  const x = 24
1088  const right = DW - 22
1089  const live = withCodex + withClaude > 0
1090  const line1 = kicker(x, 30, fitKicker(`Together · ${n('done')} of ${j.parts.length} done`, right - x - 130), colour) + clockCorner(right, 30, { clock: j.clock, time: j.time, live, id })
1091  const line2 = headline(x, 58, right - x, j.title, { size: 17 })
1092  const line3 = facts(x, 80, right - x, [
1093    withCodex ? [`${withCodex} with Codex`, D.codex] : '',
1094    withClaude ? [`${withClaude} with Claude`, D.claude] : '',
1095    n('queued') ? `${n('queued')} waiting` : '',
1096    n('dropped') ? [`${n('dropped')} dropped`, D.bad] : '',
1097  ])
1098  const floor = H - 14
1099  const beads: Bead[] = j.parts.map((p, i) =>
1100    p.state === 'done' ? { kind: 'answer', label: `${i + 1}` } : p.state === 'claude' ? { kind: 'claude', label: `part ${i + 1}` } : p.state === 'dropped' ? { kind: 'finding', label: `${i + 1}`, severity: 'high' } : { kind: 'Edit', label: `part ${i + 1}` },
1101  )
1102  const scene = stage({ actor, codexSprite: j.codexSprite, claudeMood: j.claudeMood ?? (withClaude ? 'busy' : 'watch'), beads, live }, 14, DW - 14, floor, id)
1103  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${SANS}">
1104<defs>${g.defs}</defs>
1105${g.body}
1106<g clip-path="url(#${id}-card)">${scene}</g>
1107${line1}
1108${line2}
1109${line3}
1110</svg>`
1111}
1112
1113// ---------------------------------------------------------------------------
1114// The band at rest: the two of them, small, on a floor
1115
1116export const MINI_STAGE = { width: 92, height: 34 }
1117
1118export function miniStageSvg(o: { state: 'idle' | 'you' | 'working' | 'sleep'; codexSprite?: Sprite }) {
1119  const { width: W, height: H } = MINI_STAGE
1120  const floor = H - 4
1121  const colour = o.state === 'you' ? D.you : o.state === 'working' ? D.codex : D.violet
1122  const spot = o.state === 'you' ? 22 : o.state === 'working' ? 70 : -100
1123  const zz =
1124    o.state === 'sleep'
1125      ? `<text x="34" y="12" font-size="8" font-weight="800" fill="#fff" fill-opacity=".6">z<animate attributeName="y" values="14;6" dur="2.4s" repeatCount="3" fill="freeze"/><animate attributeName="opacity" values="0;1;0" dur="2.4s" repeatCount="3" fill="freeze"/></text>`
1126      : ''
1127  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
1128<defs><radialGradient id="mini-pool"><stop offset="0" stop-color="${colour}" stop-opacity=".55"/><stop offset="1" stop-color="${colour}" stop-opacity="0"/></radialGradient></defs>
1129<ellipse cx="${spot}" cy="${floor}" rx="20" ry="4" fill="url(#mini-pool)"/>
1130<path d="M4 ${floor}H${W - 4}" stroke="#fff" stroke-opacity=".14"/>
1131${clawd(22, floor - 9, 1.6, o.state === 'sleep' ? 'sleep' : o.state === 'working' ? 'watch' : 'idle')}
1132${o.codexSprite ? codexSprite(o.codexSprite, 70, floor - 15, 30) : codexAvatar(70, floor - 12, 10)}
1133${zz}
1134</svg>`
1135}
1136
1137// ---------------------------------------------------------------------------
1138// Usage: one meter a window, as a card
1139
1140export type UsageWindow = { name: string; percent: number; resets?: string }
1141
1142export function usageCardSvg(o: { windows: UsageWindow[]; plan?: string; today?: string; reading?: string }, width_: number) {
1143  const W = Math.max(280, Math.round(width_))
1144  const row = 34
1145  const head = 34
1146  const H = head + Math.max(1, o.windows.length) * row + (o.today ? 22 : 10)
1147  const id = idOf('usage', W, o.windows.map(w => w.percent).join(','))
1148  const x0 = 18
1149  const x1 = W - 18
1150  let out = `<rect x=".5" y=".5" width="${W - 1}" height="${H - 1}" rx="14" fill="${D.ground}" stroke="#fff" stroke-opacity=".08"/>`
1151  out += kicker(x0, 24, `Codex usage${o.plan ? ` · ${o.plan}` : ''}`, D.violet)
1152  if (o.reading) out += `<text x="${x1}" y="24" text-anchor="end" font-size="10.5" fill="#fff" fill-opacity=".45">${escape(o.reading)}</text>`
1153  if (!o.windows.length) out += `<text x="${x0}" y="${head + 16}" font-size="12" fill="#fff" fill-opacity=".55">No reading yet: it comes with the next Codex run.</text>`
1154  o.windows.forEach((w, i) => {
1155    const y = head + i * row
1156    const p = Math.max(0, Math.min(100, w.percent))
1157    const c = p >= 90 ? D.bad : p >= 75 ? D.warn : D.good
1158    const barX = x0 + 74
1159    const barW = Math.max(60, x1 - barX - 56)
1160    const pct = p < 10 ? p.toFixed(1).replace(/\.0$/, '') : String(Math.round(p))
1161    out += `<text x="${x0}" y="${y + 12}" font-size="11" font-weight="700" fill="#fff" fill-opacity=".8">${escape(w.name)}</text>`
1162    if (w.resets) out += `<text x="${x0}" y="${y + 25}" font-size="9.5" fill="#fff" fill-opacity=".42">${escape(w.resets)}</text>`
1163    out += `<rect x="${barX}" y="${y + 5}" width="${barW}" height="8" rx="4" fill="#fff" fill-opacity=".08"/>`
1164    out += `<linearGradient id="${id}-g${i}" x1="0" x2="1"><stop offset="0" stop-color="${c}" stop-opacity=".55"/><stop offset="1" stop-color="${c}"/></linearGradient>`
1165    out += `<rect x="${barX}" y="${y + 5}" width="${n1(Math.max(4, (barW * p) / 100))}" height="8" rx="4" fill="url(#${id}-g${i})"><animate attributeName="width" from="0" to="${n1(Math.max(4, (barW * p) / 100))}" dur=".8s" fill="freeze" ${EASE}/></rect>`
1166    out += `<text x="${x1}" y="${y + 13}" text-anchor="end" font-size="13" font-weight="750" fill="${c}">${pct}%</text>`
1167  })
1168  if (o.today) out += `<text x="${x0}" y="${H - 8}" font-size="10.5" fill="#fff" fill-opacity=".5">${escape(o.today)}</text>`
1169  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${SANS}">${out}</svg>`
1170}
1171
hooks/tiles.tsx 84 lines
1import type { ClientModule } from 'claude-code'
2
3// The pane's sections as tiles, drawn on the surface so a whole card answers
4// the pointer (a Button draws its children on one line), lights up under it,
5// and takes the arrow keys and 1-4 once clicked. After Branchline's.
6
7export type TileSpec = { id: string; label: string; value: string; sub: string; c: string; sel: boolean; alert?: boolean }
8export type TilesProps = { tiles: TileSpec[]; card: string; line: string }
9type Local = { hover: number; ref: { unpoint?: () => void; unkey?: () => void } }
10
11const Tiles: ClientModule<TilesProps, Local> = (props, surface) => {
12  const { Box, Text } = surface.elements
13  let state = surface.state
14  if (state === undefined) {
15    state = { hover: -1, ref: {} }
16    surface.setState(state)
17  }
18  const count = Math.max(1, props.tiles.length)
19  const cols = surface.columns
20  const perRow = cols <= 0 || cols >= 64 ? count : 2
21  const rowsOfTiles = Math.ceil(count / perRow)
22  state.ref.unpoint = surface.onPointer(e => {
23    const cur = surface.state ?? state
24    const w = Math.max(1, surface.columns / perRow)
25    const h = Math.max(1, surface.rows / rowsOfTiles)
26    const inside = e.x >= 0 && e.y >= 0 && e.x < surface.columns && e.y < surface.rows
27    const at = inside ? Math.min(count - 1, Math.floor(e.y / h) * perRow + Math.min(perRow - 1, Math.floor(e.x / w))) : -1
28    if (e.type === 'leave' || !inside) {
29      if (cur.hover !== -1) surface.setState({ ...cur, hover: -1 })
30      return
31    }
32    if (e.type === 'move' && cur.hover !== at) surface.setState({ ...cur, hover: at })
33    if (e.type === 'down' && (e.button ?? 'left') === 'left') {
34      const t = props.tiles[at]
35      if (t) surface.post({ tab: t.id })
36    }
37  })
38  state.ref.unkey = surface.onKey(e => {
39    const sel = Math.max(0, props.tiles.findIndex(t => t.sel))
40    const digit = Number(e.key)
41    const to = e.key === 'left' || e.key === 'h' ? sel - 1 : e.key === 'right' || e.key === 'l' ? sel + 1 : digit >= 1 && digit <= count ? digit - 1 : -1
42    const t = props.tiles[(to + count) % count]
43    if (to !== -1 && t) surface.post({ tab: t.id })
44  })
45  const rows: TileSpec[][] = []
46  for (let i = 0; i < props.tiles.length; i += perRow) rows.push(props.tiles.slice(i, i + perRow))
47  return (
48    <Box flexDirection="column" gap={1}>
49      {rows.map((row, r) => (
50        <Box key={`row${r}`} flexDirection="row" gap={1}>
51          {row.map((t, i) => {
52            const hot = state.hover === r * perRow + i
53            return (
54              <Box
55                key={t.id}
56                flexDirection="column"
57                flexGrow={1}
58                width={`${Math.floor(100 / perRow)}%`}
59                borderStyle="round"
60                borderColor={t.sel ? t.c : hot ? `${t.c}99` : t.alert ? `${t.c}66` : props.line}
61                backgroundColor={t.sel ? `${t.c}24` : hot ? '#8080801c' : props.card}
62                paddingX={2}
63                paddingY={perRow < count ? 0 : 1}
64              >
65                <Text size="caption" weight="bold" color={t.sel || hot || t.alert ? t.c : undefined} dimColor={!t.sel && !hot && !t.alert}>
66                  {t.label}
67                </Text>
68                <Text size="title" weight="bold" tabular>
69                  {t.value}
70                </Text>
71                <Text size="footnote" color={t.sel || t.alert ? t.c : undefined} dimColor={!t.sel && !t.alert} wrap="truncate-end">
72                  {t.sub}
73                </Text>
74              </Box>
75            )
76          })}
77        </Box>
78      ))}
79    </Box>
80  )
81}
82
83export default Tiles
84
hooks/parse.ts 205 lines
1// Turns what Codex runs into words a person reads at a glance: one part per
2// statement of a shell command (Read, Search, Git, List, Run), with the files
3// it opened. Codex on Windows wraps each batch in `powershell -Command '...'`.
4
5import type { CodexVerb } from '../types'
6
7export type Part = { verb: CodexVerb; text: string; file?: string }
8
9function unquote(text: string) {
10  const s = text.trim()
11  if (s.length >= 2 && (s[0] === "'" || s[0] === '"') && s[s.length - 1] === s[0]) {
12    const inner = s.slice(1, -1)
13    return s[0] === "'" ? inner.replace(/''/g, "'") : inner.replace(/\\"/g, '"').replace(/`"/g, '"')
14  }
15  return s
16}
17
18/**
19 * One shell word as POSIX quoting spells it: quoted and bare pieces glued
20 * together, as in `'a'"'"'b'` for a'b (how Codex quotes its scripts).
21 */
22function shellWord(text: string) {
23  let out = ''
24  let i = 0
25  const s = text.trim()
26  while (i < s.length) {
27    const c = s[i] as string
28    if (c === "'") {
29      const end = s.indexOf("'", i + 1)
30      if (end < 0) return out + s.slice(i + 1)
31      out += s.slice(i + 1, end)
32      i = end + 1
33    } else if (c === '"') {
34      i++
35      while (i < s.length && s[i] !== '"') {
36        if (s[i] === '\\' && (s[i + 1] === '"' || s[i + 1] === '\\')) i++
37        out += s[i]
38        i++
39      }
40      i++
41    } else if (/\s/.test(c)) {
42      return out
43    } else {
44      out += c
45      i++
46    }
47  }
48  return out
49}
50
51/** The script inside a `powershell -Command` or `bash -lc` wrapper. */
52export function unwrap(command: string) {
53  let s = command.trim()
54  const ps = s.match(/^(?:"[^"]*powershell(?:\.exe)?"|\S*powershell(?:\.exe)?|\S*pwsh(?:\.exe)?)\s+(?:-[A-Za-z]+\s+)*?-Command\s+([\s\S]+)$/i)
55  if (ps?.[1]) s = shellWord(ps[1])
56  const sh = s.match(/^(?:"[^"]*(?:bash|sh)(?:\.exe)?"|\S*\b(?:bash|sh)(?:\.exe)?)\s+-l?c\s+([\s\S]+)$/i)
57  if (sh?.[1]) s = shellWord(sh[1])
58  return s
59}
60
61/** Splits on separators outside quotes: `;`, newlines, `&&`, `||` (or `|` for pipes). */
62function split(script: string, pipes: boolean) {
63  const out: string[] = []
64  let quote = ''
65  let current = ''
66  for (let i = 0; i < script.length; i++) {
67    const c = script[i] as string
68    if (quote) {
69      current += c
70      if (c === quote) quote = ''
71      continue
72    }
73    if (c === "'" || c === '"') {
74      quote = c
75      current += c
76      continue
77    }
78    const two = script.slice(i, i + 2)
79    const isBreak = pipes
80      ? c === '|' && two !== '||'
81      : c === ';' || c === '\n' || c === '\r' || two === '&&' || two === '||'
82    if (isBreak) {
83      if (current.trim()) out.push(current.trim())
84      current = ''
85      if (two === '&&' || two === '||') i++
86      continue
87    }
88    current += c
89  }
90  if (current.trim()) out.push(current.trim())
91  return out
92}
93
94function tokens(segment: string) {
95  const out: string[] = []
96  const re = /'(?:[^']|'')*'|"(?:[^"\\]|\\.)*"|\S+/g
97  for (const m of segment.matchAll(re)) out.push(unquote(m[0]))
98  return out
99}
100
101const READERS = new Set(['get-content', 'gc', 'cat', 'type', 'sed', 'head', 'tail', 'more', 'less', 'bat', 'nl'])
102const LISTERS = new Set(['get-childitem', 'gci', 'ls', 'dir', 'tree', 'fd', 'find', 'resolve-path', 'test-path'])
103const SEARCHERS = new Set(['rg', 'grep', 'select-string', 'sls', 'findstr', 'ag'])
104
105/** The first argument that is not a flag (or the value of one of `named`). */
106function argument(args: string[], named: string[] = []) {
107  for (let i = 0; i < args.length; i++) {
108    const a = args[i] as string
109    if (named.includes(a.toLowerCase())) return args[i + 1]
110  }
111  for (let i = 0; i < args.length; i++) {
112    const a = args[i] as string
113    if (a.startsWith('-')) {
114      // Flags that take a value.
115      if (/^-(?:m|A|B|C|g|t|e|First|Last|Head|Tail|TotalCount|Encoding|Filter|Include|Exclude|Depth|Context)$/i.test(a)) i++
116      continue
117    }
118    if (/^\d/.test(a) && !/[\\/.]/.test(a)) continue
119    return a
120  }
121  return undefined
122}
123
124function valueOf(args: string[], name: string) {
125  const at = args.findIndex(a => a.toLowerCase() === name)
126  return at >= 0 ? args[at + 1] : undefined
127}
128
129export function tidyPath(path: string, cwd: string) {
130  let p = path.replace(/\\/g, '/').replace(/^\.\//, '')
131  const root = cwd.replace(/\\/g, '/').replace(/\/$/, '')
132  if (root && p.toLowerCase().startsWith(root.toLowerCase() + '/')) p = p.slice(root.length + 1)
133  return p
134}
135
136function short(text: string, max = 70) {
137  const t = text.replace(/\s+/g, ' ').trim()
138  return t.length > max ? `${t.slice(0, max - 1)}…` : t
139}
140
141function classify(statement: string, cwd: string): Part | undefined {
142  const s = statement.replace(/^\$[\w:]+\s*=\s*/, '').trim()
143  if (!s || /^\$[\w:]+(\[[^\]]*\])?$/.test(s)) return undefined
144  const segments = split(s, true)
145  let listPath: string | undefined
146
147  for (const segment of segments) {
148    const t = tokens(segment)
149    const cmd = (t[0] ?? '').toLowerCase().replace(/\.exe$/, '')
150    const args = t.slice(1)
151    if (cmd === 'git' && (args[0] ?? '').toLowerCase() === 'grep') {
152      const pattern = argument(args.slice(1), ['-e'])
153      return { verb: 'Search', text: pattern ? `“${short(pattern, 48)}”` : 'with git grep' }
154    }
155    if (SEARCHERS.has(cmd)) {
156      if (cmd === 'rg' && args.includes('--files')) {
157        const where = argument(args.filter(a => a !== '--files'))
158        return { verb: 'List', text: where ? tidyPath(where, cwd) : 'the files' }
159      }
160      const pattern = argument(args, ['-pattern', '-e', '--regexp'])
161      const rest = pattern ? args.slice(args.indexOf(pattern) + 1) : args
162      const named = valueOf(args, '-path')
163      const where = cmd === 'select-string' || cmd === 'sls' ? (named ?? listPath) : argument(rest, ['-path'])
164      const what = pattern ? `“${short(pattern, 48)}”` : 'the code'
165      return { verb: 'Search', text: where ? `${what} in ${short(tidyPath(where, cwd), 44)}` : what }
166    }
167    if (LISTERS.has(cmd)) listPath = argument(args, ['-path', '-literalpath']) ?? '.'
168    if (READERS.has(cmd)) listPath = argument(args, ['-path', '-literalpath'])
169  }
170
171  const t = tokens(segments[0] ?? '')
172  const cmd = (t[0] ?? '').toLowerCase().replace(/\.exe$/, '')
173  const args = t.slice(1)
174  if (READERS.has(cmd)) {
175    const file = cmd === 'sed' ? args.filter(a => !a.startsWith('-') && !/^\d/.test(a) && !/[pd]$/.test(a)).pop() : argument(args, ['-path', '-literalpath'])
176    if (!file) return { verb: 'Read', text: 'a file' }
177    const tidy = tidyPath(file, cwd)
178    return { verb: 'Read', text: tidy, file: tidy }
179  }
180  if (cmd === 'git') {
181    const sub = args.filter(a => !/^-C$/.test(a))
182    return { verb: 'Git', text: short(sub.join(' '), 60) || 'status' }
183  }
184  if (LISTERS.has(cmd)) return { verb: 'List', text: tidyPath(listPath ?? '.', cwd) }
185  if (/^(select-object|sort-object|measure-object|foreach-object|where-object|format-\w+|out-\w+|write-\w+|echo)$/.test(cmd)) return undefined
186  return { verb: 'Run', text: short(segments[0] ?? s, 60) }
187}
188
189/** Every part of a command Codex ran, in order. */
190export function describe(command: string, cwd: string): Part[] {
191  const script = unwrap(command)
192  const parts: Part[] = []
193  for (const statement of split(script, false)) {
194    const part = classify(statement, cwd)
195    if (part) parts.push(part)
196  }
197  return parts.length ? parts : [{ verb: 'Run', text: short(script, 60) }]
198}
199
200/** The first line of Codex's commentary, as one sentence. */
201export function headline(text: string) {
202  const line = text.replace(/\*\*/g, '').trim().split(/\r?\n/).find(l => l.trim()) ?? ''
203  return line.trim()
204}
205
types/index.d.ts 470 lines
1// ---------------------------------------------------------------------------
2// Conversations and tasks: one round of talking to Codex each
3
4export type CodexKind = 'review' | 'plan' | 'question' | 'debug' | 'joint' | 'compare' | 'visual' | 'reply' | 'recheck' | 'task'
5
6export type CodexStatus = 'queued' | 'sending' | 'thinking' | 'reading' | 'done' | 'failed' | 'stopped'
7
8export type CodexVerdict = 'approve' | 'approve_with_nits' | 'changes_needed' | 'rethink' | 'answer'
9
10export type CodexVerb = 'Read' | 'Search' | 'Git' | 'List' | 'Run' | 'Edit' | 'Note' | 'Think' | 'Plan'
11
12export type CodexLens = 'general' | 'correctness' | 'security' | 'performance' | 'tests' | 'simplicity' | 'ux'
13
14/** One thing Codex did or said while it worked. */
15export type CodexStep = {
16  /** The Codex item it came from, so a finished command updates its row. */
17  item: string
18  verb: CodexVerb
19  text: string
20  at: number
21  done: boolean
22  ms?: number
23}
24
25export type CodexStance = 'agree' | 'disagree' | 'unsure'
26
27export type CodexFinding = {
28  /** F1, F2, ... numbered across the whole thread. */
29  id: string
30  severity: 'high' | 'medium' | 'low'
31  title: string
32  file?: string
33  line?: number
34  detail: string
35  suggestion?: string
36  stance?: CodexStance
37  note?: string
38  state: 'open' | 'fixed' | 'dismissed'
39  /** When a fix for it was applied (by the person, whole): it stays open until checked, since a fix may leave a finding it disputed unchanged. */
40  applied?: number
41  replies?: { round: number; position: 'concede' | 'hold' | 'refine'; text: string }[]
42  /** On Claude's own findings in a joint review: what Codex made of it when it compared notes. */
43  match?: { position: 'agree' | 'disagree' | 'refine'; same?: string; text: string }
44}
45
46export type CodexResponse = { finding: string; position: 'concede' | 'hold' | 'refine'; text: string }
47
48/** What a task changed, in its own git worktree. */
49export type CodexTaskWork = {
50  worktree: string
51  /** The commit the worktree started from: HEAD plus the user's uncommitted changes. */
52  base: string
53  /** `revised`: sent back with notes; a later round of the same task holds the current version. `undone`: applied, then taken out again. */
54  state: 'working' | 'ready' | 'empty' | 'applied' | 'discarded' | 'conflict' | 'revised' | 'undone'
55  patch?: string
56  /** Each changed file, with the start of its own diff while there is room for it. */
57  files?: { path: string; added: number; removed: number; diff?: string }[]
58  /** Files left out when a version was applied in part. */
59  skip?: string[]
60  /** Files already taken into the working tree from an earlier version (a part applied, the rest sent back). */
61  applied?: string[]
62  /** The exact patch this round put into the working tree, so it can be taken out again. */
63  appliedPatch?: string
64  /** Dependency folders linked in from the main checkout (node_modules), so Codex can run the project's checks. */
65  linked?: string[]
66  /** The checks this project defines (package.json scripts, cargo, go, pytest), handed to Codex as context. */
67  checks?: string[]
68  /** Who applied it: Claude (its job) or the person, stepping in. */
69  appliedBy?: 'claude' | 'user'
70  added?: number
71  removed?: number
72  /** The start of the diff, for the card and the pane. */
73  preview?: string
74  changes?: { file: string; what: string }[]
75  tests?: string
76  notes?: string
77  /** Why applying failed, when it did. */
78  problem?: string
79  /** The worktree was cleaned up; the patch file stays and can still be applied. */
80  gone?: boolean
81}
82
83/** A finding a task fixes. */
84export type CodexFix = { thread: string; finding: string; title: string }
85
86/** A "work on it together" job: Claude leads, and each part it hands Codex (or has Codex review) is a thread of the job. */
87export type CodexJob = { id: string; title: string }
88
89/**
90 * A note handed to Codex while it works (Codex's app server steers the running
91 * turn with it). `queued`: written down before Codex had started, handed over
92 * once it has. `sending`: in Codex's inbox. `sent`: Codex has it. `failed`:
93 * Codex finished, or refused it, first.
94 */
95export type CodexSteer = {
96  id: string
97  from: 'user' | 'claude'
98  /** As the person or Claude wrote it; Codex gets it with a line saying who it is from. */
99  text: string
100  at: number
101  state: 'queued' | 'sending' | 'sent' | 'failed'
102  error?: string
103}
104
105/**
106 * A question Codex put to Claude mid-run (its ask_claude tool), waiting for
107 * the answer: Claude's, or the person's from a card or the pane. Codex waits
108 * a while, then carries on with its own judgement (`timeout`). `closed`: the
109 * run ended (stopped, failed) while it was still open.
110 */
111/**
112 * Something for Codex's next round, queued while it works ("next" rather
113 * than "now"), or Claude's answer to a question Codex did not wait on that
114 * arrived after its turn ended. When the round ends, the queued ones start
115 * the next round together.
116 */
117export type CodexFollowUp = {
118  id: string
119  from: 'claude' | 'user'
120  text: string
121  at: number
122  state: 'queued' | 'started' | 'dropped' | 'failed'
123  /** The question it answers, when it is an answer. */
124  question?: string
125  /** The round it started. */
126  round?: string
127  error?: string
128  /** The ticket (question id) whose answer this carries: one follow-up a ticket, however its answer got here. */
129  ticket?: string
130}
131
132export type CodexAsk = {
133  id: string
134  question: string
135  at: number
136  state: 'waiting' | 'answered' | 'timeout' | 'closed'
137  answer?: string
138  by?: 'user' | 'claude'
139  answeredAt?: number
140  /** Asked without waiting (a ticket): Codex carried on, and the answer reaches it as a note or in its next round. */
141  later?: boolean
142}
143
144/**
145 * A permission one of Codex's own tools asked the person for mid-run (its
146 * browser wanting to open a site, say). Only the person answers it, on the
147 * card, the band or the pane; unanswered, it is declined after a while.
148 * `refused`: the bridge turned it down by itself (a device check only the
149 * Codex app can do, or writing outside what the run may write). `closed`: the
150 * run ended while it was still open.
151 */
152export type CodexPermit = {
153  id: string
154  kind: 'form' | 'link' | 'access' | 'verify'
155  /** The tool asking (Browser use) and the site it is about. */
156  tool?: string
157  origin?: string
158  title: string
159  message?: string
160  url?: string
161  /** A form's own choices, when it has any. */
162  options?: string[]
163  /** How long an allow may be kept: for this conversation, or from now on. */
164  persist?: ('session' | 'always')[]
165  at: number
166  state: 'waiting' | 'sending' | 'allowed' | 'denied' | 'timeout' | 'refused' | 'closed'
167  /** Why the bridge turned it down by itself. */
168  refused?: string
169  /** What the person chose. */
170  kept?: 'session' | 'always'
171  choice?: string
172  settledAt?: number
173}
174
175/** Something Codex told Claude mid-run without waiting (its tell_claude tool): news Claude should have now, not in the final answer. */
176export type CodexHeard = {
177  id: string
178  text: string
179  at: number
180}
181
182/** A build loop the person started: one builds, the other reviews, until both agree the work is done. */
183export type CodexLoop = {
184  /** Codex builds in its worktree and Claude reviews each version, or Claude builds in the working tree and Codex reviews it. */
185  builder: 'codex' | 'claude'
186}
187
188export type CodexConsult = {
189  /** The thread's id for its first round (K7Q2), then K7Q2-2 ... */
190  id: string
191  thread: string
192  round: number
193  kind: CodexKind
194  from: 'claude' | 'user'
195  toolUseId?: string
196  title: string
197  request: string
198  context?: string
199  plan?: string
200  lens?: CodexLens
201  scope?: string
202  diffstat?: string
203  model: string
204  effort: string
205  codexThread?: string
206  status: CodexStatus
207  background: boolean
208  createdAt: number
209  startedAt?: number
210  finishedAt?: number
211  steps: CodexStep[]
212  files: string[]
213  note?: string
214  /** Codex's own plan for the turn, as it keeps it: each step done, under way (`now`) or still to come. */
215  plan_items?: { text: string; done: boolean; now?: boolean }[]
216  /** Notes handed to Codex while it worked, from the person or Claude. */
217  steers?: CodexSteer[]
218  /** Questions Codex asked Claude while it worked. */
219  asks?: CodexAsk[]
220  /** For the next round: queued while this one runs, started when it ends. */
221  followups?: CodexFollowUp[]
222  /** What Codex told Claude while it worked. */
223  heard?: CodexHeard[]
224  /** Permissions Codex's tools asked the person for while it worked. */
225  permits?: CodexPermit[]
226  /** A task: files it changed that Claude has changed in this chat too (they clash when it is applied). */
227  clashes?: string[]
228  /** A task's diff while Codex works on it: each file, with lines added and removed so far. */
229  liveDiff?: { path: string; added: number; removed: number }[]
230  /** What Codex is writing to Claude right now (its commentary), as it writes it. */
231  live?: { text: string; at: number }
232  /** How much of its final answer Codex has written so far (an answer under a schema is not shown half-made). */
233  writing?: number
234  /** The command Codex is running (or last ran), and the last lines it printed. */
235  output?: { command: string; tail: string; exit?: number | null; at: number }
236  /** Warnings Codex raised during the run: config and deprecation notices, guardian warnings. */
237  notices?: { kind: string; text: string; at: number }[]
238  /** Codex moved the turn to another model. */
239  rerouted?: { from: string; to: string; reason: string; at: number }
240  /** What the run actually ran with: model, service tier, and the instruction files Codex loaded. */
241  ran?: { model?: string; tier?: string; instructions?: string[] }
242  tokens?: { input: number; cached: number; output: number; reasoning: number }
243  verdict?: CodexVerdict
244  summary?: string
245  answer?: string
246  findings: CodexFinding[]
247  responses: CodexResponse[]
248  take?: string
249  task?: CodexTaskWork
250  /** Where the reviewed plan lives, when it is a file. */
251  planFile?: string
252  /** The whole text sent to Codex: what a retry or a queued start sends. */
253  sent?: string
254  /** A joint review: Claude's own findings (C1…), made without seeing Codex's. */
255  mine?: CodexFinding[]
256  /** A joint review: the solution Claude proposed on its own. */
257  myTake?: string
258  /** A joint review: Codex's findings stay hidden from Claude until Claude's are in. */
259  sealed?: boolean
260  /** A joint review: the solution Claude and Codex agreed on. */
261  agreed?: string
262  /** Kept at the top of the list, and never trimmed from the history. */
263  pinned?: boolean
264  /** A task that fixes findings of other conversations (one object in data saved before batches). */
265  fixes?: CodexFix[]
266  /** A "work on it together" job this thread is part of. */
267  job?: CodexJob
268  /** On a thread's first round: the build loop it is part of. */
269  loop?: CodexLoop
270  /** The chat (Claude Code session) this round was started in; absent on rounds from before chats were told apart. */
271  chat?: string
272  /** The project (working directory) it ran in: its tasks apply there and nowhere else. */
273  cwd?: string
274  /** Its result on its way to Claude (`pending`) or delivered; absent on rounds from before handoffs were tracked. */
275  handoff?: 'pending' | 'done'
276  /** When this version of the round was made, so the newest of two chats' versions wins. */
277  at?: number
278  /** Visual reviews: the pages to look at, and the screenshots taken of them (with small previews). */
279  targets?: { label: string; url: string }[]
280  shots?: { label: string; path: string; thumb?: string }[]
281  /** Screenshots and other pictures Codex looks at. */
282  images?: string[]
283  /** Output that shows the problem (a failing command, a stack trace). */
284  evidence?: string
285  /** When Codex last did or said anything, and whether it has gone quiet without answering a check-in. */
286  lastEventAt?: number
287  stalled?: boolean
288  /** Stopped, but its run (a worktree still being made, a process still ending) has not let go yet: no retry anywhere, and its own chat's results stand. */
289  draining?: boolean
290  /** The last check-in on a quiet run: when, and whether Codex was still at work. */
291  checkIn?: { at: number; alive: boolean; found: boolean }
292  error?: string
293}
294
295// ---------------------------------------------------------------------------
296// Images
297
298export type CodexAspect = 'square' | 'landscape' | 'portrait' | 'wide' | 'panorama'
299
300export type CodexPreset = 'none' | 'cutout' | 'plate'
301
302export type CodexImageStatus = 'queued' | 'starting' | 'thinking' | 'rendering' | 'saving' | 'done' | 'failed' | 'interrupted'
303
304export type CodexThumb = { path: string; width: number; height: number }
305
306export type CodexImage = {
307  id: string
308  batch: string
309  toolUseId?: string
310  name: string
311  dir: string
312  path: string
313  prompt: string
314  sent: string
315  references: string[]
316  aspect: CodexAspect
317  style?: string
318  preset: CodexPreset
319  status: CodexImageStatus
320  wait: boolean
321  origin: 'claude' | 'gallery'
322  parent?: string
323  createdAt: number
324  startedAt?: number
325  finishedAt?: number
326  error?: string
327  width?: number
328  height?: number
329  bytes?: number
330  tokens?: number
331  sm?: CodexThumb
332  lg?: CodexThumb
333  xl?: CodexThumb
334  /** The person chose this one. */
335  picked?: boolean
336  /** Key out the magenta once Codex has painted it (a cut-out on its way). */
337  keyAfter?: boolean
338  /** A transparent cut-out made from another picture. */
339  cutout?: boolean
340  /** A WebP copy for the site. */
341  webp?: string
342  /** The chat it was painted for; absent on pictures from before chats were told apart. */
343  chat?: string
344  /** The prompt Codex's image tool actually used, as Codex reported it. */
345  revisedPrompt?: string
346  /** Codex reported the picture's background as transparent. */
347  transparent?: boolean
348}
349
350// ---------------------------------------------------------------------------
351// The pane and the plan
352
353export type CodexTab = 'talk' | 'tasks' | 'findings' | 'images'
354
355export type CodexImagePanel = 'none' | 'edit' | 'tweak' | 'ask' | 'sent'
356
357export type CodexView = {
358  tab: CodexTab
359  /** The conversation or task open in the pane; empty for the list. */
360  thread: string
361  showActivity: string
362  showBrief: string
363  /** The image open in detail, and the one in the viewer. */
364  image: string
365  viewing: string
366  imagePanel: CodexImagePanel
367  pick: { aspect: string; style: string }
368  draft: string
369  page: number
370  /** Findings opened in full (`thread:F2`). */
371  open: string[]
372  /** Settled cards the person opened again. */
373  expanded: string[]
374  /** Starting something from the pane: '' (nothing), 'menu' (the choices) or the choice being confirmed, as JSON. */
375  start: string
376  /** The last result the person opened or dismissed on the band (a round's id); no longer read. */
377  seen: string
378  /** Results that finished before this are not news on the band; after it, those opened or dismissed (round ids). */
379  seenAt: number
380  dismissed: string[]
381  /** The findings tab's severity filter ('' for all). */
382  filter: string
383  /** The conversation whose actions are open (the terminal), and the one being renamed. */
384  rowMenu: string
385  renaming: string
386  /** The last deleted conversation, as JSON, while Undo is offered. */
387  undo: string
388  /** What each start would cover right now, as JSON, while the menu is open. */
389  menu: string
390  /** Bumped when the mascots' art is ready, so what is drawn picks it up. */
391  art: number
392  /** The lists show this chat's alone ('', the default) or every chat's ('all'). */
393  scope: string
394  /** Findings picked in the findings tab (`thread:F2`), to fix together. */
395  picked: string[]
396  /** The lists' search text, and which of them to show ('' all, attention, running, failed, findings). */
397  query: string
398  show: string
399  /** The running round whose "Tell Codex" field takes the keyboard (opened from the band's Tell). */
400  tell?: string
401  /** Where the person's note goes: into the running turn (now), or into Codex's next round. */
402  tellWhen?: 'now' | 'next'
403}
404
405/** The person's picks in the pane: they override the settings' defaults. */
406export type CodexPrefs = {
407  model: string
408  effort: string
409  lens: CodexLens
410  aspect: CodexAspect
411  /** The pane's board folded to one line. */
412  compact?: boolean
413  /** The band above the chat as one line a job, not pictures. */
414  bandSmall?: boolean
415  /** The service tier runs ask for ("default" or one the model offers, such as "priority"). */
416  tier?: string
417  /** How much the scenes move: full, calm (a burst on each change, the default) or still. */
418  motion?: 'full' | 'calm' | 'still'
419}
420
421/** What Codex says about itself: its version, the account signed in, and the models it offers. */
422export type CodexInfo = {
423  version: string | null
424  account: { type: string; plan?: string } | null
425  models: { id: string; name: string; description: string; isDefault: boolean; efforts: string[]; defaultEffort: string | null; tiers: { id: string; name: string }[]; images: boolean }[]
426  at: number
427}
428
429
430/** A chat (Claude Code session) using the mod, as the others see it. */
431export type CodexChat = {
432  id: string
433  /** The chat's first prompt, short. */
434  title: string
435  /** The desktop app's id for it (local_…), to open it with claude://code/continue. */
436  link?: string
437  cwd?: string
438  /** When the chat last wrote its file: a running round whose chat went quiet is gone. */
439  beat: number
440}
441
442export type CodexUsage = {
443  at: string
444  plan?: string
445  windows: { minutes: number; percent: number; resetsAt?: number }[]
446  credits?: string
447  reached?: string
448}
449
450declare module 'claude-code' {
451  interface PluginState {
452    claudex: {
453      consults: CodexConsult[]
454      images: CodexImage[]
455      now: number
456      view: CodexView
457      usage: CodexUsage | null
458      prefs: CodexPrefs
459      worktrees: { count: number; bytes: number } | null
460      chats: Record<string, CodexChat>
461      /** The last poke at the mascots: who, and a count that makes each one new. */
462      poke: { who: '' | 'claude' | 'codex'; n: number }
463      /** The minute, ticking while idle: what changes with time alone (a cheer ending, Clawd dozing off) is drawn again. */
464      minute: number
465      /** Codex's version, account and models, as its app server reported them. */
466      info: CodexInfo | null
467    }
468  }
469}
470