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…

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.
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.
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">
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">
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">
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">
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">
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">
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">
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">
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.
<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.
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:
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.
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.
| Needs | Details |
|---|---|
| Claude Code | The desktop app or the terminal |
| Codex | Signed 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.js | 20 or newer |
| git | Any 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.
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 pullin~/.claude/mods/claudexand restart Claude Code. INSTALL.md covers upgrading from the older Codex mod and where Claudex keeps its files.
Open Usage & settings in the Claudex pane, or the mod's settings in Claude Code:
| Setting | Options | Default |
|---|---|---|
| Codex model | Any model your Codex offers | gpt-6.1-sol |
| Reasoning effort | low · medium · high · xhigh · max · ultra | high |
| Motion | Calm · Full · Still | Calm |
Codex runs use your own Codex quota. Claudex shows your usage, and warns you before starting work once you're above 90%.
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.
.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
MIT © Andrej Safranovic
hooks/register.tsx 11981 lines1import { 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 === 0hooks/art.ts 2273 lines1// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 = 14hooks/duet.ts 1171 lines1// 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}
1171hooks/tiles.tsx 84 lines1import 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
84hooks/parse.ts 205 lines1// 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}
205types/index.d.ts 470 lines1// ---------------------------------------------------------------------------
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