diff --git a/.changeset/story-mode.md b/.changeset/story-mode.md new file mode 100644 index 000000000..0a22f048f --- /dev/null +++ b/.changeset/story-mode.md @@ -0,0 +1,7 @@ +--- +'aicodeman': minor +--- + +Story Mode draws what Claude Code does as a comic. Turn on **Story Mode** in App Settings → Header buttons (per device, off by default) and its button opens the active Claude session as comic pages: one page per prompt, with panels for reading code, planning, calling in helpers, editing, running tests (POW! or KRAK!), asking you and saving, each with a plain caption anyone can follow. Three reading levels: **Simple** (plain words, for anyone), **Advanced** (the story with file names) and **Director's cut** (every step and tool, listed beside the page), the default for now. Everything is clickable: a file opens in the file viewer, the code page shows the diff of Claude's edits, a test board shows the output with clickable `file:line` links, and a helper shows what it was asked, what it answered and every step it took. A turn that is still running redraws as Claude works, new panels inking in where blue-pencil roughs stood. **Save image** downloads the page as a PNG to share with someone who never opens a terminal. A prompt bar under the page, with the turn picker, sends Claude your next prompt without leaving the comic. On phones, open it from the session's Session Options. The data comes from `GET /api/sessions/:id/story` and `GET /api/sessions/:id/story/helpers/:agentId`, experimental read-only endpoints. + +A prompt sent from Story Mode's prompt bar or the phone's Compose sheet (a paste, then Enter) is now checked like an API prompt: while Claude Code is still ignoring Enter right after it starts, the server presses Enter again until the prompt leaves the composer. That check also recognises a multi-line prompt that Claude Code shows as `[Pasted text #1 +3 lines]`, and a prompt under 24 characters now has to match the composer exactly. diff --git a/CLAUDE.md b/CLAUDE.md index 9472b727a..137812ea2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ## Common Gotchas -- **A prompt is one paste plus Enter** — `writeViaMux()` sends the text as one bracketed paste and Enter separately. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are kept (`\r\n` becomes `\n`): all lines go in as that ONE paste and the Enter after it submits them together, so `"echo A\necho B\r"` runs both commands in a shell (bash holds the pasted lines until the Enter; a shell without bracketed paste runs each line as it arrives). Multi-line text WITHOUT `\r` is pasted the same way; on a pane that has not enabled bracketed paste (POSIX sh, macOS `/bin/bash` 3.2) tmux delivers every line break as a CR, so every line but the last runs, where the old line-stripping typed the joined text and ran nothing. Leading and trailing line breaks are dropped. ⚠️ `SubmitVerifier` cannot reliably recognise a multi-line paste in the composer: it matches one Claude shows inline whose first line has 24+ non-space characters, but misses a short first line and the `[Pasted text #N +k lines]` placeholder, so it may not re-press a stranded Enter for one. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write +- **A prompt is one paste plus Enter** — `writeViaMux()` sends the text as one bracketed paste and Enter separately. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are kept (`\r\n` becomes `\n`): all lines go in as that ONE paste and the Enter after it submits them together, so `"echo A\necho B\r"` runs both commands in a shell (bash holds the pasted lines until the Enter; a shell without bracketed paste runs each line as it arrives). Multi-line text WITHOUT `\r` is pasted the same way; on a pane that has not enabled bracketed paste (POSIX sh, macOS `/bin/bash` 3.2) tmux delivers every line break as a CR, so every line but the last runs, where the old line-stripping typed the joined text and ran nothing. Leading and trailing line breaks are dropped. A multi-line prompt is recognised in either form Claude Code draws it: inline (the glyph line plus its indented continuation lines, so a short first line still matches) or as the `[Pasted text #N +k lines]` stand-in, which counts only when k is the prompt's own number of line breaks (`_verifySubmitted()` keeps them for that; Codex's `[Pasted Content N chars]` is not matched). ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`, and by `PastedPromptWatch` for the browser composers' two-write send, one bracketed paste then a bare Enter, on the WebSocket and the POST fallback alike) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent (a prompt under 24 characters must match whole); an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write or a newer paste replaces or cancels the schedule. Typed keystrokes plus Enter arm nothing. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write - **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks - **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`) - **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically) @@ -263,6 +263,8 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin **Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization) +**Story Mode** (opt-in header button `showStoryButton`, per-device, default OFF): a Claude session's turns drawn as comic pages, live. `GET /api/sessions/:id/story` (EXPERIMENTAL shape) reads a bounded tail of the session's OWN transcript (hook path, else `.jsonl` via `findClaudeTranscriptPath()`), never a path from the request, and never adopts or persists anything. ⚠️ Gate on `capabilities.transcript === 'claude-jsonl'` and `!remote && !docker`, never the mode id. ⚠️ `createStoryReducer()` (`src/story-mode.ts`, pure) is where size is shed: Read contents, Edit/Write `originalFile` and images never leave it, outputs keep a tail, hunks are capped; the reader caches only reduced entries. ⚠️ Interactive transcripts carry no `result` entry: a turn ends at `system/turn_duration`. Frontend rules in `src/web/CLAUDE.md` (Story Mode). + **Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (the API adds no case-name subfolder; the Clone Repo tab's custom folder is a PARENT, so the page sends `/` through `_cloneTargetPath()`, as Create New does through `_newCaseTargetPath()`) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case) **Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `/.claude/worktrees/` on `worktree-`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case) diff --git a/config/test-suites.ts b/config/test-suites.ts index 6e523cc32..c3d310472 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -23,6 +23,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/command-palette-agent-arrows.browser.test.ts', 'test/voice-input.browser.test.ts', 'test/tmux-capture-sgr.browser.test.ts', + 'test/story-mode.browser.test.ts', 'test/tab-rail-resize.browser.test.ts', 'test/tab-activation.browser.test.ts', 'test/tab-layout-editing.browser.test.ts', diff --git a/docs/api-reference.md b/docs/api-reference.md index 987409597..67aec772f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1589,6 +1589,42 @@ What the Edit history panel reads (Settings → Header & Panels → Header, per- The edits are stored per session in `~/.codeman/edit-history/.json` (mode 0600, at most 500 edits and 4 MiB of diff text, the oldest dropped), deleted when the session's record is removed (a killed pinned session keeps it; at startup, a file whose session no longer exists is deleted too) and never fed into `/api/v1/search`. No route here serves a file: the panel shows thumbnails through `file-raw` inside the workspace only; an image outside it opens in the file preview, which registers it through the attachment route. +## Story Mode (experimental) + +What Story Mode reads (the opt-in header button, App Settings → Header buttons → Story Mode, per device, off by default; on phones, Session Options → Story). It draws one turn of a Claude Code session as a comic page. **Experimental:** the payload shape may still change in a minor release; it is not part of the stable `/api/v1` contract yet. + +### Routes + +- `GET /api/sessions/:id/story[?turn=]`: one turn as a page. `data` is `{ supported: true, turns, page }`: `turns` lists up to 60 turns, oldest first (`{ id, at, prompt, origin, complete, interrupted, superseded?, toolCalls }`; `toolCalls`, here and on the page, leaves out second opinions, which are no step of the work), and `page` is the newest turn, or the one `turn=` names, or `null` when there is none (an unknown id included). `older: true` means older turns exist that `turns` leaves out: before the part of the transcript that was read, or past the 60 listed. +- `GET /api/sessions/:id/story/helpers/:agentId`: one helper (an Agent call, `agentId` from its event's `helper.agentId`) drawn from its own transcript, `/subagents/agent-.jsonl` beside the session's. `data` is `{ supported: true, helper: { agentId, type?, description?, finished? }, page }`, the same page shape, or `page: null` while the helper has written nothing yet. `agentId` must match `[A-Za-z0-9_-]{1,64}` (`400` otherwise). + +Both answer `{ supported: false, reason }` for a session they cannot draw: its CLI writes no Claude Code transcript (registry `capabilities.transcript` is not `claude-jsonl`), or it is remote or Docker, whose transcript is not on this host. Both are read-only. The session resolves like every session route (ownership via `findSessionOrFail`; another user's session is `404`), and the file read is the session's own transcript, never one the request names: the path the Claude hook reported when it really lies (symlinks resolved) in a Claude projects folder (the session's relocated `CLAUDE_CONFIG_DIR`, or the server's), else `.jsonl` under that folder. Only a regular file is read, through one non-blocking handle, so a FIFO or a device in its place fails at once. An unchanged file is a `304` decided from a stat, before anything is read. Only its tail is read (8 MB, widened in steps to 64 MB while that tail holds no prompt to start the turn); file contents, original files and images never leave the server. On the page route, a transcript that cannot be read is a `500` with `OPERATION_FAILED`; the helper route answers `page: null` for a helper transcript it cannot read (one not written yet, or gone). + +Responses carry a weak `ETag` with `Cache-Control: no-cache`; a matching `If-None-Match` gets a `304` without the page being rebuilt, which keeps a live view's frequent refetches cheap. + +### Turns: where they start and end + +A turn is one prompt and everything Claude did for it. `origin` says who sent the prompt: `human`, `task` (a background job reporting back), `peer` (another session), `scheduled` (a scheduled task's run) or `system`. + +- **Start.** A user line with a prompt starts a turn. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn. A prompt written twice under one `promptId` (a typed `/compact`, then its command record) is one turn. A slash command is a prompt event with tool `Command`. +- **End.** At `turn_duration`; at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`; at an interrupt; for a local slash command (`/compact`, `/model`), at its own output (``, kept as the prompt event's `output`). A turn still open when the next prompt starts has ended too, and is marked `superseded`. +- **Duration.** `durationMs` is `turn_duration`'s, capped at the turn's own span (Claude Code times a turn a background job started from the prompt that started the whole chain); a turn with no `turn_duration` is timed by its span. +- A compaction that lands between turns belongs to the turn after it; one mid-turn stays where it is. + +### Background work + +A helper started in the background (`run_in_background`, or as a teammate: `status: teammate_spawned`) has `helper.background` and `helper.running: true` until it reports back. A `` (matched by tool call) or a `` (matched by name) joins the answer, tool count and duration back onto the helper in the turn that started it. A report that arrives after that turn ended starts a turn of its own (origin `task`) whose prompt event carries the report as `output` and the helper as `helper`; one that lands mid-turn is an event with intent `notice` instead of a new mission. `page.helpersRunning` counts the helpers still out, and `page.finishedLater` the helpers and background commands that finished only after their turn ended (each such event carries `finishedLater: true`); the final panel then says so instead of a clean finish. `page.openNote` says Claude's last words leave something open, from its `keyPoints`: `catch` (a problem left) or `note` (something not done, checked or confirmed yet); that panel is not a celebration either. `page.asks` says they ask you something or offer more: the next move is yours. + +A Bash call sent to the background (its result is only `Command running in background with ID: …`) has `running: true` and no `ok`: it started, nothing more is known, and its `output` says so instead of carrying the launch note meant for Claude. Its `` settles `ok` from the exit code in the summary (the `output` then names the exit code, and `background: true` stays, so the page says the run happened in the background), and reading the job's output file later is a test run when that output reports test counts. + +### A page + +A `page` carries the turn's summary fields plus: + +- `events`: one per prompt, sentence Claude wrote and tool call. Each has `intent`, `tool`, `label` and, when the call returned, `ok` (`false` when the result was flagged as an error, or reported failed tests or lint problems a pipe kept from the exit code). Depending on the call: `edit` (an Edit/Write, hunks in Claude Code's own `structuredPatch` shape), `edits` (files a Bash call wrote through here-documents or `printf`/`echo` redirects, that a python or node script fed by a here-document opens for writing, or that Claude Code says it changed; the last two have no lines, since what changed is not in the command; an append's hunk has `atEnd: true` and no line numbers, since where it lands is not in the command), the tail of `output`, `tests` (`{ passed, failed, errors? }`, counted on the whole output before it is cut), `helper`, `files` (files a Bash call printed with `cat`/`head`/`tail`, resolved against the command's folder), `headline` (the part of a one-liner that decided its intent), `commits`, `subject` (the first commit's message, when the command spells it out with `-m` or a `git commit -F -` here-document), `ships` (`push` / `pr`), `running`, `background` and `finishedLater` (see Background work), `keyPoints` (for a sentence Claude wrote: up to two sentences from further into a long message that a reader must not lose, such as an open problem, something not yet done or checked, or a word on what you asked for; read on the whole message, past the `label`'s clip, and used by the final panel's balloon), `question` and, for prompts, `origin`. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript, so it is never a panel of its own: the panel of the step after it (or the last one, when nothing follows) holds its events and counts them in `secondOpinion` (an advisor call that returned an error is drawn as a second opinion that got no answer). `unaskedSave` marks the turn's first save when no prompt of the turn (nor a plan you approved) asked to commit, push or save the work: `rule` when Claude said a project rule asks for it, else `true`; the page says so in a small line, never as a fault. In plan mode the plan file under `~/.claude/plans/` is planning (intent `plan`, its numbered steps as `todos`), and `ExitPlanMode`'s result carries the plan (`output`) and your answer (`Approved` / `Sent back`). +- `panels`: at most 12, each `{ kind, events, caption: { story, director }, balloon?, files?, notes?, helpers?, todos?, tests?, ok?, rounds?, roundsOk?, question?, live? }`, with `events` as indices into `events`. A run panel's `printed` holds the first lines its last command printed (its last lines when it failed), for the terminal card: the card is drawn on the canvas and goes into a saved image, so lines that look like secrets are left out and addresses and long token-like strings are masked. `balloon.who` is `you`, `claude` or `note` (words that start a turn but are not yours: a background job's report, another session's message); a helper's report keeps its own words there (`"" reports: …`, its first sentence and key points, absolute paths shortened to their last two parts). A loop's `roundsOk` lists every test run in order, `true` where it passed and `null` for one still running, so a failure inside a round that passed in the end still shows, and a live loop shows the runs that finished. Kinds: `mission`, `investigate`, `research`, `plan`, `backup`, `fix`, `check`, `loop` (fix and check rounds folded), `run`, `save`, `ask`, `compact`, `montage` (quieter panels folded to fit) and `complete`. +- `filesChanged`, `added`, `removed` (summed per edit), `helpers` (the turn's own helper calls; a report turn's prompt carries the helper it answers, which is not counted), `helpersRunning`, `noneWorked` (every tool call of the turn failed, so the last panel is no celebration; a command whose last check only found nothing, exit code 1 after its answer, does not count as failed), `durationMs`, `partial` (the turn began before the part that was read) and `truncated` (older outputs, hunks and helper texts were dropped to keep the page small: a page carries at most 120,000 characters of them, newest first; an edit that lost its lines keeps its counts and an empty `hunks`). + ## Git worktrees of a case A local case whose folder is the top of a git repository can keep **worktrees** at `/.claude/worktrees/`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree ` uses (branch `worktree-`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 821d6ab0e..8ffd2532c 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -61,7 +61,7 @@ non-updatable. See [Running As A Service](Running-As-A-Service). Chips for every optional header control, with a live preview of the resulting header: -Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Away Digest, Session +Run, Font Size, System Stats, Redraw Terminal, Response Viewer, Story Mode, Away Digest, Session Manager, Attachments, Edit history, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Lifecycle Log, Monitor, Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents, Ultracode Windows, Cron. @@ -76,6 +76,18 @@ total is taken as half of Windows' RAM, the limit Windows applies to integrated `scaling_cur_freq`, `/proc/cpuinfo` or, inside WSL, the Windows `Processor Frequency` counter for the CPU clock. Nothing is read, and `nvidia-smi` and `typeperf.exe` are never run, while both are off. +**Story Mode** (off by default, per device, desktop and tablet) adds a header button that +draws the active Claude Code session as a comic: one page per prompt, panels for what Claude +did (reading, planning, helpers, edits, tests, saving), and plain captions anyone can follow. +Three reading levels sit in its bar: **Simple** (plain words, for anyone), **Advanced** (the +story with file names) and **Director's cut** (every step and tool, with a list of every event +beside the page), which is the default; your pick is remembered on that device. Click a file to +open it, the code page for the exact diff, a test board for the output, a helper for what it was +asked and what it did. An unfinished turn redraws as Claude works, and **Save image** downloads +the page as a PNG to share. The prompt bar under the page holds the turn picker and a field that +sends Claude your next prompt (Enter sends, Shift+Enter adds a line). On phones, open it from the +session's Session Options (the Story button there). + **Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is diff --git a/src/session-submit-verifier.ts b/src/session-submit-verifier.ts index edadf9981..8244dc712 100644 --- a/src/session-submit-verifier.ts +++ b/src/session-submit-verifier.ts @@ -21,6 +21,13 @@ * prompt with the same glyph higher up in the transcript, so only the last one says * whether the text was taken. * + * The browser's composers (Story Mode's prompt bar, the phone's Compose sheet) never send + * a `text\r` pair: they write one bracketed paste and a bare Enter about 120 ms later, + * because Codex drops a key that shares a read with a paste. `PastedPromptWatch` pairs + * those two writes back up so the same check covers them. A long paste is not on the + * composer as text at all (Claude Code draws `[Pasted text #1 +3 lines]`), so that + * stand-in counts as the prompt only when its line count is the prompt's own. + * * Pure apart from the injected capture, send and log, so the schedule, the cap and * every stop condition are unit-tested with fake timers (test/session-submit-verifier.test.ts). */ @@ -36,33 +43,105 @@ export const SUBMIT_VERIFY_DELAYS_MS: readonly number[] = [ 2_000, 3_000, 5_000, 5_000, 5_000, 10_000, 10_000, 10_000, 10_000, ]; -/** How many leading characters of the prompt have to match, whitespace removed. */ +/** + * How many leading characters of the prompt have to match, whitespace removed. A prompt + * shorter than this has to match whole, so a one-word answer cannot read a selection + * dialog's highlighted row (`❯ 1. Yes`) as itself. + */ const PROMPT_HEAD_CHARS = 24; const compact = (s: string): string => s.replace(/\s+/g, ''); +/** + * Claude Code's stand-in for a long paste, whitespace removed (measured on 2.1.303): + * `[Pasted text #1]` for one line over 800 characters, `[Pasted text #1 +3 lines]` for + * four lines or more, the number counting line BREAKS. `#n` counts pastes, so it is never + * pinned. Codex's `[Pasted Content N chars]` is not matched: not measured here. + */ +const PASTE_PLACEHOLDER = /^\[Pastedtext#\d+(?:\+(\d+)lines?)?\]$/; + +/** The box-drawing rule Claude Code draws under its composer, which ends the block. */ +const RULE_LINE = /^[\u2500-\u257f]/; + +const lineBreaks = (s: string): number => (s.match(/\r\n|\r|\n/g) ?? []).length; + /** * Whether `prompt` is still sitting unsubmitted in the composer of `screen`. * - * - `true`: the last `glyph` line holds the prompt's head. + * - `true`: the last `glyph` line holds the prompt's head, or the paste stand-in for a + * prompt with that many lines. * - `false`: the composer is empty (the prompt was taken) or holds other text. * - `undefined`: no composer line at all; nothing can be said, so nothing is sent. * * Whitespace is removed on both sides before comparing, because the composer wraps a * long prompt onto indented continuation lines and Claude Code draws a no-break space - * after the glyph; `\s` covers that one in JavaScript. + * after the glyph; `\s` covers that one in JavaScript. Those continuation lines (up to a + * blank line or the rule) count too, for a prompt whose first line is short. */ export function promptStillInComposer(screen: string, prompt: string, glyph: string): boolean | undefined { if (!glyph) return undefined; - const composerLines = stripAnsi(screen) - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.startsWith(glyph)); - if (composerLines.length === 0) return undefined; - const composer = compact(composerLines[composerLines.length - 1].slice(glyph.length)); - if (!composer) return false; - const head = compact(prompt).slice(0, PROMPT_HEAD_CHARS); - return head.length > 0 && composer.startsWith(head); + const lines = stripAnsi(screen).split('\n'); + let at = lines.length - 1; + while (at >= 0 && !lines[at].trim().startsWith(glyph)) at--; + if (at < 0) return undefined; + const first = compact(lines[at].trim().slice(glyph.length)); + if (!first) return false; + const placeholder = PASTE_PLACEHOLDER.exec(first); + if (placeholder) return Number(placeholder[1] ?? 0) === lineBreaks(prompt); + const wanted = compact(prompt); + if (!wanted) return false; + const head = wanted.slice(0, PROMPT_HEAD_CHARS); + const holds = (composer: string): boolean => + wanted.length >= PROMPT_HEAD_CHARS ? composer.startsWith(head) : composer === wanted; + if (holds(first)) return true; + let block = first; + for (let i = at + 1; i < lines.length; i++) { + const text = lines[i].trim(); + if (!text || !/^\s/.test(lines[i]) || RULE_LINE.test(text)) break; + block += compact(text); + } + return block !== first && holds(block); +} + +/** How long after a paste a bare Enter still counts as submitting it. */ +export const PASTE_ENTER_WINDOW_MS = 10_000; + +const PASTE_START = '\x1b[200~'; +const PASTE_END = '\x1b[201~'; + +/** The text of a write that is exactly one bracketed paste, else null. */ +export function bracketedPasteText(data: string): string | null { + if (data.length < PASTE_START.length + PASTE_END.length) return null; + if (!data.startsWith(PASTE_START) || !data.endsWith(PASTE_END)) return null; + const text = data.slice(PASTE_START.length, -PASTE_END.length); + return text.includes(PASTE_START) || text.includes(PASTE_END) ? null : text; +} + +/** + * Pairs a composer's two writes: one bracketed paste, then a bare Enter. Fed every + * write to the pane; anything between the two breaks the pair, so the Enter is + * only ever matched with the paste right before it. + */ +export class PastedPromptWatch { + private pending: { text: string; at: number } | null = null; + + constructor(private readonly windowMs: number = PASTE_ENTER_WINDOW_MS) {} + + /** + * `paste` when `data` is a paste (a newer one owns the composer now), `submit` with + * the pasted text when it is the Enter that sends the paste just before it. + */ + feed(data: string, now: number): { kind: 'paste' } | { kind: 'submit'; text: string } | null { + const pasted = bracketedPasteText(data); + if (pasted !== null) { + this.pending = pasted.trim() ? { text: pasted, at: now } : null; + return { kind: 'paste' }; + } + const pending = this.pending; + this.pending = null; + if (data !== '\r' || !pending || now - pending.at > this.windowMs) return null; + return { kind: 'submit', text: pending.text }; + } } /** diff --git a/src/session.ts b/src/session.ts index 27e9f9f28..eb593dee1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -134,7 +134,7 @@ import { import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; -import { SubmitVerifier } from './session-submit-verifier.js'; +import { PastedPromptWatch, SubmitVerifier } from './session-submit-verifier.js'; import { guardedComposerChrome, promptSha256, @@ -732,6 +732,7 @@ export class Session extends EventEmitter { private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below) /** Re-sends Enter while a programmatic prompt still sits in the composer (session-submit-verifier.ts). */ private _submitVerifier: SubmitVerifier | null = null; + private readonly _pastedPromptWatch = new PastedPromptWatch(); private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan) private _taskTracker: TaskTracker; @@ -2991,7 +2992,7 @@ export class Session extends EventEmitter { /** * The session's Claude config dir when it has been relocated (#255), else undefined. - * Public for the edit history reader, which looks for the transcript there. + * Public for the edit history reader and Story Mode, which look for the transcript there. */ claudeConfigDir(): string | undefined { // Trimmed like `claudeProjectsDir()` trims the process-wide override: the @@ -5310,6 +5311,7 @@ export class Session extends EventEmitter { if (!this.ptyProcess) return false; this.ptyProcess.write(data); this._emitSubmittedPrompt(submittedPrompt); + this._notePastedPrompt(data); return true; } @@ -5492,6 +5494,7 @@ export class Session extends EventEmitter { if (sent) { this._emitSubmittedPrompt(submittedPrompt); this._verifySubmitted(data); + this._notePastedPrompt(data); } return sent; } @@ -5521,12 +5524,39 @@ export class Session extends EventEmitter { * Arm the composer check for a write that carried Enter (session-submit-verifier.ts): * Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints, * so the pair `sendInput` just sent can leave the text stranded. Only a mux session - * can read its pane, only text can be stranded, and the glyph is the CLI's own. + * can read its pane, only text can be stranded, and the glyph is the CLI's own. The + * text checked is the one `TmuxManager.sendInput()` pastes (carriage returns gone, + * leading blank lines and trailing whitespace dropped), inner line breaks KEPT, so a + * multi-line prompt Claude Code shows as `[Pasted text #1 +3 lines]` is recognised. */ private _verifySubmitted(data: string): void { - if (!data.includes('\r') || !this._mux?.capturePaneText || !this._muxSession) return; - const text = data.replace(/[\r\n]/g, '').trimEnd(); - if (!text) return; + if (!data.includes('\r')) return; + this._armSubmitVerifier( + data + .replace(/\r/g, '') + .replace(/^\s*\n/, '') + .trimEnd() + ); + } + + /** + * The browser composers (Story Mode's prompt bar, the phone's Compose sheet) send a + * prompt as a bracketed paste and then a bare Enter, as two writes: over the + * WebSocket both land in `write()`, on the POST fallback the Enter takes + * `writeViaMux()`. Neither is a `text\r` pair, so the Enter armed nothing and a + * prompt sent while Claude Code was ignoring Enter sat unsent. This pairs them + * (PastedPromptWatch) and checks the pasted text, line breaks kept so a paste the + * CLI shows as `[Pasted text #1 +3 lines]` is still recognised. A newer paste + * cancels any pending check: pressing Enter then would send the user's paste too. + */ + private _notePastedPrompt(data: string): void { + const seen = this._pastedPromptWatch.feed(data, Date.now()); + if (seen?.kind === 'paste') this._submitVerifier?.cancel(); + else if (seen?.kind === 'submit') this._armSubmitVerifier(seen.text); + } + + private _armSubmitVerifier(text: string): void { + if (!text.trim() || !this._mux?.capturePaneText || !this._muxSession) return; this._submitVerifier ??= new SubmitVerifier({ capture: () => this._isStopped || !this._mux || !this._muxSession diff --git a/src/story-mode.ts b/src/story-mode.ts new file mode 100644 index 000000000..00771a8aa --- /dev/null +++ b/src/story-mode.ts @@ -0,0 +1,3557 @@ +/** + * @fileoverview Story Mode core (pure): a Claude Code transcript becomes comic pages, one page per turn. + * + * Three stages, each pure so the tests exercise the shipped code: + * + * 1. `createStoryReducer()` turns ONE parsed transcript line into compact `StoryEntry`s. This is where + * size is shed: a Read result's file content, a Write/Edit result's `originalFile` and image data + * never leave this function, outputs keep only a bounded tail, and an edit keeps its + * `structuredPatch` hunks up to `STORY_LIMITS.hunkLines`. The reader (story-reader.ts) caches + * these entries, so what is dropped here is never held in memory. + * 2. `buildStoryTurns()` joins each tool call with its result and splits the entries into turns. + * 3. `buildStoryPage()` groups one turn's events into panels and writes two captions per panel: + * a plain-language "story" line for anyone and a "director" line naming the tools. + * + * What the format rests on (measured 2026-10-18 on 40 local transcripts, counts and key names only): + * - Interactive transcripts carry NO `type: 'result'` entries. A turn ends with + * `{ type: 'system', subtype: 'turn_duration', durationMs }`; a compaction is `compact_boundary`. + * - A prompt is a `user` entry with text and no `tool_result`. `isMeta` and `isCompactSummary` + * entries are plumbing, `turnCompanion` rides along with a prompt, and `turnOrigin` says who + * started the turn (`human`, `task_notification`, `peer`, `system`, `scheduled`, `sdk`). + * - Tool results carry `toolUseResult` beside the `tool_result` block: every Edit result had a + * `structuredPatch` with line numbers (125 of 125), Write results the new `content`, Agent + * results `totalToolUseCount`/`totalDurationMs`, AskUserQuestion results `answers`. + * - Bash results carry no exit code; a failed call's `tool_result` has `is_error: true` + * (72 of 3,334, most starting "Exit code"). 92% of Bash calls carry Claude's own + * `description` ("Run the test suite"), which is what the plain captions use. + * - Bash is 78% of all tool calls, so a Bash call is sorted by what its command does + * (`classifyBashCommand`), not by its tool name. Across all 1,309 local transcripts (3,856 + * turns, 44,008 tool calls) it is 87%, and 91% of those calls carry a `description`. + * + * Everything here is untrusted text written by the agent. Nothing is interpreted: no path is + * opened, no command run, no regex built from transcript content. + */ + +import type { + StoryActor, + StoryEdit, + StoryEvent, + StoryHelper, + StoryHunk, + StoryIntent, + StoryPage, + StoryPanel, + StoryPanelKind, + StoryTests, + StoryTodo, + StoryTurnOrigin, + StoryTurnSummary, +} from './types/story.js'; + +/** Size caps. A page stays small enough to fetch on every live refresh. */ +export const STORY_LIMITS = Object.freeze({ + promptChars: 4000, + textChars: 600, + labelChars: 400, + inputChars: 2000, + outputChars: 8000, + outputLines: 160, + hunkLines: 160, + lineChars: 400, + helperChars: 4000, + todoItems: 12, + eventsPerPage: 800, + panelsPerPage: 12, + /** + * Characters of output, hunk and helper text per page; older events give theirs up first. Sized on + * 3,855 local turns: 94% never reach it, and the longest live page went from 157 KB to 74 KB + * gzipped (it is refetched every few seconds while it changes). + */ + pageTextBudget: 120_000, + turnsListed: 60, +}); + +// ========== Entries (stage 1 output) ========== + +interface ReducedInput { + command?: string; + /** A Bash call's intent, judged on the whole command before `command` is clipped for display. */ + intent?: StoryIntent; + /** Files a Bash call prints (`cat`, `head`, `tail`), from the whole command. */ + reads?: string[]; + /** A save that ships: `gh pr create` opens a pull request, `git push` sends the work up. */ + ships?: 'pr' | 'push'; + /** The segment of a Bash one-liner that decided its intent. */ + headline?: string; + /** How many commits the command makes (`git commit` segments), when any. */ + commits?: number; + /** The first commit's message, when the command spells it out. */ + subject?: string; + /** Files a Bash call writes through here-documents. */ + writes?: StoryEdit[]; + description?: string; + file?: string; + offset?: number; + old?: string; + new?: string; + pattern?: string; + path?: string; + url?: string; + query?: string; + type?: string; + name?: string; + prompt?: string; + todos?: StoryTodo[]; + question?: string; + options?: string[]; + plan?: string; + to?: string; + summary?: string; + text?: string; +} + +export type StoryEntry = + | { + k: 'prompt'; + id: string; + at: string; + text: string; + origin: StoryTurnOrigin; + queued: boolean; + /** A slash command (`/compact`), not a prompt to Claude. */ + slash?: boolean; + /** Claude Code's id for the prompt; one prompt can be written more than once (typed, then as a command). */ + promptId?: string; + /** A background task reporting back: which call it answers, and how it went. */ + task?: StoryTaskReport; + /** Teammates (helpers started as a team) reporting back, matched to their helper by name. */ + teammates?: StoryTeammateReport[]; + } + | { k: 'text'; at: string; text: string; keyPoints?: string[]; citesSaveRule?: boolean } + | { k: 'tool'; at: string; id: string; name: string; input: ReducedInput } + | { + k: 'result'; + at: string; + id: string; + isError: boolean; + output?: string; + outputTruncated?: boolean; + edit?: StoryEdit; + helper?: { result?: string; toolUses?: number; durationMs?: number; agentId?: string; background?: boolean }; + answer?: string; + tests?: StoryTests; + /** A check whose output reports problems (lint, format, types). */ + problems?: boolean; + /** The plan an ExitPlanMode call presented. */ + plan?: string; + /** Files Claude Code says a Bash call changed ("This command modified 1 file you've previously read: a.py"). */ + modified?: string[]; + /** A Bash call sent to the background: its result only says it started. */ + background?: boolean; + } + | { k: 'end'; at: string; durationMs?: number } + | { k: 'compact'; at: string; trigger?: string; preTokens?: number; postTokens?: number; durationMs?: number } + /** What a local slash command printed (``): its whole answer. */ + | { k: 'local'; at: string; text: string } + | { k: 'interrupt'; at: string }; + +/** What a `` says about the background call it answers. */ +interface StoryTaskReport { + toolUseId?: string; + status?: string; + result?: string; + toolUses?: number; + durationMs?: number; +} + +/** One ``: a helper started as a teammate, reporting back by name. */ +interface StoryTeammateReport { + from: string; + summary?: string; + result?: string; +} + +// ========== Small helpers ========== + +const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v); +const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined); +const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined); + +export function clip(value: string, max: number): string { + const s = value.trim(); + return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s; +} + +/** The END of a long output: that is where the result and the error are. */ +function clipTail(value: string, maxChars: number, maxLines: number): { text: string; truncated: boolean } { + let lines = value.replace(/\s+$/, '').split('\n'); + let truncated = false; + if (lines.length > maxLines) { + lines = lines.slice(-maxLines); + truncated = true; + } + let text = lines.join('\n'); + if (text.length > maxChars) { + text = text.slice(-maxChars); + truncated = true; + } + return { text, truncated }; +} + +/** Text of a message `content`: a string, or the text blocks of a block array. */ +function blockText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .map((b) => (isRecord(b) && b.type === 'text' && typeof b.text === 'string' ? b.text : '')) + .filter(Boolean) + .join('\n'); +} + +const basename = (path: string): string => path.slice(path.replace(/\/+$/, '').lastIndexOf('/') + 1) || path; +const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`; +const unique = (items: T[]): T[] => [...new Set(items)]; + +/** + * Markdown to plain text: only real markdown goes, paired emphasis and line-start markers. A bare `_` + * or `*` inside a word stays, or `test_wcount.py` would read `testwcount.py`. Code spans come back + * as placeholders (`\u0000\u0000`, their text in `spans`) so nothing inside one is read as + * markdown or as the end of a sentence. + */ +function plainMarkdown(text: string, spans: string[]): string { + return ( + text + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`([^`\n]*)`/g, (_, code: string) => `\u0000${spans.push(code) - 1}\u0000`) + .replace(/!?\[([^\]\n]*)\]\([^)\n]*\)/g, '$1') + .replace(/(^|[\s(])(\*\*|__)(?=\S)([^\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3') + .replace(/(^|[\s(])([*_])(?=\S)([^*_\n]*?\S)\2(?=[\s),!?:;]|\.(?:\s|$)|$)/g, '$1$3') + // A heading ends at its line. A short one (`## Summary`) is a label and goes; a longer one + // (`# Plan: add wrap to mathx.py`) is a sentence of its own. + .replace(/^[ \t]{0,3}#{1,6}[ \t]+(.*?)[ \t]*$/gm, (_, h: string) => + h.split(/\s+/).length <= 3 ? '' : /[.!?:]$/.test(h) ? h : `${h}.` + ) + .replace(/^[ \t]{0,3}(>|[-*+]|\d+[.)])[ \t]+/gm, '') + ); +} + +const restoreSpans = (text: string, spans: string[]): string => + // NUL marks a kept span: no text Claude writes contains one. + // eslint-disable-next-line no-control-regex + text.replace(/\u0000(\d+)\u0000/g, (_, n: string) => spans[Number(n)] ?? ''); + +/** + * Where a sentence ends: `.`, `!` or `?` before a space or the end, but not inside an ellipsis + * (`...`) and not after `e.g` or `i.e`. + */ +const SENTENCE_END = /^(.+?(? = [ + [ + 'problem', + // "bug-fixing" and "cron-broken-network" are names, and a bare "warning" is often one too. + /\b(problems?|bugs?(?!-| fix)|(? (NOT_FIXED.test(s) || !PROBLEM_CLOSED.test(s)) && !NO_PROBLEM.test(s); +/** A sentence's kind: the first that matches, where a problem must still be open. */ +/** A sentence about how things were before the change ("Before, an early idle could end a wait"). */ +const HISTORY = /^(before|previously|until now|in the past|originally)\b,?|\bused to\b/i; +const sentenceKind = (s: string): StoryKeyKind | undefined => + KEY_KINDS.find( + ([kind, re]) => + re.test(s) && + (kind !== 'problem' || openProblem(s)) && + ((kind !== 'problem' && kind !== 'pending') || !HISTORY.test(s)) + )?.[0]; +/** A sentence whose "it" or "that" is further back: "would fix it", "That's the cause". */ +const POINTS_BACK = /^(that|this|it)\b|\b(fix|solve|change|handle|avoid|prevent|add) (it|that|this|them)\b/i; +/** A sentence that only points elsewhere ("described below"): nothing a balloon can show. */ +const POINTS_ELSEWHERE = /\b(described|listed|shown|details?) below\b|\bsee below\b|\bas follows\b/i; +/** The fix that follows a problem sentence: then the problem is not open. */ +const FIX_FOLLOWS = + /^(so i (changed|fixed|made|updated)|i (fixed|changed|corrected|updated) (it|that|this|the)|fixed\b|now it\b)/i; + +export type StoryKeyKind = 'problem' | 'offer' | 'howto' | 'pending' | 'failed' | 'asked' | 'correction'; + +/** A key point that waits on you: a question, or an offer to do more (not an optional tip). */ +const ASKS_YOU = + /\?$|\b(want me to|should i|shall i|say if you want|let me know|tell me)\b|\bi can\b.{0,100}\b(if|whenever) you want\b/i; + +/** The kind of a key point already chosen (its words say it), for the final panel's title. */ +export function keyKindOf(point: string): StoryKeyKind | undefined { + // A problem with its fix is history, not an open problem. + if (/\b(so i (changed|fixed)|i fixed|fixed it)\b/i.test(point)) return undefined; + return sentenceKind(point); +} + +/** + * Sentences from further into a long message that a reader must not lose, at most three, picked by + * kind (one of each, in KEY_KINDS order) and shown in the order Claude wrote them; a bare negation + * only when none of those is there. Read on the whole message (the event's label is clipped), its + * first 8,000 characters. A table row is skipped; a list intro ending in `:` brings its first item; a + * sentence starting with "that"/"this"/"it" brings the one before it; a problem brings the fix that + * follows it. + */ +function keyPointsOf(text: string): string[] { + if (text.length <= 40) return []; + const spans: string[] = []; + const plain = plainMarkdown(text.slice(0, 8000), spans); + // Every sentence in order, with the line it came from (list items are lines). + const sentences: string[] = []; + const lineOf: number[] = []; + let lineNo = 0; + for (const line of plain.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + lineNo++; + if (trimmed.startsWith('|') || (trimmed.match(/\|/g)?.length ?? 0) >= 2) continue; // a table row + let rest = trimmed.replace(/^[-*•]\s+/, '').replace(/\s+/g, ' '); + while (rest) { + const m = SENTENCE_END.exec(rest); + sentences.push((m ? m[1] : rest).trim()); + lineOf.push(lineNo); + rest = m ? rest.slice(m[1].length).trim() : ''; + } + } + type Pick = { at: number; text: string; kind?: StoryKeyKind }; + const candidates: Pick[] = []; + let weak: Pick | undefined; + for (let i = 1; i < sentences.length && candidates.length < 24; i++) { + let sentence = sentences[i]; + if (sentence.length < 12 || POINTS_ELSEWHERE.test(sentence)) continue; + // Judged on its own words (a list intro too: "Known quirks:", "Two places where I didn't…:"). + const kind = sentenceKind(sentence); + if (!kind && (weak || !KEY_WEAK.test(sentence))) continue; + // A list intro brings its first items, while short, never one that only points elsewhere. One that + // announces how many ("Two places where I didn't follow your wording:") brings that many lines. + let wide = false; + if (/:$/.test(sentence)) { + const count = + /\b(two|three|2|3)\s+(places|things|points|changes|issues|differences|deviations|items|notes|decisions|quirks|caveats)\b/i.exec( + sentence + )?.[1]; + const lines = count ? (/^(three|3)$/i.test(count) ? 3 : 2) : 0; + wide = lines > 0; + const room = lines ? 480 : 360; + for ( + let k = i + 1; + k < sentences.length && + (lines ? lineOf[k] <= lineOf[i] + lines : k <= i + 2) && + sentence.length + sentences[k].length < room; + k++ + ) + if (!POINTS_ELSEWHERE.test(sentences[k])) sentence = `${sentence} ${sentences[k]}`; + } + // "That's out of date" needs what "that" was, and so does "Want me to add that?". + // ("It doesn't mention…" is a sentence of its own: only "that"/"this" point back.) + const refersBack = + /^(that|this)\b/i.test(sentence) || + (kind === 'offer' && sentence.length < 80 && /\b(that|this|it|them)\b/i.test(sentence)); + let before = refersBack && i > 1 ? `${sentences[i - 1]} ` : ''; + // "…would fix it for up to 10 files. Want me to add that?": what "it" is sits further back, at the + // start of that paragraph ("Suffix swap: the two files … would trade their _1 suffix."). + if (before && kind === 'offer' && i > 2 && POINTS_BACK.test(sentences[i - 1])) { + let start = i - 1; + while (start > 1 && lineOf[start - 1] === lineOf[i - 1]) start--; + if (start < i - 1) before = `${sentences[start]}${start < i - 2 ? ' …' : ''} ${before}`; + } + // A problem reported with its fix right after it: both, so it never reads as still open. + const fix = + kind === 'problem' && sentences[i + 1] && FIX_FOLLOWS.test(sentences[i + 1]) ? ` ${sentences[i + 1]}` : ''; + const shown = clip( + restoreSpans(before + sentence + fix, spans), + before.includes(' …') || wide ? 480 : before || fix ? 360 : 300 + ); + if (kind) candidates.push({ at: i, text: shown, kind }); + else weak = { at: i, text: shown }; + } + const picked: Pick[] = []; + for (const [kind] of KEY_KINDS) { + const c = candidates.find((p) => p.kind === kind); + if (c && picked.length < 3) picked.push(c); + } + if (!picked.length && weak) picked.push(weak); + return picked.sort((a, b) => a.at - b.at).map((p) => p.text); +} + +/** + * Claude's last words: the first sentence, the next when it carries a caveat, and the key points from + * further in (an open problem, what was or was not done), so the final panel never reads as a clean + * finish when Claude said otherwise. + */ +export function closingText(text: string, keyPoints: string[] = [], gap = ' '): string { + const [one, two] = firstTwoSentences(text); + // A very short first sentence ("Not done.") says too little alone. + const head = [one]; + if (two && (CAVEAT.test(two) || OUTCOME.test(two) || one.length < 24) && two.length <= 200) head.push(two); + const more: string[] = []; + for (const point of keyPoints) + if (![...head, ...more].some((p) => p.includes(point) || point.includes(p))) more.push(point); + // `gap` marks where the balloon skips ahead (" … " for a helper's report quoted in a notice): only + // where text was left out between two kept sentences, found in the message itself. + const flat = (t: string) => + t + .replace(/[`*_#>|-]/g, '') + .replace(/\s+/g, ' ') + .trim(); + const whole = gap === ' ' ? '' : flat(text.slice(0, 8000)); + let out = head.join(' '); + let end = whole ? whole.indexOf(flat(out).slice(0, 60)) + flat(out).length : -1; + for (const point of more) { + const at = whole ? whole.indexOf(flat(point).slice(0, 60)) : -1; + out += at >= 0 && end >= 0 && at - end <= 2 ? ` ${point}` : `${gap}${point}`; + end = at >= 0 ? at + flat(point).length : -1; + } + return clip(out, head.length + more.length > 1 ? 560 : 300); +} + +/** The first sentence of a message, without markdown, for a speech balloon. */ +export function firstSentence(text: string, max = 160): string { + const spans: string[] = []; + // Only the start can hold the first sentence; the rest (a helper's whole report) is never scanned. + const plain = plainMarkdown(text.slice(0, 4000), spans).replace(/\s+/g, ' ').trim(); + const match = SENTENCE_END.exec(plain); + return clip(restoreSpans(match ? match[1] : plain, spans).replace(/\s+/g, ' '), max); +} + +/** + * A plan Claude wrote as text (no plan tool): three numbered items or more, or two checkboxes or + * more. Plain bullets never count; a summary at the end of a turn is usually a bullet list. + */ +export function planFromText(text: string): StoryTodo[] | null { + const items: StoryTodo[] = []; + let numbered = 0; + let boxes = 0; + for (const line of text.slice(0, 20_000).split('\n')) { + const m = /^\s{0,3}(?:(\d+)[.)]|[-*+])\s+(\[[ xX]\]\s+)?(\S.*)$/.exec(line); + if (!m || (!m[1] && !m[2])) continue; + if (m[2]) boxes++; + else numbered++; + const spans: string[] = []; + const plain = restoreSpans(plainMarkdown(m[3].slice(0, 400), spans), spans) + .replace(/\s+/g, ' ') + .trim(); + if (plain) items.push({ text: clip(plain, 120), done: /x/i.test(m[2] ?? '') }); + } + return numbered >= 3 || boxes >= 2 ? items.slice(0, STORY_LIMITS.todoItems) : null; +} + +export function roughDuration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return plural(s, 'second'); + const m = Math.round(s / 60); + if (m < 60) return plural(m, 'minute'); + const h = Math.floor(m / 60); + const rest = m % 60; + return rest ? `${h} h ${rest} min` : plural(h, 'hour'); +} + +export function exactDuration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s} s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} min ${s % 60} s`; + return `${Math.floor(m / 60)} h ${m % 60} min ${s % 60} s`; +} + +const fmtCounts = (added: number, removed: number): string => + [added ? `+${added}` : '', removed ? `−${removed}` : ''].filter(Boolean).join(' ') || '±0'; + +// ========== Bash commands and test counts ========== + +/** + * Splits a command line into its simple commands at `&&`, `||`, `;`, `|` and newlines, outside quotes. + * Not a shell parser: just enough to see what each part of a one-liner starts with. + */ +function commandSegments(command: string): string[] { + const segments: string[] = []; + let cur = ''; + let quote = ''; + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + if (quote) { + if (ch === quote) quote = ''; + else if (ch === '\\' && quote === '"') cur += command[++i] ?? ''; + cur += ch; + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + cur += ch; + continue; + } + if (ch === ';' || ch === '\n' || ch === '|' || (ch === '&' && command[i + 1] === '&')) { + if (ch === '&' || (ch === '|' && command[i + 1] === '|')) i++; + segments.push(cur); + cur = ''; + continue; + } + cur += ch; + } + segments.push(cur); + return segments.map((x) => x.trim()).filter(Boolean); +} + +/** A segment's command with what only wraps it removed: env assignments, `sudo`, `timeout 60`, `( ...`. */ +function segmentHead(segment: string): string { + let s = segment.replace(/^[({\s]+/, ''); + // Shell structure is not a command: `for x in a b`, `done`, `fi`; `do`/`then`/`if` only wrap one. + if (/^(for\s+\w+(\s+in\b.*)?|done|fi|esac|else|[{}])\s*$/.test(s)) return ''; + if (/^command\s+-[vV]\b/.test(s)) return 'which'; + for (let k = 0; k < 6; k++) { + const next = s + .replace(/^(do|then|else|if|elif|while|until|!)\s+/, '') + .replace(/^[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'[^']*'|\S*)\s+/, '') + .replace(/^(sudo|command|exec|time|nice|nohup|env|xargs)\s+/, '') + .replace(/^timeout\s+(-\S+\s+)*\S+\s+/, ''); + if (next === s) break; + s = next; + } + // `git -c k=v -C dir commit`: drop git's own options so the subcommand comes next. + return s.replace(/^git((\s+-[cC]\s+\S+)|(\s+--\S+))*\s+/, 'git '); +} + +const TEST_HEAD = + /^(npm (run )?test\b|npm run test:\S+|yarn (run )?test\b|pnpm (run )?test\b|bun test\b|npx (vitest|jest|mocha|playwright test)\b|vitest\b|jest\b|mocha\b|pytest\b|py\.test\b|python3? -m (pytest|unittest)\b|node --test\b|deno test\b|go test\b|cargo (test|nextest)\b|make (test|check)\b|rspec\b|phpunit\b|mix test\b|dotnet test\b|ctest\b|tox\b)/; +const BUILD_HEAD = + /^(npm run (build|typecheck|lint|check|format)\S*|yarn (run )?(build|lint|typecheck)\b|pnpm (run )?(build|lint|typecheck)\b|tsc\b|npx (tsc|eslint|prettier|biome)\b|eslint\b|prettier\b|biome\b|make\b|cargo (build|check|clippy)\b|go (build|vet)\b|gradlew?\b|\.\/gradlew\b|mvn\b|ruff\b|mypy\b|black\b|cmake\b|ninja\b|shellcheck\b|node --check\b)/; +const SAVE_HEAD = /^(git (commit|push|tag|merge|rebase|cherry-pick|revert|am)\b|gh (pr|release) (create|merge)\b)/; +/** Editors that change files in place. `tee` is not one: in a pipeline it keeps a log. */ +const EDIT_HEAD = /^(sed (-i|--in-place)\b|perl -p?i\b|patch\b|git apply\b)/; +/** Where a redirect writes scratch, not the project: temp dirs, devices, and paths held in variables. */ +const SCRATCH_TARGET = /^(\/tmp\/|\/var\/tmp\/|\/dev\/|\$|~\/\.cache\/)/; +const LOOK_HEAD = + /^(cd|cat|head|tail|sed -n|grep|egrep|rg|find|ls|wc|awk|jq|tree|stat|file|du|df|diff|less|more|xxd|od|which|type|pwd|echo|printf|sort|uniq|cut|basename|dirname|realpath|readlink|git (status|diff|log|show|branch|blame|ls-files|rev-parse|remote|config (--get|-l|--list))|gh (pr|issue|run|api) (view|list|diff|checks))\b/; +/** A probe that asks a tool about itself without running anything: `pytest --version`, `npm test --help`. */ +const PROBE = /\s--(version|help|collect-only)\b|\s-[Vh]\s*$/; + +/** + * Whether a segment writes a project file through a redirect (`> notes.md`, `>> app.log`, `cat > a.py`). + * Quote-aware: a `>` inside a quoted string is text. A redirect into scratch (`/tmp/x`, `"$S/out.txt"`, + * `/dev/null`) or onto a stream (`2>&1`) is not an edit, and the target must look like a file. + */ +function redirectsIntoProject(segment: string): boolean { + let quote = ''; + for (let i = 0; i < segment.length; i++) { + const ch = segment[i]; + if (quote) { + if (ch === quote) quote = ''; + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + continue; + } + if (ch !== '>') continue; + const before = segment[i - 1] ?? ' '; + if (/[0-9&>]/.test(before) && before !== ' ') continue; // `2>`, `&>`, the second `>` of `>>` + let j = i + 1; + if (segment[j] === '>') j++; + if (segment[j] === '&' || segment[j] === '(') continue; // `>&2`, `>(process)` + while (segment[j] === ' ' || segment[j] === '\t') j++; + const q = segment[j] === '"' || segment[j] === "'" ? segment[j++] : ''; + let target = ''; + while (j < segment.length && (q ? segment[j] !== q : !/[\s|;&<>()]/.test(segment[j]))) target += segment[j++]; + if (target && !SCRATCH_TARGET.test(target) && /^[\w./~@+-]*\.\w+$/.test(target)) return true; + i = j; + } + return false; +} + +/** + * One-line writes: `printf 'a\nb\n' > f`, `echo "x" >> f`. The quoted text is the content (printf + * and `echo -e` turn `\n` into line breaks). Same target rules as here-documents. + */ +function echoWrites(command: string, cwd?: string): StoryEdit[] { + const out: StoryEdit[] = []; + let moved = false; + for (const segment of commandSegments(withoutHeredocBodies(command.slice(0, 20_000)))) { + const head = segmentHead(segment); + if (/^(cd|pushd)\b/.test(head)) moved = true; + const m = + /^(printf|echo)((?:\s+-[neE]+)*)\s+(['"])((?:(?!\3)[\s\S])*)\3\s*(>>?)\s*(["']?)([^\s"'|;&<>()]+)\6\s*$/.exec( + head + ); + if (!m || SCRATCH_TARGET.test(m[7])) continue; + const escapes = m[1] === 'printf' || /e/.test(m[2]); + const text = escapes ? m[4].replace(/\\n/g, '\n').replace(/\\t/g, '\t') : m[4]; + const lines = text.replace(/\n$/, '').split('\n'); + const file = + m[7].startsWith('/') || !cwd || moved ? m[7] : `${cwd.replace(/\/+$/, '')}/${m[7].replace(/^\.\//, '')}`; + out.push({ + file, + added: lines.length, + removed: 0, + created: false, + hunks: [ + { + oldStart: 0, + oldLines: 0, + newStart: m[5] === '>>' ? 0 : 1, + newLines: lines.length, + lines: lines.slice(0, 40).map((l) => `+${clip(l, STORY_LIMITS.lineChars)}`), + ...(m[5] === '>>' ? { atEnd: true } : {}), + }, + ], + truncated: lines.length > 40, + }); + if (out.length >= 6) break; + } + return out; +} + +/** + * The first commit's message in a command: `-m "..."` / `-m '...'`, or the first line of the + * here-document a `git commit -F -` reads. + */ +function commitSubject(command: string): string | undefined { + // Line by line with plain searches, never one regex over the command: a command is agent-written, + // and `[^;]*commit[^;]*?-m` over many `git commit` tokens backtracks (seconds on a long one-liner). + const lines = command.slice(0, 8000).split('\n'); + for (let i = 0; i < lines.length && i < 400; i++) { + const line = lines[i]; + const g = line.search(/\bgit\b/); + if (g < 0) continue; + const c = line.slice(g).search(/\bcommit\b/); + if (c < 0) continue; + const rest = line.slice(g + c + 'commit'.length); + // Its own options only: the part before the next `;`, `&` or `|`. + const stop = rest.search(/[;&|]/); + const end = stop < 0 ? rest.length : stop; + // `-m`, a combined `-am` / `-qm`, or `--message`; a trailer (`Co-Authored-By:`) is never the subject. + const m = /\s-(?:[a-zA-Z]{0,8}m|-message=?)\s*(["'])/.exec(rest); + if (m && m.index < end) { + const from = m.index + m[0].length; + const close = rest.indexOf(m[1], from); + const first = (close < 0 ? rest.slice(from) : rest.slice(from, close)).trim(); + if (first && !/^[\w-]+-By:/i.test(first)) return clip(first, 100); + continue; + } + // `git commit -F - <<'EOF'`: the here-document's first line. + const f = /\s-F\s*-(?=\s|$)/.exec(rest); + const h = /<<-?\s*(['"]?)(\w+)\1/.exec(rest); + if (f && f.index < end && h) { + const body = lines[i + 1]?.trim(); + if (body && body !== h[2]) return clip(body, 100); + } + } + return undefined; +} + +/** A here-document's body is data, not commands: `cat > a.py <<'EOF' ... EOF` keeps only its first line. */ +function withoutHeredocBodies(command: string): string { + const out: string[] = []; + let end: string | null = null; + for (const line of command.split('\n')) { + if (end !== null) { + if (line.trim() === end) end = null; + continue; + } + out.push(line); + const m = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line.replace(/<<]/.test(m[2])) continue; + const words = m[2].match(/"[^"]*"|'[^']*'|\S+/g) ?? []; + for (let k = 0; k < words.length; k++) { + const w = words[k]; + if (w.startsWith('-')) { + if (/^-[nc]$/.test(w)) k++; // `head -n 20 file` + continue; + } + const name = w.replace(/^(["'])(.*)\1$/, '$2'); + if (!name || /[*?$`{}[\]]/.test(name) || /^\d+$/.test(name)) continue; + files.push(name); + } + } + return unique(files).slice(0, 8); +} + +/** + * Strongest first: one test in a one-liner makes the whole call a test run (its pass or fail is the + * news), and a commit outranks the build or lint that ran before it. + */ +const INTENT_RANK: StoryIntent[] = ['test', 'save', 'build', 'edit', 'run', 'look']; + +/** + * What a Bash command does, from the command text alone. Never executes it, and never parses it as + * shell beyond splitting it into simple commands: each part is judged by what it starts with (after + * env assignments and wrappers like `timeout`), and the strongest intent of any part wins. + */ +export function classifyBashCommand(command: string): StoryIntent { + return judgeBashCommand(command).intent; +} + +/** The intent of a Bash command and the segment that decided it. */ +function judgeBashCommand(command: string): { intent: StoryIntent; segment: string } { + let best = INTENT_RANK.length - 1; + let deciding = ''; + for (const segment of commandSegments(withoutHeredocBodies(command.slice(0, 4000)))) { + const head = segmentHead(segment); + if (!head) continue; + const intent: StoryIntent = PROBE.test(head) + ? 'look' + : TEST_HEAD.test(head) + ? 'test' + : BUILD_HEAD.test(head) + ? 'build' + : SAVE_HEAD.test(head) + ? 'save' + : EDIT_HEAD.test(head) || redirectsIntoProject(head) + ? 'edit' + : LOOK_HEAD.test(head) + ? 'look' + : 'run'; + const rank = INTENT_RANK.indexOf(intent); + if (rank < best || !deciding) { + best = Math.min(best, rank); + deciding = segment.trim(); + } + } + return { intent: INTENT_RANK[best], segment: deciding }; +} + +/** + * Files a command writes through here-documents: `cat > f <<'EOF'`, `cat < f`, `tee f <>` / `tee -a`, appends). Each becomes a whole-file edit of the body's lines. A relative + * target resolves against the transcript line's `cwd` unless the command changed directory first; + * scratch targets (`/tmp/x`, `"$S/x"`) are not project files. A script fed to python or node + * (`python3 - <&0-9])(>>?)\s*(["']?)([^\s"'|;&<>()]+)\2/; + let target: string | undefined; + let append = false; + if (/\bcat\s*(>|$|\s)/.test(before)) { + const r = redirect.exec(before) ?? redirect.exec(after); + if (r) { + target = r[3]; + append = r[1] === '>>'; + } + } else { + const tee = /\btee\s+(-a\s+)?(["']?)([^\s"'|;&<>()]+)\2/.exec(before); + if (tee) { + target = tee[3]; + append = !!tee[1]; + } + } + if (!target && /\b(python3?|node)\b/.test(before)) { + for (const written of scriptWrites(body)) { + if (SCRATCH_TARGET.test(written) || out.some((d) => d.file.endsWith(written))) continue; + const file = + written.startsWith('/') || !cwd || moved + ? written + : `${cwd.replace(/\/+$/, '')}/${written.replace(/^\.\//, '')}`; + out.push({ file, added: 0, removed: 0, created: false, hunks: [], truncated: true }); + if (out.length >= 6) break; + } + continue; + } + if (!target || SCRATCH_TARGET.test(target)) continue; + const file = + target.startsWith('/') || !cwd || moved ? target : `${cwd.replace(/\/+$/, '')}/${target.replace(/^\.\//, '')}`; + const kept = body + .slice(0, STORY_LIMITS.hunkLines) + .map((l) => (l.length >= STORY_LIMITS.lineChars ? `+${l.slice(0, STORY_LIMITS.lineChars - 2)}…` : `+${l}`)); + out.push({ + file, + added: body.length, + removed: 0, + created: false, + hunks: [ + append + ? { oldStart: 0, oldLines: 0, newStart: 0, newLines: body.length, lines: kept, atEnd: true } + : { oldStart: 0, oldLines: 0, newStart: 1, newLines: body.length, lines: kept }, + ], + truncated: kept.length < body.length, + }); + } + return out; +} + +/** + * Files a python or node script opens for writing: `open('a.py', 'w')`, `open(p, "w")` after + * `p = "a.py"`, `Path('a.py').write_text(...)`, `fs.writeFileSync('a.js', ...)`. Only string literals + * and names bound to one; anything computed is left out rather than guessed. + */ +function scriptWrites(body: string[]): string[] { + const names = new Map(); + for (const line of body) { + const m = /^\s*(?:(?:const|let|var)\s+)?([A-Za-z_]\w*)\s*=\s*(?:Path\(\s*)?(['"])([^'"\n]+)\2\s*\)?\s*;?\s*$/.exec( + line + ); + if (m) names.set(m[1], m[3]); + } + const target = (lit: string | undefined, name: string | undefined) => lit ?? (name ? names.get(name) : undefined); + const found: Array<{ at: number; file: string }> = []; + const text = body.join('\n'); + const patterns = [ + // open(target, 'w') / open(target, mode="a+") + /\bopen\(\s*(?:(['"])([^'"\n]+)\1|([A-Za-z_]\w*))\s*,\s*(?:mode\s*=\s*)?(['"])([rwaxbt+]*)\4/g, + // Path(target).write_text( / name.write_text( + /(?:\bPath\(\s*(?:(['"])([^'"\n]+)\1|([A-Za-z_]\w*))\s*\)|\b([A-Za-z_]\w*))\.write_(?:text|bytes)\(/g, + // fs.writeFileSync(target / appendFileSync(target + /\b(?:write|append)File(?:Sync)?\(\s*(?:(['"])([^'"\n]+)\1|([A-Za-z_]\w*))/g, + ]; + for (const [k, re] of patterns.entries()) { + for (const m of text.matchAll(re)) { + if (k === 0 && !/[wax+]/.test(m[5])) continue; + const file = k === 1 ? target(m[2], m[3] ?? m[4]) : target(m[2], m[3]); + if (file && !/[*?$`{}]/.test(file)) found.push({ at: m.index ?? 0, file }); + } + } + // In the order the script writes them. + return unique(found.sort((x, y) => x.at - y.at).map((f) => f.file)).slice(0, 6); +} + +/** + * Whether a linter, formatter or type checker reported problems (`Found 3 errors.`, `1 file would be + * reformatted`, `✖ 5 problems`, `error TS2322:`). Read when a trailing command (`; ls`) hid the exit code. + */ +export function reportsProblems(output: string): boolean { + // The last verdict wins: a formatter's "1 file would be reformatted" preview, then "1 file + // reformatted" and a clean check, is clean. + const last = (patterns: RegExp[]) => + Math.max(-1, ...patterns.map((re) => [...output.matchAll(new RegExp(re.source, 'gm'))].pop()?.index ?? -1)); + const problem = last([ + /\bFound [1-9]\d* errors?\b/, + /\b[1-9]\d* files? would be reformatted\b/, + /^would reformat /, + /✖ [1-9]\d* problems?\b/, + /\[warn\] Code style issues found/, + /\berror TS\d+:/, + ]); + if (problem < 0) return false; + const clean = last([ + /\bAll checks passed!/, + /\bFound 0 errors\b/, + /^(?!.*would be).*\b\d+ files? reformatted\b/, + /^(?!.*would be).*\b\d+ files? (left unchanged|already formatted)\b/, + ]); + return problem > clean; +} + +/** + * Passed/failed counts from a test runner's summary line (vitest, jest, pytest, mocha, cargo...). + * Scans the last lines only; returns null when no summary is recognisable. + */ +export function parseTestCounts(output: string): StoryTests | null { + // The newest summary wins; a long output is searched from its end (the last 2,000 lines). + const lines = output.split('\n').slice(-2000).reverse(); + // node:test (TAP `# pass 7` / spec reporter `ℹ pass 7`), with its own fail line. + const nodePass = lines.map((l) => /^\s*[#ℹ]\s*pass\s+(\d+)\s*$/.exec(l)).find(Boolean); + if (nodePass) { + const nodeFail = lines.map((l) => /^\s*[#ℹ]\s*fail\s+(\d+)\s*$/.exec(l)).find(Boolean); + return { passed: Number(nodePass[1]), failed: nodeFail ? Number(nodeFail[1]) : 0 }; + } + // Errors (pytest's collection errors) are counted apart: the run failed although no test did. + const errorsOn = (line: string) => Number(/\b(\d+) errors?\b/i.exec(line)?.[1] ?? 0); + const counts = (passed: number, failed: number, errors: number): StoryTests => + errors ? { passed, failed, errors } : { passed, failed }; + for (const line of lines) { + const passed = /\b(\d+) (passed|passing)\b/i.exec(line); + if (!passed) continue; + const failedSameLine = /\b(\d+) (failed|failing)\b/i.exec(line); + let failed = failedSameLine ? Number(failedSameLine[1]) : 0; + if (!failedSameLine) { + const failing = lines.map((l) => /^\s*(\d+) failing\b/i.exec(l)).find(Boolean); + if (failing) failed = Number(failing[1]); + } + return counts(Number(passed[1]), failed, errorsOn(line)); + } + // Nothing passed: a summary line that only counts failures (`=== 3 failed in 0.12s ===`, + // `Tests 3 failed (3)`, `1 error in 0.05s`). + for (const line of lines) { + if (!/^\s*(=+ |Tests?:?\s|test result:)|\bin [\d.]+m?s\b/.test(line)) continue; + const failed = Number(/\b(\d+) (failed|failing)\b/i.exec(line)?.[1] ?? 0); + if (failed || errorsOn(line)) return counts(0, failed, errorsOn(line)); + } + return null; +} + +// ========== Stage 1: reduce one transcript line ========== + +/* + * Tagged blocks in prompt text are found with indexOf, never a lazy `[\s\S]*?` regex: that + * one rescans to the end for every opener without a closer (seconds on a crafted megabyte, and a + * teammate's or a helper's message is another agent's writing). + */ +/** The text with every `…` block taken out; an opener never closed stays. */ +function withoutBlocks(text: string, open: string, close: string): string { + let out = ''; + let from = 0; + for (;;) { + const at = text.indexOf(open, from); + if (at < 0) break; + const end = text.indexOf(close, at + open.length); + if (end < 0) break; + out += text.slice(from, at); + from = end + close.length; + } + return out + text.slice(from); +} + +/** `inner` blocks in order (`name` followed by a space or `>`), at most `limit`. */ +function blocks(text: string, name: string, limit = 50): Array<{ attrs: string; inner: string }> { + const out: Array<{ attrs: string; inner: string }> = []; + const open = `<${name}`; + const close = ``; + let from = 0; + while (out.length < limit) { + const at = text.indexOf(open, from); + if (at < 0) break; + const next = text[at + open.length]; + if (next !== '>' && next !== undefined && !/\s/.test(next)) { + from = at + open.length; + continue; + } + const gt = text.indexOf('>', at + open.length); + if (gt < 0) break; + const end = text.indexOf(close, gt + 1); + if (end < 0) break; + out.push({ attrs: text.slice(at + open.length, gt), inner: text.slice(gt + 1, end) }); + from = end + close.length; + } + return out; +} + +/** The inner text of the first `…`. */ +function blockText1(text: string, name: string): string | undefined { + const at = text.indexOf(`<${name}>`); + if (at < 0) return undefined; + const end = text.indexOf(``, at); + return end < 0 ? undefined : text.slice(at + name.length + 2, end); +} +/** Wrappers around things the user did locally (shell `!`, slash command output): never a turn. */ +const LOCAL_WRAPPER_RE = + /^\s*<(local-command-caveat|local-command-stdout|local-command-stderr|bash-input|bash-stdout|bash-stderr)>/; + +/** + * Messages from teammates: `body`, + * where the body is text or a JSON notification (`{"type":"idle_notification","result":"..."}`). + */ +function readTeammateMessages(text: string): StoryTeammateReport[] { + const reports: StoryTeammateReport[] = []; + for (const m of blocks(text, 'teammate-message')) { + const from = /teammate_id="([^"]*)"/.exec(m.attrs)?.[1]?.split('@')[0]; + if (!from) continue; + const rawSummary = /summary="([^"]*)"/.exec(m.attrs)?.[1]; + const summary = rawSummary === undefined ? undefined : unescapeXml(rawSummary); + let body: string | undefined = unescapeXml(m.inner.trim()); + if (body.startsWith('{')) { + try { + const note: unknown = JSON.parse(body); + body = isRecord(note) ? (str(note.result) ?? str(note.summary) ?? str(note.message)) : undefined; + } catch { + // not JSON after all: keep it as text + } + } + reports.push({ + from: clip(from, 80), + summary: summary ? clip(summary, 200) : undefined, + result: body || undefined, + }); + } + return reports; +} + +/** Claude Code escapes a notification's text (`<noreply@anthropic.com>`); undo the five it uses. */ +const unescapeXml = (s: string): string => + s.replace( + /&(lt|gt|quot|apos|amp);/g, + (_, e: string) => ({ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[e] ?? _ + ); + +function readPrompt(raw: string): { + text: string; + kind: 'prompt' | 'task' | 'peer' | 'interrupt'; + /** A slash command (`/compact`, `/model`), from its ``. */ + slash?: boolean; + task?: StoryTaskReport; + teammates?: StoryTeammateReport[]; +} | null { + const text = withoutBlocks(raw, '', '') + .replace(/<\/?pasted_content\b[^>]*>/g, '') // Claude Code's wrapper around pasted text + .replace(/^\s*Another Claude session sent a message:\s*/, '') + .trim(); + if (!text) return null; + if (/^ t.summary || t.result) ?? teammates[0]; + if (first) + return { + text: `${first.from}: ${first.summary || (first.result ? firstSentence(first.result, 200) : 'reports back')}`, + kind: 'task', + teammates, + }; + } + const peer = /^]*)>([\s\S]*?)(?:<\/cross-session-message>|$)/.exec(text); + if (peer) { + const name = /from-name="([^"]*)"/.exec(peer[1])?.[1]; + return { text: `${name ? `${name}: ` : ''}${peer[2].trim()}`, kind: 'peer' }; + } + if (/^\[Request interrupted/.test(text)) return { text, kind: 'interrupt' }; + if (LOCAL_WRAPPER_RE.test(text)) return null; + const command = /([^<]*)<\/command-name>/.exec(text); + if (command) { + const args = /([^<]*)<\/command-args>/.exec(text)?.[1] ?? ''; + return { text: `${command[1].trim()} ${args.trim()}`.trim(), kind: 'prompt', slash: true }; + } + if (/^/.test(text)) { + const tag = (name: string) => { + const value = blockText1(text, name)?.trim(); + return value === undefined ? undefined : unescapeXml(value); + }; + const count = (name: string) => { + const n = Number(tag(name)); + return tag(name) !== undefined && Number.isFinite(n) ? n : undefined; + }; + const task: StoryTaskReport = { + toolUseId: tag('tool-use-id'), + status: tag('status'), + result: tag('result'), + toolUses: count('tool_uses'), + durationMs: count('duration_ms'), + }; + return { text: tag('summary') || 'A background task finished.', kind: 'task', task }; + } + // A command typed as such (`/compact`, `/model opus`); a path (`/home/x`) is not one. + if (/^\/[a-z][\w:-]*(\s|$)/i.test(text) && !text.includes('\n')) return { text, kind: 'prompt', slash: true }; + return { text, kind: 'prompt' }; +} + +function originOf(turnOrigin: unknown, kind: 'prompt' | 'task' | 'peer'): StoryTurnOrigin { + if (turnOrigin === 'task_notification' || turnOrigin === 'task-notification' || kind === 'task') return 'task'; + if (turnOrigin === 'peer' || turnOrigin === 'coordinator' || kind === 'peer') return 'peer'; + if (turnOrigin === 'human') return 'human'; + if (turnOrigin === 'scheduled') return 'scheduled'; + if (typeof turnOrigin === 'string') return 'system'; + return 'human'; +} + +function reduceInput(name: string, input: unknown, cwd?: string): ReducedInput { + const i = isRecord(input) ? input : {}; + const s = (v: unknown, max: number) => { + const value = str(v); + return value === undefined ? undefined : clip(value, max); + }; + switch (name) { + case 'Bash': { + // Judged on the whole command: a commit or a test run often sits past the displayed part. + const full = str(i.command) ?? ''; + const { intent, segment } = judgeBashCommand(full); + const input: ReducedInput = { + // Shown without here-document bodies: the files they write are shown as diffs instead. + command: clip(withoutHeredocBodies(full), STORY_LIMITS.labelChars), + description: s(i.description, 160), + intent, + }; + if (segment && segment !== full.trim() && (intent === 'test' || intent === 'build' || intent === 'save')) + input.headline = clip(segment, 200); + const writes = heredocWrites(full, cwd); + if (writes.length) input.writes = writes; + // A script or command that writes project files is an edit, whatever it was run as. + if (writes.length && (intent === 'run' || intent === 'look')) input.intent = 'edit'; + // Files it prints, whatever else it does (`cat a.py && pytest`): the drawer offers them. A + // relative name resolves against the line's cwd (printedFiles gives none after a `cd`). + const reads = printedFiles(full).map((f) => + f.startsWith('/') || f.startsWith('~') || !cwd ? f : `${cwd.replace(/\/+$/, '')}/${f.replace(/^\.\//, '')}` + ); + if (reads.length) input.reads = reads; + { + // Counted whatever wins: a test run that ends in a commit still saves the work. + const heads = commandSegments(withoutHeredocBodies(full.slice(0, 4000))).map(segmentHead); + if (heads.some((h) => /^gh pr create\b/.test(h))) input.ships = 'pr'; + else if (heads.some((h) => /^git push\b/.test(h))) input.ships = 'push'; + const commits = heads.filter((h) => /^git commit\b/.test(h)).length; + // Counted on every command that commits: a save can also be a push or a tag that makes none. + if (commits) input.commits = commits; + if (commits) { + const subject = commitSubject(full); + if (subject) input.subject = subject; + } + } + return input; + } + case 'Read': + case 'NotebookRead': + return { file: s(i.file_path ?? i.notebook_path, 1000), offset: num(i.offset) }; + case 'Edit': + case 'MultiEdit': + case 'NotebookEdit': + return { + file: s(i.file_path ?? i.notebook_path, 1000), + old: s(i.old_string ?? i.old_str, STORY_LIMITS.inputChars), + new: s(i.new_string ?? i.new_str, STORY_LIMITS.inputChars), + }; + case 'Write': { + const file = s(i.file_path, 1000); + // A plan-mode plan file keeps its text: it becomes the plan panel's list. + return file && PLAN_FILE.test(file) ? { file, plan: s(i.content, 4000) } : { file }; + } + case 'Grep': + case 'Glob': + return { pattern: s(i.pattern, 200), path: s(i.path, 1000) }; + case 'WebFetch': + return { url: s(i.url, 400) }; + case 'WebSearch': + return { query: s(i.query, 200) }; + case 'Agent': + case 'Task': + return { + description: s(i.description, 160), + type: s(i.subagent_type, 60), + name: s(i.name, 60), + prompt: s(i.prompt, STORY_LIMITS.helperChars), + }; + case 'TodoWrite': { + const todos = Array.isArray(i.todos) ? i.todos : []; + return { + todos: todos.slice(0, STORY_LIMITS.todoItems).map((t) => ({ + text: clip(String((isRecord(t) && (str(t.content) ?? str(t.subject))) || ''), 160), + done: isRecord(t) && t.status === 'completed', + })), + }; + } + case 'AskUserQuestion': { + const q = Array.isArray(i.questions) && isRecord(i.questions[0]) ? i.questions[0] : {}; + const options = Array.isArray(q.options) ? q.options : []; + return { + question: s(q.question, 300), + options: options.slice(0, 6).map((o) => clip(String((isRecord(o) ? str(o.label) : str(o)) ?? ''), 80)), + }; + } + case 'ExitPlanMode': + return { plan: s(i.plan, STORY_LIMITS.helperChars) }; + case 'SendMessage': + return { to: s(i.to ?? i.recipient, 80), summary: s(i.summary ?? i.message, 200) }; + default: { + const first = Object.values(i).find((v): v is string => typeof v === 'string'); + return { text: first ? clip(first, 200) : undefined }; + } + } +} + +function editFromResult(name: string, result: Record, input: ReducedInput): StoryEdit | undefined { + const file = str(result.filePath) ?? input.file; + if (!file) return undefined; + const clipLine = (l: string) => + l.length > STORY_LIMITS.lineChars ? `${l.slice(0, STORY_LIMITS.lineChars - 1)}…` : l; + const hunks: StoryHunk[] = []; + let added = 0; + let removed = 0; + let truncated = false; + let budget: number = STORY_LIMITS.hunkLines; + const patch = Array.isArray(result.structuredPatch) ? result.structuredPatch : []; + for (const h of patch) { + if (!isRecord(h)) continue; + const lines = Array.isArray(h.lines) ? h.lines.filter((l): l is string => typeof l === 'string') : []; + for (const l of lines) { + if (l.startsWith('+')) added++; + else if (l.startsWith('-')) removed++; + } + const kept = lines.slice(0, Math.max(0, budget)).map(clipLine); + budget -= kept.length; + if (kept.length < lines.length) truncated = true; + if (kept.length) { + hunks.push({ + oldStart: num(h.oldStart) ?? 0, + oldLines: num(h.oldLines) ?? 0, + newStart: num(h.newStart) ?? 0, + newLines: num(h.newLines) ?? 0, + lines: kept, + }); + } + } + const content = str(result.content); + if (!patch.length && name === 'Write' && content !== undefined) { + // A new file has no patch: every line is an addition. + const lines = content.split('\n'); + if (content.endsWith('\n')) lines.pop(); + added = lines.length; + const kept = lines.slice(0, STORY_LIMITS.hunkLines).map((l) => clipLine(`+${l}`)); + truncated = kept.length < lines.length; + hunks.push({ oldStart: 0, oldLines: 0, newStart: 1, newLines: lines.length, lines: kept }); + } else if (!patch.length && input.old !== undefined && input.new !== undefined) { + // An older transcript without `structuredPatch`: show the replacement itself. + const oldLines = input.old.split('\n'); + const newLines = input.new.split('\n'); + removed = oldLines.length; + added = newLines.length; + const lines = [...oldLines.map((l) => `-${l}`), ...newLines.map((l) => `+${l}`)]; + const kept = lines.slice(0, STORY_LIMITS.hunkLines).map(clipLine); + truncated = kept.length < lines.length; + hunks.push({ oldStart: 0, oldLines: removed, newStart: 0, newLines: added, lines: kept }); + } + return { file, added, removed, created: result.type === 'create', hunks, truncated }; +} + +/** Tools whose result text is noise on a comic page (file contents, "edited successfully"). */ +/** Server tools under the names the client tools of the same kind use. */ +const SERVER_TOOLS: Record = { advisor: 'Advisor', web_search: 'WebSearch', web_fetch: 'WebFetch' }; +/** The advisor's answer reaches the transcript encrypted; this says so instead. */ +const ADVISOR_ANSWER = 'The advice went to Claude only: the transcript keeps it encrypted.'; + +const QUIET_RESULTS = new Set(['Read', 'NotebookRead', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'TodoWrite']); +const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']); + +export interface StoryReducer { + /** Compact entries for one parsed JSONL line (an object, or anything: non-objects reduce to nothing). */ + reduce(line: unknown): StoryEntry[]; +} + +/** + * A reducer remembers each tool call's name and input until its result arrives, since the result line + * does not repeat them. Bounded: a call whose result never comes is forgotten after 4,000 newer calls. + * `sidechain` reads a helper's own transcript, where every line is a sidechain line; the main + * transcript skips them. + */ +export function createStoryReducer({ sidechain = false }: { sidechain?: boolean } = {}): StoryReducer { + const calls = new Map(); + const remember = (id: string, name: string, input: ReducedInput) => { + calls.set(id, { name, input }); + if (calls.size > 4000) { + const oldest = calls.keys().next().value; + if (oldest !== undefined) calls.delete(oldest); + } + }; + + function reduceUser(entry: Record, at: string): StoryEntry[] { + if (entry.isCompactSummary === true || entry.isVisibleInTranscriptOnly === true) return []; + const message = isRecord(entry.message) ? entry.message : {}; + const content = message.content; + const out: StoryEntry[] = []; + if (Array.isArray(content)) { + const results = content.filter((b): b is Record => isRecord(b) && b.type === 'tool_result'); + for (const block of results) { + const id = str(block.tool_use_id); + if (!id) continue; + const call = calls.get(id); + const name = call?.name ?? ''; + const isError = block.is_error === true; + // `toolUseResult` describes the entry's (single) result; only trust it for the first block. + const extra = block === results[0] && isRecord(entry.toolUseResult) ? entry.toolUseResult : {}; + const text = blockText(block.content); + const result: Extract = { k: 'result', at, id, isError }; + if (EDIT_TOOLS.has(name) && !isError) { + const edit = editFromResult(name, extra, call?.input ?? {}); + if (edit) result.edit = edit; + } + if (name === 'Agent' || name === 'Task') { + // A background start answers with launch notes meant for Claude only, never the helper's answer. + const background = + extra.isAsync === true || extra.status === 'async_launched' || extra.status === 'teammate_spawned'; + const answer = background ? '' : blockText(extra.content) || text; + result.helper = { + background: background || undefined, + result: answer ? clip(answer, STORY_LIMITS.helperChars) : undefined, + toolUses: num(extra.totalToolUseCount), + durationMs: num(extra.totalDurationMs), + agentId: + typeof extra.agentId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(extra.agentId) + ? extra.agentId + : undefined, + }; + } else if (name === 'ExitPlanMode') { + // The plan rides on the result (the call's input is empty in current builds), with your answer. + if (typeof extra.plan === 'string') result.plan = clip(extra.plan, 4000); + if (!isError && /\bapproved\b/i.test(text)) result.answer = 'Approved'; + else if (text) result.answer = 'Sent back'; + } else if (name === 'AskUserQuestion' && isRecord(extra.answers)) { + const first = Object.values(extra.answers).find((v): v is string => typeof v === 'string'); + if (first) result.answer = clip(first, 300); + } else if (text && (!QUIET_RESULTS.has(name) || isError)) { + const tail = clipTail(text, STORY_LIMITS.outputChars, STORY_LIMITS.outputLines); + result.output = tail.text; + if (tail.truncated) result.outputTruncated = true; + if (name === 'Bash' && /^\s*Command running in background with ID:/.test(text)) { + result.background = true; + // The launch note tells Claude where to read later; the reader gets what it means. + result.output = 'Started in the background. Its result comes back as a report of its own.'; + result.outputTruncated = undefined; + } else if (name === 'Bash') { + // Counted on the whole output: a later command's lines can push the summary out of the tail. + const tests = parseTestCounts(text); + if (tests) result.tests = tests; + const intent = call?.input.intent; + if ((intent === 'build' || intent === 'test') && reportsProblems(tail.text)) result.problems = true; + // Claude Code appends this note: one anchored try at its last occurrence, never a scan + // of the whole output (a lazy match per repeat of the phrase is quadratic). + const noteAt = text.lastIndexOf('This command modified '); + const note = + noteAt < 0 + ? null + : /^This command modified \d+ files? you've previously read: ([^\]\n]{1,2000}?)\.? Call Read/.exec( + text.slice(noteAt, noteAt + 2400) + ); + if (note) { + const cwd = str(entry.cwd); + result.modified = note[1] + .split(/,\s*/) + .filter((f) => f && !/\s/.test(f)) + .slice(0, 8) + .map((f) => (f.startsWith('/') || !cwd ? f : `${cwd.replace(/\/+$/, '')}/${f}`)); + } + } + } + out.push(result); + calls.delete(id); + } + if (results.length) return out; + } + // A scheduled task's prompt is written as a meta line; it still starts a turn of its own. + const scheduled = entry.turnOrigin === 'scheduled' || typeof entry.scheduledTaskId === 'string'; + if ((entry.isMeta === true && !scheduled) || entry.turnCompanion === true) return out; + const local = /^\s*([\s\S]*?)<\/local-command-\1>/.exec(blockText(content)); + if (local) { + const text = local[2] + // eslint-disable-next-line no-control-regex + .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') + .replace(/`([^`\n]+)`/g, '$1') + .trim(); + return [{ k: 'local', at, text: clip(text, 2000) }]; + } + const prompt = readPrompt(blockText(content)); + if (!prompt) return out; + if (prompt.kind === 'interrupt') return [{ k: 'interrupt', at }]; + return [ + { + ...promptEntry( + prompt, + str(entry.uuid) ?? `prompt-${at}`, + at, + originOf(entry.turnOrigin, prompt.kind), + entry.promptSource === 'queued' + ), + promptId: str(entry.promptId), + }, + ]; + } + + /** + * A prompt that arrived while Claude worked: current builds write no user line for it, only an + * `attachment` of type `queued_command` where Claude took it in (a typed prompt, a background + * task's notification, a message from another session). + */ + function reduceQueuedCommand(entry: Record, at: string): StoryEntry[] { + const a = isRecord(entry.attachment) ? entry.attachment : {}; + if (a.type !== 'queued_command') return []; + const prompt = readPrompt(typeof a.prompt === 'string' ? a.prompt : blockText(a.prompt)); + if (!prompt || prompt.kind === 'interrupt') return []; + const from = isRecord(a.origin) ? a.origin.kind : undefined; + const kind = a.commandMode === 'task-notification' && prompt.kind === 'prompt' ? 'task' : prompt.kind; + const id = str(a.source_uuid) ?? str(entry.uuid) ?? `queued-${at}`; + return [promptEntry(prompt, id, at, originOf(from ?? 'human', kind), true)]; + } + + function promptEntry( + prompt: NonNullable>, + id: string, + at: string, + origin: StoryTurnOrigin, + queued: boolean + ): Extract { + const reduced: Extract = { + k: 'prompt', + id, + at, + text: clip(prompt.text, STORY_LIMITS.promptChars), + origin, + queued, + }; + if (prompt.slash) reduced.slash = true; + if (prompt.task) { + const t = prompt.task; + reduced.task = { + toolUseId: t.toolUseId && /^[A-Za-z0-9_-]{1,128}$/.test(t.toolUseId) ? t.toolUseId : undefined, + status: t.status ? clip(t.status, 40) : undefined, + result: t.result ? clip(t.result, STORY_LIMITS.helperChars) : undefined, + toolUses: t.toolUses, + durationMs: t.durationMs, + }; + } + if (prompt.teammates?.length) + reduced.teammates = prompt.teammates.slice(0, 8).map((t) => ({ + from: t.from, + summary: t.summary, + result: t.result ? clip(t.result, STORY_LIMITS.helperChars) : undefined, + })); + return reduced; + } + + function reduceAssistant(entry: Record, at: string): StoryEntry[] { + const message = isRecord(entry.message) ? entry.message : {}; + const content = message.content; + const textEntry = (text: string): StoryEntry => { + const keyPoints = keyPointsOf(text); + // Read on the whole message: the label keeps only its start. + const rule = RULE_SAVE.test(text.slice(0, 8000)); + return { + k: 'text', + at, + text: clip(text, STORY_LIMITS.textChars), + ...(keyPoints.length ? { keyPoints } : {}), + ...(rule ? { citesSaveRule: true } : {}), + }; + }; + if (typeof content === 'string') return content.trim() ? [textEntry(content)] : []; + if (!Array.isArray(content)) return []; + const out: StoryEntry[] = []; + for (const block of content) { + if (!isRecord(block)) continue; + if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) { + out.push(textEntry(block.text)); + } else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { + const input = reduceInput(block.name, block.input, str(entry.cwd)); + remember(block.id, block.name, input); + out.push({ k: 'tool', at, id: block.id, name: block.name, input }); + } else if (block.type === 'server_tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { + // Tools the API runs itself (the advisor, web search): call and result share the message. + const name = SERVER_TOOLS[block.name] ?? block.name; + const input = reduceInput(name, block.input); + remember(block.id, name, input); + out.push({ k: 'tool', at, id: block.id, name, input }); + } else if ( + typeof block.type === 'string' && + block.type.endsWith('_tool_result') && + typeof block.tool_use_id === 'string' && + calls.has(block.tool_use_id) + ) { + const content = isRecord(block.content) ? block.content : {}; + const isError = typeof content.type === 'string' && /error/.test(content.type); + const call = calls.get(block.tool_use_id); + const result: Extract = { k: 'result', at, id: block.tool_use_id, isError }; + if (call?.name === 'Advisor' && !isError) result.helper = { result: ADVISOR_ANSWER }; + out.push(result); + calls.delete(block.tool_use_id); + } + } + return out; + } + + return { + reduce(line: unknown): StoryEntry[] { + if (!isRecord(line) || (line.isSidechain === true && !sidechain)) return []; + const at = str(line.timestamp) ?? ''; + switch (line.type) { + case 'user': + return reduceUser(line, at); + case 'assistant': + return reduceAssistant(line, at); + case 'attachment': + return reduceQueuedCommand(line, at); + case 'system': + if (line.subtype === 'turn_duration') return [{ k: 'end', at, durationMs: num(line.durationMs) }]; + // Some sessions never write turn_duration; the Stop hook's summary marks the same end (unless + // the hook kept Claude going). + if (line.subtype === 'stop_hook_summary' && line.preventedContinuation !== true) return [{ k: 'end', at }]; + if (line.subtype === 'compact_boundary') { + const meta = isRecord(line.compactMetadata) ? line.compactMetadata : {}; + return [ + { + k: 'compact', + at, + trigger: str(meta.trigger), + preTokens: num(meta.preTokens), + postTokens: num(meta.postTokens), + durationMs: num(meta.durationMs), + }, + ]; + } + return []; + default: + return []; + } + }, + }; +} + +// ========== Stage 2: turns ========== + +export interface StoryTurn { + id: string; + at: string; + prompt: string; + origin: StoryTurnOrigin; + /** Began before the part of the transcript that was read. */ + partial: boolean; + events: StoryEvent[]; + ended: boolean; + interrupted: boolean; + endedAt?: string; + durationMs?: number; + /** Claude Code's id for the prompt that started the turn. */ + promptId?: string; + /** What ended the turn, for the Director's cut. */ + endedBy?: 'turn_duration' | 'stop_hook_summary' | 'local command' | 'interrupted' | 'next prompt'; +} + +/** How long after a turn's end a line may still belong to it (Claude Code flushes some lines late). */ +const LATE_FLUSH_MS = 5000; + +/** Plan mode keeps its plan in a file under `~/.claude/plans/`: writing it is planning, not changing code. */ +const PLAN_FILE = /\/\.claude\/plans\/[^/]+\.md$/; + +function intentOf(name: string, input: ReducedInput): StoryIntent { + switch (name) { + case 'Read': + case 'NotebookRead': + case 'LS': + return 'read'; + case 'Grep': + case 'Glob': + return 'search'; + case 'WebFetch': + case 'WebSearch': + return 'web'; + case 'TodoWrite': + case 'TaskCreate': + case 'TaskUpdate': + case 'EnterPlanMode': + return 'plan'; + case 'ToolSearch': + return 'look'; + case 'ExitPlanMode': + case 'AskUserQuestion': + return 'ask'; + case 'Agent': + case 'Task': + case 'Workflow': + case 'Advisor': + return 'helper'; + case 'SendMessage': + return 'message'; + case 'Edit': + case 'MultiEdit': + case 'Write': + case 'NotebookEdit': + return input.file && PLAN_FILE.test(input.file) ? 'plan' : 'edit'; + case 'Bash': + return input.intent ?? classifyBashCommand(input.command ?? ''); + default: + return 'tool'; + } +} + +function labelOf(name: string, i: ReducedInput): string { + switch (name) { + case 'Bash': + return i.command ?? ''; + case 'Grep': + case 'Glob': + return `"${i.pattern ?? ''}"${i.path ? ` in ${i.path}` : ''}`; + case 'WebFetch': + return i.url ?? ''; + case 'WebSearch': + return i.query ?? ''; + case 'Agent': + case 'Task': + return `${i.name || i.type || 'helper'}: ${i.description ?? ''}`.trim(); + case 'Advisor': + return 'a second opinion'; + case 'TodoWrite': + return plural(i.todos?.length ?? 0, 'item'); + case 'AskUserQuestion': + return i.question ?? ''; + case 'ExitPlanMode': + return 'Plan ready for your OK'; + case 'EnterPlanMode': + return 'plan mode on'; + case 'ToolSearch': + return `tools: ${(i.text ?? '').replace(/^select:/, '')}`; + case 'Write': + case 'Edit': + return i.file && PLAN_FILE.test(i.file) ? `the plan, ${basename(i.file)}` : (i.file ?? ''); + case 'SendMessage': + return `to ${i.to ?? '?'}: ${i.summary ?? ''}`.trim(); + default: + return i.file ?? i.text ?? ''; + } +} + +function toolEvent(e: Extract, actor: StoryActor): StoryEvent { + const i = e.input; + const ev: StoryEvent = { + id: e.id, + at: e.at, + actor, + tool: e.name, + label: clip(labelOf(e.name, i), STORY_LIMITS.labelChars), + intent: intentOf(e.name, i), + pending: true, + }; + if (i.file) ev.file = i.file; + if (i.reads) ev.files = i.reads; + if (i.ships) ev.ships = i.ships; + if (i.headline) ev.headline = i.headline; + if (i.commits) ev.commits = i.commits; + if (i.subject) ev.subject = i.subject; + if (i.writes) ev.edits = i.writes; + if (i.offset) ev.line = i.offset; + if (e.name === 'Bash' && i.description) ev.description = i.description; + if (e.name === 'Agent' || e.name === 'Task') { + ev.helper = { + name: i.name || i.type || 'helper', + type: i.type || 'general-purpose', + task: i.description, + prompt: i.prompt || i.description || '', + }; + } + if (e.name === 'Advisor') + ev.helper = { + name: 'advisor', + type: 'advisor', + task: 'A second opinion', + prompt: 'Claude shows a stronger model the whole conversation so far and asks what it thinks.', + }; + if (i.todos) ev.todos = i.todos; + if (i.plan) { + const todos = planFromText(i.plan); + if (todos) ev.todos = todos; + } + if (e.name === 'AskUserQuestion') ev.question = { text: i.question ?? '', options: i.options ?? [] }; + if (e.name === 'ExitPlanMode') + ev.question = { text: firstSentence(i.plan ?? 'Claude has a plan.', 240), options: [] }; + return ev; +} + +function applyResult(ev: StoryEvent, r: Extract): void { + ev.pending = undefined; + ev.ok = !r.isError; + if (r.output !== undefined) ev.output = r.output; + if (r.outputTruncated) ev.outputTruncated = true; + if (r.edit && ev.intent !== 'plan') { + ev.edit = r.edit; + ev.file = r.edit.file; + } + if (r.plan) { + ev.output = r.plan; + if (ev.question) ev.question = { ...ev.question, text: firstSentence(r.plan, 240) }; + const todos = planFromText(r.plan); + if (todos) ev.todos = todos; + } + if (r.problems) ev.ok = false; + if (r.background) { + // Started, not done: its task notification says how it ended. + ev.running = true; + ev.background = true; + ev.ok = undefined; + } + // Reading a background job's output file that reports tests is a look at test results. + if ( + r.tests && + ev.tool === 'Bash' && + (ev.intent === 'look' || ev.intent === 'run') && + /\.(output|log)\b|\/tasks\//.test(ev.label) + ) + ev.intent = 'test'; + if (r.modified?.length) { + // Changed, but no diff to show: the file is listed, its lines unknown. A plain command that did + // it (a script, a formatter) edited files. + if (ev.intent === 'run' || ev.intent === 'look') ev.intent = 'edit'; + const known = new Set((ev.edits ?? []).map((d) => d.file)); + const more = r.modified + .filter((f) => !known.has(f)) + .map((file) => ({ file, added: 0, removed: 0, created: false, hunks: [], truncated: true })); + if (more.length) ev.edits = [...(ev.edits ?? []), ...more]; + } + if (r.tests) { + ev.tests = r.tests; + // A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0). + if (r.tests.failed > 0 || (r.tests.errors ?? 0) > 0) ev.ok = false; + } + if (r.helper && ev.helper) { + const helper: StoryHelper = { ...ev.helper }; + if (r.helper.background && !r.isError) { + helper.background = true; + helper.running = true; + } + if (r.helper.result) helper.result = r.helper.result; + if (r.helper.toolUses !== undefined) helper.toolUses = r.helper.toolUses; + if (r.helper.durationMs !== undefined) helper.durationMs = r.helper.durationMs; + if (r.helper.agentId) helper.agentId = r.helper.agentId; + ev.helper = helper; + } + if (r.answer && ev.question) ev.question = { ...ev.question, answer: r.answer }; +} + +/** + * Splits reduced entries into turns. A prompt starts a turn, except one queued while a turn is + * still running, which joins it ("you add something"). Entries before the first prompt in the + * window form a `partial` turn. `turn_duration` ends a turn; an interrupt ends it too. + */ +export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { + const turns: StoryTurn[] = []; + let cur: StoryTurn | null = null; + const byId = new Map(); + /** Helper calls by tool id, across turns: a background helper reports back in a later turn. */ + const helpers = new Map(); + /** A compaction between turns, held for the turn that comes next. */ + let pendingCompact: StoryEvent | null = null; + /** The same helpers by name: a teammate reports back by name, not by call. */ + const helpersByName = new Map(); + let say = 0; + /** The turn each helper or command call belongs to: a report after that turn ended came back later. */ + const turnOfCall = new WeakMap(); + const cameBackLater = (ev: StoryEvent) => { + const own = turnOfCall.get(ev); + return !!own && (own !== cur || own.ended); + }; + const teammatesBack = (reports: StoryTeammateReport[], prompt: StoryEvent) => { + const told = reports.filter((r) => r.result || r.summary); + if (told.length) { + prompt.output = clip( + told.map((r) => `${r.from}: ${r.result ?? r.summary}`).join('\n\n'), + STORY_LIMITS.helperChars + ); + } + for (const r of reports) { + const ev = helpersByName.get(r.from); + if (!ev?.helper) continue; + const helper: StoryHelper = { ...ev.helper, running: undefined }; + if (helper.background && cameBackLater(ev)) ev.finishedLater = true; + if (r.result) helper.result = r.result; + ev.helper = helper; + } + }; + /** Ends a turn at `at` with no duration of its own: timed by its span, when the span is known. */ + const closeTurn = (t: StoryTurn, at: string, by: NonNullable) => { + t.ended = true; + t.endedAt = at; + t.endedBy = by; + const span = Date.parse(at) - Date.parse(t.at); + if (!t.partial && Number.isFinite(span) && span >= 0) t.durationMs ??= span; + }; + const reportBack = (report: StoryTaskReport, prompt: StoryEvent) => { + if (report.result) { + prompt.output = report.result; + if (report.result.length >= STORY_LIMITS.helperChars) prompt.outputTruncated = true; + } + const ev = report.toolUseId ? helpers.get(report.toolUseId) : undefined; + if (ev?.running && !ev.helper) { + // A background command: done now, and its summary carries the exit code. + ev.running = undefined; + if (cameBackLater(ev)) ev.finishedLater = true; + const code = /\(exit code (\d+)\)/.exec(prompt.label)?.[1]; + ev.ok = + code !== undefined ? code === '0' : !report.status || /^(completed|done|finished|success)/i.test(report.status); + ev.output = `Finished in the background${code !== undefined ? `, exit code ${code}` : ''}. Its report is the turn it came back in.`; + return; + } + if (!ev?.helper) return; + const helper: StoryHelper = { ...ev.helper, running: undefined }; + if (helper.background && cameBackLater(ev)) ev.finishedLater = true; + if (report.result) helper.result = report.result; + if (report.toolUses !== undefined) helper.toolUses = report.toolUses; + if (report.durationMs !== undefined) helper.durationMs = report.durationMs; + ev.helper = helper; + // The report's own turn links back to the helper: its drawer shows what the helper did. + prompt.helper = { ...helper }; + if (report.status && !/^(completed|done|finished|success)/i.test(report.status)) ev.ok = false; + }; + const ensure = (at: string): StoryTurn => { + // Work that comes well after a turn ended (no prompt of its own) is a turn of its own; a line + // Claude Code flushed a moment late still joins the turn it belongs to. + const after = cur?.ended ? Date.parse(at) - Date.parse(cur.endedAt ?? at) : 0; + if (!cur || (cur.ended && Number.isFinite(after) && after > LATE_FLUSH_MS)) { + cur = { + id: cur ? `continued-${at}` : `partial-${at}`, + at, + prompt: '', + origin: cur ? 'system' : 'human', + partial: !cur, + events: [], + ended: false, + interrupted: false, + }; + turns.push(cur); + } + return cur; + }; + for (const e of entries) { + switch (e.k) { + case 'prompt': { + const ev: StoryEvent = { + id: e.id, + at: e.at, + actor: e.origin === 'human' ? 'you' : 'system', + tool: e.slash ? 'Command' : 'Prompt', + label: e.text, + intent: 'prompt', + origin: e.origin, + }; + if (e.task) reportBack(e.task, ev); + if (e.teammates) teammatesBack(e.teammates, ev); + const open = cur as StoryTurn | null; + if (open && !open.ended && e.promptId && open.promptId === e.promptId) { + // The same prompt written again (`/compact` typed, then recorded as a command): one turn. + const first = open.events[0]; + if (e.slash && first?.intent === 'prompt') { + first.tool = 'Command'; + first.label = e.text; + open.prompt = e.text; + } + break; + } + if (open && !open.ended && e.queued) { + // A report that lands while Claude works is a line on the wire, not a new mission. + if (e.origin === 'task') { + ev.intent = 'notice'; + ev.tool = 'Report'; + } + open.events.push(ev); + } else { + // Claude runs one turn at a time: a turn still open when the next one starts has ended. + if (open && !open.ended) closeTurn(open, e.at, 'next prompt'); + cur = { + id: e.id, + at: e.at, + prompt: e.text, + origin: e.origin, + partial: false, + events: [ev], + ended: false, + interrupted: false, + }; + if (e.promptId) cur.promptId = e.promptId; + turns.push(cur); + byId.clear(); + if (pendingCompact) { + cur.events.push(pendingCompact); + pendingCompact = null; + } + } + break; + } + case 'text': + ensure(e.at).events.push({ + id: `say-${say++}`, + at: e.at, + actor: 'claude', + tool: 'Text', + label: e.text, + intent: 'say', + ...(e.keyPoints ? { keyPoints: e.keyPoints } : {}), + ...(e.citesSaveRule ? { citesSaveRule: true } : {}), + }); + break; + case 'tool': { + const ev = toolEvent(e, 'claude'); + ensure(e.at).events.push(ev); + byId.set(e.id, ev); + if (ev.helper || ev.tool === 'Bash') { + // A helper, or a command that may turn out to run in the background: both report back later. + helpers.set(e.id, ev); + turnOfCall.set(ev, ensure(e.at)); + if (helpers.size > 2000) helpers.delete(helpers.keys().next().value as string); + if (ev.helper) { + helpersByName.set(ev.helper.name, ev); + if (helpersByName.size > 500) helpersByName.delete(helpersByName.keys().next().value as string); + } + } + break; + } + case 'result': { + const ev = byId.get(e.id); + if (ev) applyResult(ev, e); + break; + } + case 'end': { + const t = cur as StoryTurn | null; + if (t) { + t.ended = true; + t.endedAt = e.at; + // Claude Code times a turn a background task started from the prompt that started the chain; + // a turn never takes longer than its own span, so that is the cap. + const span = Date.parse(e.at) - Date.parse(t.at); + const known = !t.partial && Number.isFinite(span) && span >= 0; + if (e.durationMs !== undefined) t.durationMs = known ? Math.min(e.durationMs, span) : e.durationMs; + else if (known && t.durationMs === undefined) t.durationMs = span; // ended by the Stop hook's summary + t.endedBy = e.durationMs !== undefined ? 'turn_duration' : (t.endedBy ?? 'stop_hook_summary'); + } + break; + } + case 'compact': { + const ev: StoryEvent = { + id: `compact-${say++}`, + at: e.at, + actor: 'system', + tool: 'Compact', + label: 'Context compacted', + intent: 'compact', + }; + const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)); + const facts = [ + e.trigger === 'auto' ? 'automatic' : e.trigger, + e.preTokens !== undefined && e.postTokens !== undefined + ? `${k(e.preTokens)} → ${k(e.postTokens)} tokens` + : '', + e.durationMs !== undefined ? exactDuration(e.durationMs) : '', + ].filter(Boolean); + if (facts.length) ev.description = facts.join(' · '); + if (e.trigger === 'auto') ev.label = 'Context compacted: memory was full'; + // Between turns (`/compact` writes its boundary before the command line itself), it belongs to + // the turn that comes next; mid-turn (an automatic compaction), to the running one. + const t = cur as StoryTurn | null; + if (t && !t.ended) t.events.push(ev); + else pendingCompact = ev; + break; + } + case 'local': { + // A local slash command answers itself: its turn ends with what it printed. + const t = cur as StoryTurn | null; + if (t && !t.ended && t.events[0]?.tool === 'Command' && countToolCalls(t.events) === 0) { + if (e.text) t.events[0].output = e.text; + closeTurn(t, e.at, 'local command'); + } + break; + } + case 'interrupt': { + const t = ensure(e.at); + t.events.push({ + id: `stop-${say++}`, + at: e.at, + actor: 'you', + tool: 'Interrupt', + label: 'You stopped Claude', + intent: 'interrupt', + }); + t.interrupted = true; + closeTurn(t, e.at, 'interrupted'); + break; + } + } + } + // A compaction after the last turn (no turn came after it to take it) closes that turn. + if (pendingCompact && turns.length) turns[turns.length - 1].events.push(pendingCompact); + return turns; +} + +const NOT_TOOL_CALLS = new Set(['prompt', 'say', 'interrupt', 'compact', 'notice']); +/** The turn's own tool calls: a second opinion (the advisor) is no step of the work, so it is not counted. */ +const countToolCalls = (events: StoryEvent[]) => + events.filter((e) => !NOT_TOOL_CALLS.has(e.intent) && e.tool !== 'Advisor').length; + +export function summarizeTurn(turn: StoryTurn): StoryTurnSummary { + return { + id: turn.id, + at: turn.at, + prompt: clip(turn.prompt.split('\n')[0] ?? '', 140), + origin: turn.origin, + complete: turn.ended && !turn.interrupted, + interrupted: turn.interrupted, + ...(turn.endedBy === 'next prompt' ? { superseded: true } : {}), + toolCalls: countToolCalls(turn.events), + }; +} + +// ========== Stage 3: panels ========== + +const PANEL_FOR: Record = { + prompt: 'mission', + read: 'investigate', + search: 'investigate', + look: 'investigate', + web: 'research', + plan: 'plan', + say: null, + helper: 'backup', + message: 'backup', + edit: 'fix', + test: 'check', + build: 'check', + run: 'run', + tool: 'run', + save: 'save', + ask: 'ask', + compact: 'compact', + interrupt: null, + notice: null, +}; +/** Kinds whose consecutive events share one panel. A prompt or a question always gets its own. */ +const MERGEABLE = new Set([ + 'investigate', + 'research', + 'plan', + 'backup', + 'fix', + 'check', + 'run', + 'save', + 'compact', +]); +/** Kinds that may be folded into a montage when a page has too many panels. */ +const MINOR = new Set(['investigate', 'research', 'plan', 'run', 'compact', 'montage']); + +interface DraftPanel { + kind: StoryPanelKind; + events: number[]; + balloon?: { who: 'you' | 'claude' | 'note'; text: string }; + rounds?: number; +} + +/** Every edit an event made: an Edit/Write call's one, or the files a Bash call wrote. */ +const editsOf = (e: StoryEvent): StoryEdit[] => (e.edit ? [e.edit] : (e.edits ?? [])); + +/** Keeps the newest outputs, hunks and helper texts within the page budget; true when it cut any. */ +function applyTextBudget(events: StoryEvent[]): boolean { + let used = 0; + let cut = false; + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + const hunkChars = editsOf(e).reduce( + (n, d) => n + d.hunks.reduce((m, h) => m + h.lines.reduce((k, l) => k + l.length, 0), 0), + 0 + ); + const size = (e.output?.length ?? 0) + hunkChars + (e.helper?.result?.length ?? 0) + (e.helper?.prompt.length ?? 0); + if (used + size <= STORY_LIMITS.pageTextBudget) { + used += size; + continue; + } + if (e.output !== undefined) { + delete e.output; + e.outputTruncated = true; + } + if (e.edit && e.edit.hunks.length) e.edit = { ...e.edit, hunks: [], truncated: true }; + if (e.edits?.some((d) => d.hunks.length)) e.edits = e.edits.map((d) => ({ ...d, hunks: [], truncated: true })); + if (e.helper) + e.helper = { + ...e.helper, + prompt: clip(e.helper.prompt, 200), + result: e.helper.result ? clip(e.helper.result, 200) : undefined, + }; + cut = true; + } + return cut; +} + +/** Claude reporting how a check went ("All 7 tests pass"), as opposed to announcing its next step. */ +const TALKS_RESULTS = /\b(pass(es|ed|ing)?|fail(s|ed|ing|ures?)?|green|red|clean|errors?)\b/i; +/** Claude reporting a save ("Commits are in.", "Pushed to origin."). */ +const TALKS_SAVES = /\b(commit(s|ted)?|pushed|merged|tagged)\b/i; +/** The remark that reports on a panel of this kind, if any. */ +const REPORT_TALK: Partial> = { + check: TALKS_RESULTS, + save: TALKS_SAVES, + run: TALKS_RESULTS, +}; +const ANNOUNCES = /\b(I'll|I will|let me|let's|I'm going to|next,? I|now I)\b/i; + +/** What Claude's last words leave open: a catch (a problem), a note (something pending), a question. */ +/** Claude did something differently from what you asked: worth a note on the final panel. */ +const DEVIATED = + /\b(didn't|did not|couldn't|could not) (follow|use|do) (your|exactly what you)|\bnot exactly as you\b/i; + +function closingKinds(points: string[] = []): { openNote?: 'catch' | 'note'; asks?: boolean } { + const kinds = new Set(points.map(keyKindOf)); + return { + ...(kinds.has('problem') + ? { openNote: 'catch' as const } + : kinds.has('pending') || points.some((p) => DEVIATED.test(p)) + ? { openNote: 'note' as const } + : {}), + ...(points.some((p) => keyKindOf(p) === 'offer' && ASKS_YOU.test(p)) ? { asks: true } : {}), + }; +} + +function groupPanels(events: StoryEvent[]): { + panels: DraftPanel[]; + trailingSay?: string; + openNote?: 'catch' | 'note'; + asks?: boolean; +} { + const panels: DraftPanel[] = []; + let pendingSay: string | undefined; + let lastSaid: StoryEvent | undefined; + // A second opinion (the advisor) is not a step of its own (its answer is not readable anyway): it + // rides on the panel of the step after it, or the last one when nothing follows. + let opinions: number[] = []; + const takeOpinions = (panel: DraftPanel) => { + if (!opinions.length) return; + panel.events = [...panel.events, ...opinions].sort((a, b) => a - b); + opinions = []; + }; + events.forEach((ev, idx) => { + if (ev.tool === 'Advisor') { + opinions.push(idx); + return; + } + if (ev.intent === 'say' && !ev.todos) { + pendingSay ??= balloonText(ev.label); + lastSaid ??= ev; + return; + } + lastSaid = undefined; + const kind = ev.todos && ev.intent === 'say' ? 'plan' : PANEL_FOR[ev.intent]; + if (!kind) return; + const last = panels[panels.length - 1]; + if (last && last.kind === kind && MERGEABLE.has(kind)) { + last.events.push(idx); + takeOpinions(last); + if (pendingSay && !last.balloon) last.balloon = { who: 'claude', text: pendingSay }; + } else { + const panel: DraftPanel = { kind, events: [idx] }; + // A sentence about how the check just before went belongs to that check, not to what comes next. + // A check that also committed reports on both: "All 8 pass." and "Committed." + const saves = last?.kind === 'check' && last.events.some((i) => events[i].commits); + const talk = last + ? saves + ? new RegExp(`${TALKS_RESULTS.source}|${TALKS_SAVES.source}`, 'i') + : REPORT_TALK[last.kind] + : undefined; + const reportsBack = + pendingSay && + talk && + last && + (!last.balloon || (last.kind === 'check' && !talk.test(last.balloon.text))) && + talk.test(pendingSay) && + !ANNOUNCES.test(pendingSay); + if (kind === 'mission') panel.balloon = { who: 'you', text: clip(ev.label, 400) }; + else if (reportsBack && last && pendingSay) last.balloon = { who: 'claude', text: pendingSay }; + else if (pendingSay) panel.balloon = { who: 'claude', text: pendingSay }; + takeOpinions(panel); + panels.push(panel); + } + pendingSay = undefined; + }); + if (opinions.length) { + const last = panels[panels.length - 1]; + if (last) takeOpinions(last); + else panels.push({ kind: 'backup', events: opinions }); + } + // The closing words keep a caveat that follows them ("Nothing is committed: this is no git repo."). + return { + panels, + trailingSay: lastSaid !== undefined ? closingText(lastSaid.label, lastSaid.keyPoints) : pendingSay, + // Claude's last words name an open problem, or something not done or checked: no celebration. + ...closingKinds(lastSaid?.keyPoints), + }; +} + +/** Fix → check → fix → check (4 panels or more) is one "loop" panel: try, test, repeat. */ +function collapseLoops(panels: DraftPanel[]): DraftPanel[] { + const out: DraftPanel[] = []; + let i = 0; + while (i < panels.length) { + let j = i; + while ( + j < panels.length && + (panels[j].kind === 'fix' || panels[j].kind === 'check') && + (j === i || panels[j].kind !== panels[j - 1].kind) + ) { + j++; + } + // A loop ends at a check: writes after the last check (docs, config) are their own panel. + while (j > i && panels[j - 1].kind === 'fix') j--; + const run = panels.slice(i, j); + if (run.length >= 4) { + out.push({ + kind: 'loop', + events: run.flatMap((p) => p.events), + // The last word of the rounds is how they ended. + balloon: [...run].reverse().find((p) => p.balloon)?.balloon, + rounds: run.filter((p) => p.kind === 'check').length, + }); + i = j; + } else { + out.push(panels[i]); + i++; + } + } + return out; +} + +/** + * Folds neighbouring panels into montages until the page fits. Quiet kinds fold first, and the + * oldest pair first, growing a montage already there when it can: so on a live page, the panels + * the reader is watching keep their place, and the newest panel is never the one folded while + * another choice exists. + */ +function capPanels(panels: DraftPanel[], max: number): DraftPanel[] { + const out = [...panels]; + const firstMergeable = out[0]?.kind === 'mission' ? 1 : 0; + while (out.length > max) { + let best = -1; + // Four passes: quiet kinds; then anything but a prompt or a question; then a prompt you added + // mid-turn; then, only so the page keeps its 12 panels, a question too (the turn's own prompt + // is never folded). + search: for (const pass of [0, 1, 2, 3]) { + for (const lastStart of [out.length - 3, out.length - 2]) { + let oldest = -1; + for (let k = firstMergeable; k <= lastStart; k++) { + const a = out[k]; + const b = out[k + 1]; + const ok = + pass === 0 + ? MINOR.has(a.kind) && MINOR.has(b.kind) + : pass === 1 + ? ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask') + : ![a, b].some( + (d) => + (pass === 2 && d.kind === 'ask') || (d.kind === 'mission' && d.events[0] === out[0]?.events[0]) + ); + if (!ok) continue; + if (a.kind === 'montage' || b.kind === 'montage') { + best = k; + break search; + } + if (oldest < 0) oldest = k; + } + if (oldest >= 0) { + best = oldest; + break search; + } + } + } + if (best < 0) break; + const a = out[best]; + const b = out[best + 1]; + out.splice(best, 2, { kind: 'montage', events: [...a.events, ...b.events], balloon: a.balloon ?? b.balloon }); + } + return out; +} + +function countBy(events: StoryEvent[], key: (e: StoryEvent) => string): string { + const counts = new Map(); + for (const e of events) counts.set(key(e), (counts.get(key(e)) ?? 0) + 1); + return [...counts].map(([k, n]) => (n > 1 ? `${k} ×${n}` : k)).join(' · '); +} + +/** Lines of a script that say nothing about what it does: `set -e`, a bare `cd dir`, `S=/tmp/x`, comments. */ +const NOISE_LINE = /^\s*(#.*|set\s+[-+][a-zA-Z]+(\s+\w+)?|cd\s+\S+|[A-Za-z_][A-Za-z0-9_]*=\S*)?\s*$/; +/** What a command does, for a caption: the part that decided it, else its first telling line. */ +const shortCommand = (e: StoryEvent) => { + if (e.headline) return clip((e.headline.split('\n')[0] ?? '').replace(/\s*<<-?\s*(['"]?)\w+\1/g, ''), 60); + const lines = e.label.split('\n'); + return clip(lines.find((l) => !NOISE_LINE.test(l)) ?? lines[0] ?? '', 60); +}; + +function montageSummary(evs: StoryEvent[]): string { + const parts: string[] = []; + const files = unique(evs.filter((e) => e.intent === 'read' && e.file).map((e) => e.file as string)).length; + const commands = evs.filter((e) => e.intent === 'run' || e.intent === 'look' || e.intent === 'tool').length; + if (files) parts.push(`reads ${plural(files, 'file')}`); + if (commands) parts.push(`runs ${plural(commands, 'command')}`); + if (evs.some((e) => e.intent === 'plan')) parts.push('updates its plan'); + if (evs.some((e) => e.intent === 'web')) parts.push('checks the web'); + if (evs.some((e) => e.intent === 'edit')) parts.push('changes files'); + if (evs.some((e) => e.intent === 'test' || e.intent === 'build')) parts.push('runs checks'); + const commits = evs.reduce((n, e) => n + (e.commits ?? 0), 0); + if (commits || evs.some((e) => e.intent === 'save')) + parts.push(commits > 1 ? `saves the work in ${commits} commits` : 'saves the work'); + return parts.length ? parts.join(', ') : 'a few more steps'; +} + +/** + * One output line as the terminal card may show it. The card is drawn on the canvas, so it goes into a + * saved image someone may share: a line that looks like a secret is left out, addresses and long + * token-like strings are masked, and Claude Code's own markers go. + */ +function printableLine(line: string): string | null { + const text = line.replace(/<\/?[a-z_]+>/g, '').trim(); + if (!text || /^\(Bash completed with no output\)$/.test(text)) return null; + // Anywhere in the line, inside identifiers too (`API_TOKEN=`, `db_password:`): losing a harmless line is cheap. + if (/token|secret|passw(or)?d|api[_-]?key|authorization|bearer|private[_ -]?key|credential/i.test(text)) return null; + return clip(text.replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '…@…').replace(/[A-Za-z0-9+/=_-]{32,}/g, '…'), 90); +} + +/** What a check other than the tests is, by its tool, for a reader: "a formatting check", "a style check". */ +function checkName(e: StoryEvent | undefined): string { + const cmd = e?.label ?? ''; + if (/\b(ruff format|black|prettier|gofmt|rustfmt|isort|autopep8|biome format)\b/.test(cmd)) + return 'a formatting check'; + if (/\b(ruff|eslint|flake8|pylint|clippy|shellcheck|golangci-lint|stylelint|biome)\b/.test(cmd)) + return 'a style check'; + if (/\b(tsc|mypy|pyright|vue-tsc)\b/.test(cmd)) return 'a type check'; + if (/\b(build|make|cargo build|go build|webpack|vite build|esbuild)\b/.test(cmd)) return 'the build'; + return 'another check'; +} + +/** + * A command whose LAST step answers "no" with exit code 1: grep finds nothing, a git setting is unset, + * `test` is false, a program is not installed. What it printed before that is the answer Claude was + * after, so the panel says so instead of "it fails". Anchored on a separator, so linear. + */ +const EMPTY_ANSWER_STEP = /(?:^|[;&|]\s*)(?:e?grep|fgrep|rg|git config|test|\[|which|command -v)\s[^;&|]*$/; +const emptyAnswer = (e: StoryEvent): boolean => { + const out = e.output ?? ''; + // A usage message or an error is a step that went wrong, not an empty answer. + return ( + e.ok === false && + /^Exit code 1\n\s*\S/.test(out) && + !/^\s*(usage|error|fatal|grep):/im.test(out) && + EMPTY_ANSWER_STEP.test(e.label.trim()) + ); +}; + +/** The first save of a folder Claude made a repository (`git init`, not `--bare`) with nothing committed before. */ +function startsRepoAt(indices: number[], events: StoryEvent[]): boolean { + const earlier = events.slice(0, Math.min(...indices)); + return ( + !earlier.some((e) => (e.commits ?? 0) > 0) && + [...earlier, ...indices.map((i) => events[i])].some( + (e) => e.tool === 'Bash' && /\bgit init\b(?![^;&|\n]*\s--bare\b)/.test(e.label) + ) + ); +} + +/** What the last lookup of an empty answer came back without, in a reader's words. */ +function emptyAnswerWhat(e: StoryEvent): string { + const step = EMPTY_ANSWER_STEP.exec(e.label.trim())?.[0].replace(/^[;&|\s]+/, '') ?? ''; + return /^git config/.test(step) + ? 'the last one finds a git setting that is not set' + : /^(which|command -v)/.test(step) + ? 'the last one finds a program that is not installed' + : /^(test|\[)/.test(step) + ? 'the last test it makes comes out false' + : 'the last search finds no match'; +} + +/** Words in a prompt that ask for (or mention) saving the work: then a save is no surprise. */ +const ASKS_TO_SAVE = + /\b(commit|commits|committed|git|push|pushed|pull request|check(ed)? in|ship|release|merge|version control)\b|\bpr\b|\b(save|saving|saved) (the |your |all |this |my |our )?(work|changes|progress|code|project)\b/i; + +/** Claude saying a project rule asks for the commits ("CLAUDE.md says to commit after every change"). */ +const RULE_SAVE = + /\b(CLAUDE\.md|project('s)? (rules|notes|instructions)|house rules)\b[^.\n]{0,120}\bcommit|\bcommit[^.\n]{0,120}\b(CLAUDE\.md|project('s)? (rules|notes|instructions)|house rules)\b/i; + +/** The branch Claude created for this save (`git checkout -b x`, `git switch -c x`), when it is the first save on it. */ +function newBranchAt(indices: number[], events: StoryEvent[]): string | undefined { + const firstAt = Math.min(...indices); + const lastAt = Math.max(...indices); + for (let i = lastAt; i >= 0; i--) { + const m = + events[i].tool === 'Bash' && + /\bgit (?:checkout|switch)(?:\s+-q)?\s+(?:-b|-c|--create)\s+([\w./-]+)/.exec(events[i].label); + if (!m) continue; + // Nothing committed between the branch and this panel: this is its first save. + return events.slice(i + 1, firstAt).some((e) => (e.commits ?? 0) > 0) ? undefined : m[1]; + } + return undefined; +} + +/** + * Lines added and removed, per file summed: a file created in these edits counts what it ends with + * (its later edits in the same span are its own drafting), so a new file never shows removed lines. + */ +function netCounts(edits: StoryEdit[]): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const file of unique(edits.map((d) => d.file))) { + const mine = edits.filter((d) => d.file === file); + const a = mine.reduce((n, d) => n + d.added, 0); + const r = mine.reduce((n, d) => n + d.removed, 0); + if (mine.some((d) => d.created)) added += Math.max(0, a - r); + else { + added += a; + removed += r; + } + } + return { added, removed }; +} + +/** A file an earlier panel of the turn already wrote or changed: writing it again is changing it. */ +function touchedBefore(file: string, before: StoryEvent[]): boolean { + return before.some((e) => editsOf(e).some((d) => d.file === file)); +} + +/** A command made only of git lookups ("git log --oneline; git status -sb", "git -C origin.git log"). */ +const GIT_LOOK = + /^(?:(?:git (?:-C \S+ |--git-dir=\S+ )?(?:log|status|show|ls-remote|branch|remote|describe|rev-parse|diff)\b[^;&|\n]*|(?:echo|head|tail|wc|sort)\b[^;&|\n]*)\s*(?:&&|\|\||;|\||\n|$)\s*)+$/; + +/** A dry run: a command that shows what it would do without doing it. */ +const DRY_RUN = /\bdry[- ]?run\b|--dry-run\b|--what-if\b/i; + +/** One or two files by name ("calc.py and test_calc.py"), more by count ("3 files"). */ +const fileWords = (files: string[], noun = 'file'): string => + files.length && files.length <= 2 ? files.map((f) => basename(f)).join(' and ') : plural(files.length, noun); + +/** You asked for a failing run first (test first), or Claude said the failure was expected. */ +const PLANNED_IN_PROMPT = + /\b(test[- ]first|tdd|fail(s|ing)? (first|on purpose)|failing tests? first|on purpose|red first|before (the )?(code|module|function|implementation) exists|should fail because|(watch|see) (them|it|the tests?) fail|must (error|fail) (at|on|because|while))\b/i; +const EXPECTED_BY_CLAUDE = + /\b(as expected|as planned|as intended|expected (failure|to fail|red)|deliberately fail\w*|on purpose|test[- ]first|the red state)\b/i; +function plannedFailure(turn: StoryTurn, events: StoryEvent[]): false | 'asked' | 'planned' { + const prompt = events.find((e) => e.intent === 'prompt')?.label ?? turn.prompt; + if (PLANNED_IN_PROMPT.test(prompt)) return 'asked'; + return events.some((e) => e.intent === 'say' && EXPECTED_BY_CLAUDE.test(e.label)) ? 'planned' : false; +} + +// ========== Simple captions ========== +// The Simple reading level is for someone who has never seen a terminal (a parent, a manager). Its +// captions are TEMPLATES ONLY: never a word taken from the transcript (no file name, command, commit +// message, branch or helper name), so no path, code or jargon can reach them. They are written from +// the same facts as the story caption, so a failed check never reads as a pass. + +/** "a file", "3 files". */ +const someFiles = (n: number): string => (n === 1 ? 'a file' : `${n} files`); +/** "a helper", "2 helpers". */ +const someHelpers = (n: number): string => (n === 1 ? 'a helper' : `${n} helpers`); +/** A caption sentence continued after a semicolon: "A check fails." becomes "a check fails.". */ +const lowerFirst = (s: string): string => (s.startsWith('Claude') ? s : `${s[0]?.toLowerCase() ?? ''}${s.slice(1)}`); +/** "a, b and c". */ +const andList = (parts: string[]): string => + parts.length < 2 ? (parts[0] ?? '') : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`; +/** A check other than the tests, in everyday words; a type check or a build is just "another check". */ +const simpleCheckName = (e: StoryEvent | undefined): string => { + const name = checkName(e); + return name === 'a formatting check' || name === 'a style check' ? name : 'another check'; +}; +/** A prompt that keeps the work out of git ("no git", "don't commit"). */ +const NO_SAVE = /\b(no|without|skip|don't use|do not use) git\b|\b(don't|do not|never) commit\b|\bno commits?\b/i; +/** git's own line for a commit it made (`[main 1a2b3c4] message`): the save went through. */ +const COMMIT_MADE = /^\[[^\]\n]+ [0-9a-f]{7,}\]/m; +/** git's own lines for a push that went through. */ +const PUSH_MADE = /\[new branch\]|\S+\.\.\S+\s+\S+\s+->|\bset up to track\b|Everything up-to-date/; + +/** A montage in everyday words: the parts that matter most first, three at most. */ +function simpleMontage(evs: StoryEvent[]): string { + const has = (...intents: StoryIntent[]) => evs.some((e) => intents.includes(e.intent)); + const parts = [ + has('edit') ? 'changes files' : '', + has('test', 'build') ? 'checks the work' : '', + has('save') || evs.some((e) => (e.commits ?? 0) > 0) ? 'saves the work' : '', + has('read', 'search', 'look') ? 'reads files' : '', + has('run', 'tool') ? 'runs some steps' : '', + has('plan') ? 'makes a plan' : '', + has('web') ? 'looks things up' : '', + ].filter(Boolean); + if (!parts.length) return 'takes a few more steps'; + return parts.length > 3 ? `${parts.slice(0, 3).join(', ')}, and more` : andList(parts); +} + +/** + * The final panel in everyday words: how the turn ended and how long it took, how many files changed, + * whether the checks pass, whether the work is saved, and what the reader must not miss. + */ +function simpleEnding(turn: StoryTurn, page: Omit): string { + const events = page.events; + // Rounded to minutes past one, so "about": the chip beside it says "2 min 41 s". + const ms = turn.durationMs; + const took = ms !== undefined && ms >= 1000 ? `${ms >= 60_000 ? 'about ' : ''}${roughDuration(ms)}` : ''; + const running = page.helpersRunning; + const commands = events.filter((e) => e.running && !e.helper).length; + const later = events.filter((e) => e.finishedLater && e.intent !== 'prompt'); + const out: string[] = []; + if (turn.interrupted) out.push(took ? `You stopped Claude after ${took}.` : 'You stopped Claude partway.'); + else if (page.noneWorked) out.push('Nothing Claude tried worked, so the job is not done.'); + else if (turn.endedBy === 'next prompt') out.push('The work stops here; the next message starts something new.'); + else if (running || commands) + out.push( + `Claude's part ${took ? `took ${took}` : 'is done'}, but ${ + running + ? `${running === 1 ? 'a helper is' : `${running} helpers are`} still working` + : 'some work is still running' + }.` + ); + else if (later.length) + out.push( + `Claude's part ${took ? `took ${took}` : 'is done'}; ${ + later.every((e) => e.helper) ? (later.length === 1 ? 'its helper' : 'its helpers') : 'the work it left running' + } finished later.` + ); + else out.push(took ? `Done in ${took}.` : 'Done.'); + + // A report turn's helper wrote its files in its own transcript: "no files changed" would contradict it. + if (page.filesChanged) out.push(`${page.filesChanged === 1 ? 'One file' : `${page.filesChanged} files`} changed.`); + else if (turn.origin === 'human') out.push('No files changed.'); + + // The checks: how the LAST one went, failed counting a run that reports failed tests. + const checks = events.filter((e) => e.intent === 'test' || e.intent === 'build'); + const last = checks[checks.length - 1]; + if (last && (last.running || last.ok === undefined)) out.push('A check is still running.'); + else if (last && last.ok && !(last.tests && last.tests.failed > 0)) out.push('The checks pass.'); + else if (last) { + // "As you asked" only while no run has passed yet: a check failing after one passed is a problem. + const planned = checks.some((e) => e.ok && !(e.tests && e.tests.failed > 0)) ? false : plannedFailure(turn, events); + out.push( + planned === 'asked' + ? 'The checks fail, as you asked.' + : planned + ? 'The checks fail, as planned.' + : 'A check still fails.' + ); + } else if (page.filesChanged) { + // No check, and nothing run after the first change either (a practice run counts as trying it). + const firstEditAt = events.findIndex((e) => editsOf(e).length > 0); + const tried = events.some((e, i) => i > firstEditAt && e.tool === 'Bash' && e.intent === 'run'); + if (!tried) out.push('Claude did not try out its changes.'); + } + + // Saved: a commit or an upload that went through, whatever came after it in the same command. + const savedAt = events.reduce( + (at, e, i) => + ((e.commits ?? 0) > 0 && (e.ok !== false || COMMIT_MADE.test(e.output ?? ''))) || + (e.ships && (e.ok !== false || PUSH_MADE.test(e.output ?? ''))) + ? i + : at, + -1 + ); + const lastEditAt = events.reduce((at, e, i) => (editsOf(e).length ? i : at), -1); + if (savedAt >= 0 && lastEditAt > savedAt) out.push('Some of the work is saved, but the latest changes are not.'); + else if (savedAt >= 0) out.push('The work is saved.'); + // "Not saved" only where saving is in play (you asked for it, or the project keeps its history in + // git): in a folder you said to keep out of git, files written are simply there. + else if ( + page.filesChanged && + (events.some((e) => e.intent === 'prompt' && ASKS_TO_SAVE.test(e.label) && !NO_SAVE.test(e.label)) || + events.some((e) => e.tool === 'Bash' && /\bgit\b/.test(e.label))) + ) + out.push('The work is not saved yet.'); + + // What the reader must not miss: Claude's own words hold the details, one click away. + if (page.openNote === 'catch') out.push('Claude says a problem is left: read its message.'); + else if (page.openNote === 'note') out.push('Claude left you a note: read its message.'); + if (page.asks) out.push('Claude has a question for you.'); + if (turn.interrupted) { + const todos = [...events].reverse().find((e) => e.todos?.length)?.todos; + if (todos?.some((t) => !t.done)) out.push('Some of its plan is not done yet.'); + } + return out.join(' '); +} + +function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, index: number): StoryPanel { + const all = draft.events.map((i) => events[i]); + // Second opinions riding on this step are counted, never part of its story. + const opinions = all.filter((e) => e.tool === 'Advisor').length; + const evs = opinions && opinions < all.length ? all.filter((e) => e.tool !== 'Advisor') : all; + const panel: StoryPanel = { + kind: draft.kind, + events: draft.events, + caption: { story: '', director: '', simple: '' }, + }; + if (evs !== all) panel.secondOpinion = opinions; + if (draft.balloon) panel.balloon = draft.balloon; + const edits = evs.flatMap(editsOf); + const checks = evs.filter((e) => e.intent === 'test' || e.intent === 'build'); + const lastCheck = checks[checks.length - 1]; + // A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0). + const passed = (e: StoryEvent | undefined): boolean | undefined => + e?.ok === undefined ? undefined : e.ok && !(e.tests && e.tests.failed > 0); + const outcome = (e: StoryEvent) => { + const ok = passed(e); + return ok === undefined ? 'running' : ok ? 'ok' : 'error'; + }; + const errors = evs.filter((e) => e.ok === false).length; + const notesOf = (list: StoryEvent[]) => + list + .map((e) => clip(e.description || shortCommand(e), 80)) + .filter(Boolean) + .slice(0, 4); + const tools = countBy(evs, (e) => e.tool); + let story = ''; + let director = tools; + // The Simple level's sentence, from the same facts (templates only: see "Simple captions"). + let simple = ''; + + switch (draft.kind) { + case 'mission': { + const words = evs[0]?.label.split(/\s+/).filter(Boolean).length ?? 0; + const lines: Record = { + human: 'You give Claude a job.', + task: 'A background job reports back.', + peer: 'Another session sends a message.', + scheduled: 'A scheduled task starts this turn.', + system: 'Something automatic starts this turn.', + }; + const origin = evs[0]?.origin ?? turn.origin; + const command = evs[0]?.tool === 'Command' ? evs[0].label.split(/\s/)[0] : ''; + story = command + ? `You run ${command}.` + : index === 0 + ? lines[turn.origin] + : origin === 'human' + ? 'You add something while Claude works.' + : origin === 'peer' + ? 'Another session chimes in while Claude works.' + : lines[origin]; + const plainly: Record = { + human: 'You give Claude a job.', + task: 'A job Claude left running reports back.', + peer: 'Another Claude sends a message.', + scheduled: 'A job set up ahead of time starts this work.', + system: 'Claude picks up the work by itself.', + }; + simple = command + ? 'You give Claude a short instruction.' + : index === 0 + ? plainly[turn.origin] + : origin === 'human' + ? 'You add something while Claude works.' + : origin === 'peer' + ? 'Another Claude chimes in while this one works.' + : plainly[origin]; + // Words that started the turn but are not yours are a note, not your balloon. + if (panel.balloon && !command && origin !== 'human') panel.balloon = { ...panel.balloon, who: 'note' }; + // A helper reporting back: its own words, not only the line saying it finished, so what it + // did not do ("I haven't run them against the real file") is on the page. + const report = origin === 'task' && evs[0]?.helper?.result?.trim(); + if (panel.balloon && report) { + // A report that is only a path says which file it wrote: its name is enough. + const pathOnly = /^\S+$/.test(report) && report.includes('/'); + const said = (pathOnly ? basename(report) : closingText(report, keyPointsOf(report), ' … ')) + // An absolute path is mostly room: its last two parts ("tests/test_extra.py") say which file. + // Run on the clipped balloon (560 characters at most), so the scan stays short. + .replace(/(? (e.intent === 'read' && e.file ? [e.file] : e.intent === 'look' ? (e.files ?? []) : [])) + ); + panel.files = files.slice(0, 12); + // Codeman's own case file is plumbing, not part of the story. + panel.files = panel.files.filter((f) => !/(^|\/)\.codeman[-.]/.test(f)); + panel.notes = evs + .filter((e) => e.intent !== 'read') + .map((e) => e.description || shortCommand(e)) + .slice(0, 4); + if (evs.every((e) => e.tool === 'Bash')) director = `${tools} · ${shortCommand(evs[0])}`; + if (errors) director += ` · ${plural(errors, 'error')}`; + const shown = files.filter((f) => !/(^|\/)\.codeman[-.]/.test(f)); + // A file or two by name; in a turn a report started, reading is checking what came back. + const checking = turn.origin === 'task' || turn.origin === 'peer'; + // Only git lookups (log, status, ls-remote) after a save in this turn: Claude checks its save. + const savedBefore = events.slice(0, Math.min(...draft.events)).some((e) => (e.commits ?? 0) > 0 || !!e.ships); + const named = shown.map((f) => basename(f)); + story = evs.every((e) => e.tool === 'ToolSearch') + ? evs.some((e) => /PlanMode/.test(e.label)) + ? 'Claude gets ready to plan.' + : 'Claude gets the tools it needs ready.' + : shown.length && shown.length <= 2 + ? `Claude ${checking ? 'checks' : 'reads'} ${named.join(' and ')}.` + : shown.length + ? `Claude reads ${plural(shown.length, 'file')} to find its way around.` + : evs.every((e) => e.intent === 'search') + ? 'Claude searches the code.' + : savedBefore && evs.every((e) => e.tool === 'Bash' && GIT_LOOK.test(e.label.trim())) + ? 'Claude checks what it saved.' + : checking + ? 'Claude checks the work that came back.' + : 'Claude looks around the project.'; + simple = evs.every((e) => e.tool === 'ToolSearch') + ? evs.some((e) => /PlanMode/.test(e.label)) + ? 'Claude gets ready to plan.' + : 'Claude gets ready for the job.' + : shown.length && shown.length <= 2 + ? checking + ? 'Claude checks the work that came back.' + : shown.length === 1 + ? 'Claude reads a file from the project.' + : 'Claude reads two files from the project.' + : shown.length + ? `Claude reads ${shown.length} files to find its way around.` + : evs.every((e) => e.intent === 'search') + ? 'Claude searches the project.' + : savedBefore && evs.every((e) => e.tool === 'Bash' && GIT_LOOK.test(e.label.trim())) + ? 'Claude checks what it saved.' + : checking + ? 'Claude checks the work that came back.' + : 'Claude looks around the project.'; + // A tool lookup's query (`select:EnterPlanMode,ExitPlanMode`) means nothing to a reader. + if (evs.every((e) => e.tool === 'ToolSearch')) panel.notes = []; + break; + } + case 'research': + panel.notes = evs.map(shortCommand).slice(0, 4); + story = 'Claude looks things up on the web.'; + simple = story; + break; + case 'plan': { + const todos = [...evs].reverse().find((e) => e.todos)?.todos; + if (todos) panel.todos = todos; + else panel.notes = evs.map(shortCommand).slice(0, 4); + story = todos + ? `Claude writes a plan: ${plural(todos.length, 'step')}.` + : evs.every((e) => e.tool === 'EnterPlanMode') + ? 'Claude switches to plan mode to think it through.' + : 'Claude thinks it through.'; + simple = todos + ? `Claude makes a plan with ${plural(todos.length, 'step')}.` + : evs.every((e) => e.tool === 'EnterPlanMode') + ? 'Claude stops to plan before it acts.' + : 'Claude thinks it through.'; + // A plan written as text has no progress to report; a plan tool's list does. + if (todos && evs.every((e) => e.tool === 'Text')) director = `written as text · ${plural(todos.length, 'step')}`; + else if (todos && evs.some((e) => e.file && PLAN_FILE.test(e.file))) + director = `${tools} · plan file · ${plural(todos.length, 'step')}`; + else if (todos) director = `${tools} · ${todos.filter((t) => t.done).length}/${todos.length} done`; + break; + } + case 'backup': { + // A second opinion riding on this panel is no helper of its own. + const helpers = draft.events + .filter((i) => events[i].helper && (!panel.secondOpinion || events[i].tool !== 'Advisor')) + .map((i) => ({ + name: (events[i].helper as StoryHelper).name, + type: (events[i].helper as StoryHelper).type, + task: (events[i].helper as StoryHelper).task, + event: i, + })); + panel.helpers = helpers; + panel.notes = evs + .filter((e) => e.intent === 'message') + .map(shortCommand) + .slice(0, 4); + const behind = evs.filter((e) => e.helper?.background).length; + const advisor = evs.filter((e) => e.tool === 'Advisor').length; + story = helpers.length + ? advisor === helpers.length + ? evs.every((e) => e.tool !== 'Advisor' || e.ok === false) + ? 'Claude asks a stronger AI model for a second opinion, but gets no answer.' + : 'Claude asks a stronger AI model for a second opinion.' + : behind === helpers.length + ? `Claude sends ${plural(helpers.length, 'helper')} off to work in the background.` + : `Claude calls in ${plural(helpers.length, 'helper')}.` + : 'Claude sends a message to another session.'; + simple = helpers.length + ? advisor === helpers.length + ? evs.every((e) => e.tool !== 'Advisor' || e.ok === false) + ? 'Claude asks a stronger AI for advice, but no answer comes.' + : 'Claude asks a stronger AI for advice.' + : behind === helpers.length + ? `Claude sends ${someHelpers(helpers.length)} off to work while it carries on.` + : `Claude calls in ${someHelpers(helpers.length)}.` + : 'Claude sends a message to another Claude.'; + const inside = evs.reduce((n, e) => n + (e.helper?.toolUses ?? 0), 0); + const types = unique(helpers.map((h) => h.type).filter((t) => t !== 'advisor')); + director = `${tools}${advisor ? ' · a second opinion' : ''}${types.length ? ` · ${types.join(' + ')}` : ''}${inside ? ` · ${plural(inside, 'call')} inside` : ''}`; + break; + } + case 'fix': + case 'loop': { + // From the calls, not only the results: an edit still running already names its file. + const files = unique([ + ...evs.filter((e) => e.intent === 'edit' && e.file).map((e) => e.file as string), + ...edits.map((d) => d.file), + ]); + panel.files = files.slice(0, 12); + ({ added: panel.added, removed: panel.removed } = netCounts(edits)); + if (draft.kind === 'fix') { + const created = unique(edits.filter((e) => e.created).map((e) => e.file)).length; + // Some files written whole (new ones), others patched: "writes README.md and changes CLAUDE.md". + // A file created here is written, even when Claude then edited it in the same panel; one an + // earlier panel already wrote is changed, even when this panel rewrites it whole. + const before = events.slice(0, Math.min(...draft.events)); + const wholeOf = (f: string) => { + const mine = edits.filter((d) => d.file === f); + return ( + mine.some((d) => d.created) || + (!touchedBefore(f, before) && + mine.every((d) => d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines))) + ); + }; + const wholeList = files.filter(wholeOf); + const mixed = + wholeList.length && wholeList.length < files.length + ? { whole: wholeList, patched: files.filter((f) => !wholeList.includes(f)) } + : undefined; + const whole = + edits.length > 0 && + edits.every((d) => d.created || (d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines))); + story = + created && created === files.length + ? files.length <= 2 + ? `Claude writes the new ${files.length === 1 ? 'file' : 'files'} ${fileWords(files)}.` + : `Claude writes ${plural(created, 'new file')}.` + : whole && files.length && wholeList.length === files.length + ? `Claude writes ${fileWords(files)}.` + : mixed + ? `Claude writes ${fileWords(mixed.whole)} and changes ${fileWords(mixed.patched)}.` + : files.length + ? `Claude changes ${fileWords(files)}.` + : 'Claude changes a file.'; + simple = + created && created === files.length + ? files.length === 1 + ? 'Claude writes a new file.' + : `Claude writes ${files.length} new files.` + : whole && files.length && wholeList.length === files.length + ? `Claude writes ${someFiles(files.length)}.` + : mixed + ? `Claude writes ${someFiles(mixed.whole.length)} and changes ${someFiles(mixed.patched.length)}.` + : `Claude changes ${someFiles(Math.max(1, files.length))}.`; + const perFile = files.slice(0, 3).map((f) => { + const mine = netCounts(edits.filter((e) => e.file === f)); + return `${basename(f)} ${fmtCounts(mine.added, mine.removed)}`; + }); + director = `${tools}${perFile.length ? ` · ${perFile.join(' · ')}` : ''}`; + } else { + panel.rounds = draft.rounds ?? checks.length; + const ok = passed(lastCheck); + if (ok !== undefined) panel.ok = ok; + const tests = lastCheck?.tests; + if (tests) panel.tests = tests; + // Every test run's verdict, so a run that failed (often the one you asked for) is not lost, + // even inside a round that passed in the end. + const runs = checks.map(passed); + // A run still going is null, so a live loop already shows the runs that finished. + if (runs.some((r) => r !== undefined)) panel.roundsOk = runs.map((r) => r ?? null); + const failed = checks.slice(0, -1).filter((e) => passed(e) === false); + const fails = failed.length; + // A failure where the tests passed and only a linter or formatter complained. + const lintFails = failed.filter( + (e) => !!e.tests && e.tests.passed > 0 && !e.tests.failed && !e.tests.errors + ).length; + const n = panel.rounds; + const testFails = fails - lintFails; + // A run where not one test loaded (a collection error) "doesn't even load", as a check panel says it. + const loadsNot = (e: StoryEvent) => !!e.tests && !e.tests.passed && !e.tests.failed && !!e.tests.errors; + // A failure you asked for (test first), or that Claude said it expected, is the plan working. + const planned = passed(checks[0]) === false ? plannedFailure(turn, events) : false; + // The check that failed while the tests passed, by name: "a formatting check", not "another check". + const otherCheck = checkName( + failed.find((e) => !!e.tests && e.tests.passed > 0 && !e.tests.failed && !e.tests.errors) + ); + story = + panel.ok === true && !fails + ? `Build and test, ${plural(n, 'round')}. Every check passes.` + : panel.ok === true && fails === lintFails + ? `Build and test, ${plural(n, 'round')}. The tests pass every time; ${otherCheck} fails ${fails === 1 ? 'once' : `${fails} times`}, then passes.` + : panel.ok === true && planned === 'asked' + ? checks.length === 2 + ? loadsNot(checks[0]) + ? "At first the tests don't even load, as you asked; the second run passes." + : 'The first run fails on purpose, as you asked; the second passes.' + : `${plural(n, 'round')}: the first run fails on purpose, as you asked, then it passes.` + : panel.ok === true && planned && checks.length === 2 + ? 'Test first: the first run fails as planned, the second passes.' + : panel.ok === true && planned + ? `Test first, ${plural(n, 'round')}: the first run fails as planned, then it passes.` + : panel.ok === true && checks.length === 2 && fails === 1 + ? 'Fix and test: the first run fails, the second passes.' + : panel.ok === true && testFails && lintFails + ? `Fix and test, ${plural(n, 'round')}: ${testFails === 1 ? 'a test fails' : `tests fail ${testFails} times`} and Claude fixes it, then ${otherCheck} fails and Claude fixes that; everything passes.` + : panel.ok === true + ? `Fix and test, ${plural(n, 'round')}: ${fails === 1 ? 'one run fails' : `${fails} runs fail`}, then it passes.` + : panel.ok === false && tests && tests.passed && !tests.failed && !tests.errors + ? `Fix and test, ${plural(n, 'round')}. The tests pass, but another check fails.` + : panel.ok === false + ? `Fix and test, ${plural(n, 'round')}. Still failing.` + : `Fix and test, ${plural(n, 'round')}.`; + simple = + panel.ok === true && !fails + ? 'Claude changes the work and checks it: everything passes.' + : panel.ok === true && fails === lintFails + ? `${simpleCheckName(failed.find((e) => !!e.tests && e.tests.passed > 0 && !e.tests.failed && !e.tests.errors)).replace(/^a/, 'A')} fails at first; Claude fixes it, and everything passes.` + : panel.ok === true && planned === 'asked' + ? checks.length === 2 && loadsNot(checks[0]) + ? "At first the checks can't even start, as you asked; then they pass." + : 'The first check fails on purpose, as you asked, then passes.' + : panel.ok === true && planned + ? 'The first check fails, as planned, then everything passes.' + : panel.ok === true && checks.length === 2 && fails === 1 + ? 'The first check fails; Claude fixes it, and the second passes.' + : panel.ok === true + ? 'Some checks fail at first; Claude fixes them until all pass.' + : panel.ok === false && tests && tests.passed && !tests.failed && !tests.errors + ? 'Claude fixes and checks the work, but one check still fails.' + : panel.ok === false + ? 'Claude keeps fixing and checking, but it still fails.' + : 'Claude fixes and checks the work.'; + // Commits made inside the loop (a test one-liner that ends in one) are part of its story. + const saved = evs.reduce((k, e) => k + (e.commits ?? 0), 0); + if (saved) + story += ` Claude ${startsRepoAt(draft.events, events) ? 'starts a git repository and ' : ''}saves the work${ + saved > 1 ? ` in ${saved} commits` : '' + } along the way.`; + if (saved) simple += ' It also saves the work.'; + director = `${plural(checks.length, 'check')} · ${plural(edits.length, 'edit')} · ${fmtCounts(panel.added, panel.removed)} · last: ${ + lastCheck ? outcome(lastCheck) : 'running' + }`; + } + break; + } + case 'check': { + const tests = [...checks].reverse().find((e) => e.tests)?.tests; + if (tests) panel.tests = tests; + const ok = passed(lastCheck); + if (ok !== undefined) panel.ok = ok; + panel.notes = notesOf(checks); + const isTest = checks.some((e) => e.intent === 'test'); + const failedFirst = checks.slice(0, -1).some((e) => passed(e) === false); + if (lastCheck?.running) + story = isTest ? 'Claude starts the tests in the background.' : 'Claude starts the checks in the background.'; + // Finished in the background: not tests that ran during this turn, and said when it was later. + else if (lastCheck?.background && ok !== undefined) + story = lastCheck.finishedLater + ? `Claude starts the ${isTest ? 'tests' : 'checks'} in the background; later they ${ok ? 'pass' : 'fail'}.` + : `The ${isTest ? 'tests' : 'checks'} run in the background and ${ok ? 'pass' : 'fail'}.`; + else if (ok === undefined) story = isTest ? 'Claude runs the tests.' : 'Claude runs the checks.'; + else if (!ok && tests && !tests.passed && !tests.failed && tests.errors) story = "The tests don't even load."; + else if (!ok && tests && tests.passed && !tests.failed && !tests.errors) + story = 'The tests pass, but another check fails.'; + else if (ok) + story = failedFirst ? 'It fails at first, then passes.' : isTest ? 'The tests pass.' : 'The checks pass.'; + else + story = isTest + ? tests && tests.failed > 1 + ? `${tests.failed} tests fail.` + : 'A test fails.' + : 'A check fails.'; + const plannedCheck = ok === false ? plannedFailure(turn, events) : false; + if (plannedCheck) + story = `${story.replace(/[.]$/, '')}, ${plannedCheck === 'asked' ? 'as you asked' : 'as planned'}.`; + // The same verdicts in everyday words ("checks", never tests, builds or linters). + if (lastCheck?.running) simple = 'Claude starts a check that runs on its own.'; + else if (lastCheck?.background && ok !== undefined) + simple = lastCheck.finishedLater + ? `Claude starts a check that runs on its own; later it ${ok ? 'passes' : 'fails'}.` + : `A check runs on its own and ${ok ? 'passes' : 'fails'}.`; + else if (ok === undefined) simple = 'Claude checks the work.'; + else if (!ok && tests && !tests.passed && !tests.failed && tests.errors) simple = "The checks can't even start."; + else if (!ok && tests && tests.passed && !tests.failed && !tests.errors) + simple = 'The main checks pass, but another one fails.'; + else if (ok) + simple = failedFirst ? 'A check fails at first, then passes.' : 'Claude checks the work: everything passes.'; + else + simple = + tests && tests.failed > 1 + ? `Claude checks the work: ${tests.failed} checks fail.` + : 'Claude checks the work: one check fails.'; + if (plannedCheck) + simple = `${simple.replace(/[.]$/, '')}, ${plannedCheck === 'asked' ? 'as you asked' : 'as planned'}.`; + // The same command with the same outcome twice in a row reads once, with a count. + const runs: Array<{ text: string; n: number }> = []; + for (const e of checks) { + const text = `${shortCommand(e)} → ${outcome(e)}`; + const prev = runs[runs.length - 1]; + if (prev?.text === text) prev.n++; + else runs.push({ text, n: 1 }); + } + director = runs + .slice(-3) + .map((r) => (r.n > 1 ? `${r.text} ×${r.n}` : r.text)) + .join(' · '); + if (tests) + director += ` · ${tests.passed} passed${tests.failed ? `, ${tests.failed} failed` : ''}${ + tests.errors ? `, ${plural(tests.errors, 'error')}` : '' + }`; + if (edits.length) director += ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}`; + // Tests run in a fresh copy (`git clone remote.git "$C" && (cd "$C" && pytest)`): the copy is what passed. + const cloned = /\bgit clone\b(?:\s+-{1,2}[\w-]+)*\s+["']?([^\s"';&|]+)/.exec(lastCheck?.label ?? '')?.[1]; + if (cloned && /^The (tests|checks) (pass|fail)\w*/.test(story)) + story = story.replace( + /^The (tests|checks) (pass|fail)(\w*)/, + `The $1 $2$3 in a fresh copy made from ${basename(cloned.replace(/\/+$/, ''))}` + ); + const written = unique(edits.map((d) => d.file)); + // Whole files are written; a file it patched is changed (a later panel often patches one written earlier). + const isWhole = (d: StoryEdit) => + d.created || (d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines)); + const earlier = events.slice(0, Math.min(...draft.events)); + const wholeFiles = written.filter( + (f) => + edits.filter((d) => d.file === f).some((d) => d.created) || + (!touchedBefore(f, earlier) && edits.filter((d) => d.file === f).every(isWhole)) + ); + const patched = written.filter((f) => !wholeFiles.includes(f)); + const did = [ + wholeFiles.length ? `writes ${fileWords(wholeFiles)}` : '', + patched.length ? `changes ${fileWords(patched)}` : '', + ].filter(Boolean); + if (did.length) story = `Claude ${did.join(' and ')}; ${story[0].toLowerCase()}${story.slice(1)}`; + // The files by count only, one verb (a new file is a change too): the sentence stays short. + const didPlainly = written.length ? `${patched.length ? 'changes' : 'writes'} ${someFiles(written.length)}` : ''; + if (didPlainly) + simple = simple.startsWith('Claude ') + ? `Claude ${didPlainly}, then ${simple.slice(7)}` + : `Claude ${didPlainly}; ${lowerFirst(simple)}`; + const saved = checks.reduce((n, e) => n + (e.commits ?? 0), 0); + if (saved) { + director += ` · ${plural(saved, 'commit')}`; + if (ok) + story = `${story.slice(0, -1)}, ${startsRepoAt(draft.events, events) ? 'then Claude starts a git repository and' : 'and Claude'} saves the work${ + newBranchAt(draft.events, events) ? ` on a new branch (${newBranchAt(draft.events, events)})` : '' + }${checks.some((e) => e.ships === 'push') ? ' and sends it up' : ''}.`; + if (ok) simple += ' Then it saves the work.'; + } + break; + } + case 'run': { + panel.notes = notesOf(evs); + // What it printed, so a dry run or a listing you asked to see is on the page, not only in the drawer. + // A dry run in the panel is what the card shows, whatever ran after it (edge cases, a clean-up). + const dryRun = evs.find( + (e) => e.output?.trim() && !e.running && e.ok !== false && DRY_RUN.test(`${e.description ?? ''} ${e.label}`) + ); + const lastOut = dryRun ?? [...evs].reverse().find((e) => e.output?.trim() && !e.running); + if (lastOut?.output) { + const lines = lastOut.output + .split('\n') + .map(printableLine) + .filter((l): l is string => !!l); + // Of a dry run, the lines that show what it would do ("a.jpg -> holiday_a.jpg"). + const shows = dryRun ? lines.filter((l) => /->|→|\bwould\b|dry[- ]?run\]/i.test(l)) : []; + const picked = shows.length ? shows.slice(0, 3) : lastOut.ok === false ? lines.slice(-2) : lines.slice(0, 3); + if (picked.length) panel.printed = picked; + } + story = evs.every((e) => e.running) + ? `Claude starts ${evs.length === 1 ? 'a command' : `${evs.length} commands`} in the background.` + : errors && errors === evs.length && evs.every(emptyAnswer) + ? `Claude looks a few things up; ${emptyAnswerWhat(evs[evs.length - 1])}, which is no error.` + : errors && errors === evs.length + ? `Claude tries ${evs.length === 1 ? 'a command' : `${evs.length} commands`}, and ${ + evs.some((e) => /\bblocked\b|PreToolUse|denied/i.test(e.output ?? '')) + ? 'it is blocked' + : evs.length === 1 + ? 'it fails' + : 'they fail' + }.` + : evs.every((e) => e.tool !== 'Bash') + ? 'Claude uses a tool.' + : dryRun + ? events.some((e) => e.intent === 'prompt' && DRY_RUN.test(e.label)) + ? 'Claude does the dry run you asked for: it shows what would happen, changing nothing.' + : 'Claude does a dry run: it shows what would happen, changing nothing.' + : 'Claude works in the terminal.'; + const one = evs.length === 1; + simple = evs.every((e) => e.running) + ? 'Claude starts something that keeps running on its own.' + : errors && errors === evs.length && evs.every(emptyAnswer) + ? 'Claude looks something up and finds nothing, which is fine.' + : errors && errors === evs.length + ? `Claude tries ${one ? 'a step' : 'some steps'}, but ${ + evs.some((e) => /\bblocked\b|PreToolUse|denied/i.test(e.output ?? '')) + ? `${one ? 'it is' : 'they are'} not allowed` + : one + ? 'it fails' + : 'they fail' + }.` + : evs.every((e) => e.tool !== 'Bash') + ? 'Claude uses one of its tools.' + : dryRun + ? events.some((e) => e.intent === 'prompt' && DRY_RUN.test(e.label)) + ? 'Claude does the practice run you asked for; nothing is changed.' + : 'Claude does a practice run; nothing is changed.' + : 'Claude runs some steps on the computer.'; + director = `${tools} · ${shortCommand(evs[0])}${errors ? ` · ${plural(errors, 'error')}` : ''}`; + break; + } + case 'save': { + panel.notes = notesOf(evs); + const last = evs[evs.length - 1]; + if (last?.ok !== undefined) panel.ok = last.ok; + const ships = evs.some((e) => e.ships === 'pr') ? 'pr' : evs.some((e) => e.ships === 'push') ? 'push' : ''; + // The command failed, but its own output shows the push (or the commit) went through: a step + // chained after it failed, not the save. + const out = last?.output ?? ''; + const savedAnyway = + last?.ok === false && + (ships === 'push' + ? /\[new branch\]|\S+\.\.\S+\s+\S+\s+->|\bset up to track\b|Everything up-to-date/.test(out) + : /^\[[^\]\n]+ [0-9a-f]{7,}\]/m.test(out)); + // `git push && git describe; echo "exit=$?"` exits 0, but printed the failure after the push. + const pushedAt = ships === 'push' ? out.search(/\[new branch\]|\S+\.\.\S+\s+\S+\s+->|Everything up-to-date/) : -1; + const hiddenFail = + last?.ok === true && pushedAt >= 0 && /^(fatal|error): |\bexit[= ][1-9]\d*\b/m.test(out.slice(pushedAt)); + if (savedAnyway) panel.ok = true; + const commits = evs.reduce((n, e) => n + (e.commits ?? 0), 0); + const inCommits = commits > 1 ? ` in ${commits} commits` : ''; + // A push of commits made earlier: nothing is saved here, only uploaded. + const uploadOnly = ships === 'push' && !commits; + // A push to a folder on this computer (a repository beside the project, `To ./origin.git`) is a + // copy, not an upload: nothing left the machine. + const target = /^To (\S+)/m.exec(evs.map((e) => e.output ?? '').join('\n'))?.[1]; + const localTarget = + target && (/^file:\/\//.test(target) || !/:/.test(target)) ? basename(target.replace(/\/+$/, '')) : ''; + const uploads = (what: string) => + localTarget ? `copies ${what} to ${localTarget}, a folder on this computer` : `uploads ${what}`; + // One commit: its message says what was saved. + const subject = commits === 1 ? evs.find((e) => e.subject)?.subject : undefined; + // Its conventional prefix (`feat:`, `chore(deps):`) is for git, not for the reader. + const plainSubject = subject?.replace(/^[a-z]+(\([^)]*\))?!?:\s*/i, ''); + const said = plainSubject ? `: "${clip(plainSubject, 60).replace(/[.]$/, '')}"` : ''; + // What came after the save in that command: only looking ("git log", "git status") makes it a check. + const ship = (last?.label ?? '').search(ships === 'push' ? /\bgit push\b/ : /\bgit commit\b/); + const after = + ship < 0 + ? [] + : (last?.label ?? '') + .slice(ship) + .split(/&&|\|\||;|\n/) + .slice(1) + .map((s) => s.trim()) + .filter(Boolean); + const looksOnly = + after.length > 0 && + after.every((s) => + /^(echo|printf|ls|cat|head|tail|wc|git (-C \S+ )?(log|status|remote|ls-remote|show|diff|branch|rev-parse|describe))\b/.test( + s + ) + ); + // The prompt named that step and said it may fail ("git describe may fail, that is fine"). + const asked = events + .filter((e) => e.intent === 'prompt') + .map((e) => e.label) + .join('\n'); + const allowed = + after.some((s) => { + const head = s.split(/\s+/).slice(0, 2).join(' '); + return head.length > 3 && asked.includes(head); + }) && + /\b(may|might|can|could|will) (fail|error)\b|\bexpected to fail\b|\b(fine|ok|okay) if it fails\b|\b(that is|that's) fine\b/i.test( + asked + ); + // `git describe` with no tags: a lookup of a version name, not a check of the work. + const noTags = /No names found, cannot describe anything|No tags can describe/.test(out); + // The failed check is run again in Claude's next command, after a change, and passes: it was fixed + // (`git -C remote.git symbolic-ref HEAD refs/heads/main && git -C remote.git log …`). + const lookSegs = after.filter((seg) => !/^(echo|printf)\b/.test(seg)); + const nextRun = events.slice(Math.max(...draft.events) + 1).find((e) => e.tool === 'Bash'); + let fixedNext = false; + if (looksOnly && !noTags && nextRun?.ok === true) { + const segs = nextRun.label + .split(/&&|\|\||;|\n/) + .map((seg) => seg.trim()) + .filter(Boolean); + const at = segs.findIndex((seg) => lookSegs.includes(seg)); + fixedNext = at > 0 && segs.slice(0, at).some((seg) => !GIT_LOOK.test(seg) && !/^(echo|printf|cd)\b/.test(seg)); + } + const stepAfter = `${ + noTags + ? 'the version-tag lookup after that fails (there are no tags yet)' + : looksOnly + ? 'a check after that fails' + : 'a step after that fails' + }${allowed ? ', as you said it might' : ''}${fixedNext ? '; Claude fixes it, and the check passes next' : ''}`; + story = + savedAnyway || hiddenFail + ? uploadOnly + ? `Claude ${uploads('the work')}, but ${stepAfter}.` + : ships === 'push' + ? `Claude saves the work${inCommits}${said} and ${uploads('it')}, but ${stepAfter}.` + : `Claude saves the work${inCommits}${said}, but ${stepAfter}.` + : panel.ok === false + ? ships === 'pr' + ? 'Claude tries to open a pull request, and the command fails.' + : ships === 'push' + ? 'Claude tries to upload the work, and the command fails.' + : 'Claude tries to save the work, and the command fails.' + : ships === 'pr' + ? 'Claude opens a pull request.' + : uploadOnly + ? `Claude ${uploads('the work')}.` + : ships === 'push' + ? localTarget && said + ? `Claude saves the work${said}, then ${uploads('it')}.` + : `Claude saves the work${inCommits} and ${uploads('it')}${said}.` + : `Claude saves the work${inCommits}${said}.`; + const shipPlainly = uploadOnly + ? localTarget + ? 'copies the work to another folder' + : 'uploads the work' + : ships === 'push' + ? localTarget + ? 'saves the work and copies it to another folder' + : 'saves the work and uploads it' + : 'saves the work'; + simple = + savedAnyway || hiddenFail + ? `Claude ${uploadOnly ? shipPlainly : 'saves the work'}, but ${ + noTags ? 'a small lookup' : looksOnly ? 'a check' : 'a step' + } after that fails${allowed ? ', as you said it might' : ''}${fixedNext ? '; Claude fixes it' : ''}.` + : panel.ok === false + ? ships === 'pr' + ? 'Claude tries to send the work for review, but it fails.' + : ships === 'push' + ? 'Claude tries to upload the work, but it fails.' + : 'Claude tries to save the work, but it fails.' + : ships === 'pr' + ? 'Claude sends the work to be reviewed.' + : `Claude ${shipPlainly}.`; + const wrote = unique(edits.map((d) => d.file)); + const startsRepo = startsRepoAt(draft.events, events); + const wroteWhole = wrote.filter( + (f) => + edits.filter((d) => d.file === f).some((d) => d.created) || + (!touchedBefore(f, events.slice(0, Math.min(...draft.events))) && + edits + .filter((d) => d.file === f) + .every((d) => d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines))) + ); + const wrotePatched = wrote.filter((f) => !wroteWhole.includes(f)); + const lead = [ + startsRepo ? 'starts a git repository' : '', + wroteWhole.length ? `writes ${fileWords(wroteWhole)}` : '', + wrotePatched.length ? `changes ${fileWords(wrotePatched)}` : '', + ].filter(Boolean); + if (lead.length && story.startsWith('Claude ')) { + // "starts a git repository, saves the work and uploads it": never "and … and". + const rest = story.slice(7); + story = + lead.length >= 2 + ? `Claude ${lead.join(', ')}, and ${rest}` + : `Claude ${lead[0]}${rest.replace(/"[^"]*"/g, '').includes(' and ') ? ',' : ' and'} ${rest}`; + } + // Files written before the save, by count: "Claude writes a file, then saves the work." + const wrotePlainly = [ + wroteWhole.length ? `writes ${someFiles(wroteWhole.length)}` : '', + wrotePatched.length ? `changes ${someFiles(wrotePatched.length)}` : '', + ].filter(Boolean); + if (wrotePlainly.length && panel.ok !== false && !savedAnyway && !hiddenFail) + simple = `Claude ${wrotePlainly.join(' and ')}, then ${simple.slice(7)}`; + const branch = newBranchAt(draft.events, events); + if (branch && commits) story = story.replace('saves the work', `saves the work on a new branch (${branch})`); + director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${ + edits.length ? ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}` : '' + }`; + break; + } + case 'ask': { + const q = evs[0]?.question; + if (q) panel.question = { text: q.text, answer: q.answer }; + const plan = evs[0]?.tool === 'ExitPlanMode'; + story = plan + ? q?.answer === 'Approved' + ? 'Claude shows you its plan, and you approve it.' + : q?.answer + ? 'Claude shows you its plan, and you send it back.' + : 'Claude shows you its plan and waits for your OK.' + : q?.answer + ? 'Claude asks you, and you answer.' + : 'Claude asks you something.'; + simple = plan ? story : q?.answer ? 'Claude asks you a question, and you answer.' : 'Claude asks you a question.'; + director = `${tools}${q?.answer ? ` · answer: ${clip(q.answer, 60)}` : ''}`; + break; + } + case 'compact': + story = evs.some((e) => e.label.includes('memory was full')) + ? "Claude's memory is full: it packs up its notes." + : 'Claude packs up its notes to free memory.'; + simple = evs.some((e) => e.label.includes('memory was full')) + ? "Claude's memory is full, so it tidies up its notes." + : 'Claude tidies up its notes to free up memory.'; + director = evs.map((e) => e.description).find(Boolean) ?? 'compact_boundary'; + break; + case 'montage': { + panel.notes = notesOf(evs.filter((e) => e.tool === 'Bash')); + const added = evs.filter((e) => e.intent === 'prompt').length; + story = `Claude ${index <= 1 ? 'gets started' : 'keeps going'}${added ? ` (you add ${plural(added, 'thing')})` : ''}: ${montageSummary(evs)}.`; + simple = `Claude ${index <= 1 ? 'gets started' : 'keeps going'}${added ? ', and you add something' : ''}: it ${simpleMontage(evs)}.`; + break; + } + case 'complete': + break; + } + const savesHere = draft.events.some((i) => (events[i].commits ?? 0) > 0); + // A plan you approved that has a commit step asked for the save too ("Verify and commit"). + const planAsked = events.some( + (e) => + e.tool === 'ExitPlanMode' && + e.question?.answer === 'Approved' && + (ASKS_TO_SAVE.test(e.label) || !!e.todos?.some((t) => ASKS_TO_SAVE.test(t.text))) + ); + if ( + savesHere && + turn.origin === 'human' && + !planAsked && + !events.slice(0, Math.min(...draft.events)).some((e) => (e.commits ?? 0) > 0) && + !events.some((e) => e.intent === 'prompt' && ASKS_TO_SAVE.test(e.label)) + ) + panel.unaskedSave = events.some((e) => e.intent === 'say' && e.citesSaveRule) ? 'rule' : true; + if (panel.secondOpinion) { + // Asked before the step, after it (nothing following), or in the middle of a step's calls. + const work = draft.events.filter((i) => events[i].tool !== 'Advisor'); + const asked = draft.events.filter((i) => events[i].tool === 'Advisor'); + const when = asked.every((i) => i < Math.min(...work)) + ? 'after' + : asked.every((i) => i > Math.max(...work)) + ? 'then' + : 'during'; + const unanswered = asked.every((i) => events[i].ok === false); + const count = plural(panel.secondOpinion, 'second opinion'); + director += ` · ${when === 'during' ? `${count} along the way` : `${when} ${count}`}${unanswered ? ' (no answer)' : ''}`; + } + panel.caption = { story, director, simple: simple || 'Claude keeps working.' }; + return panel; +} + +function completePanel(turn: StoryTurn, page: Omit, trailingSay?: string): StoryPanel { + const running = page.helpersRunning; + const commands = page.events.filter((e) => e.running && !e.helper).length; + const later = page.events.filter((e) => e.finishedLater && e.intent !== 'prompt'); + const laterHelpers = later.filter((e) => e.helper).length; + const laterCommands = later.length - laterHelpers; + const done = turn.durationMs !== undefined ? `Done in ${roughDuration(turn.durationMs)}.` : 'Done.'; + // Where a stop landed: the step Claude last announced ("Step 3: …"), and what it had saved by then. + const announced = [...page.events].reverse().find((e) => e.intent === 'say' && /^\W{0,4}step \d+\b/i.test(e.label)); + const step = announced ? /step (\d+)/i.exec(announced.label)?.[1] : undefined; + // What that step was, in Claude's words: "Step 3: new tests under test/." brings "new tests under test/". + const stepWhat = announced + ? /^\W{0,4}step \d+\W{0,3}[:.-]\s*([^.!?\n]{3,80}?)\s*(?:[.!?](?:\s|$)|\n|$)/i + .exec(announced.label.replace(/[`*]/g, ''))?.[1] + ?.replace(/[:;,]$/, '') + : undefined; + const savedBefore = page.events.reduce((n, e) => n + (e.commits ?? 0), 0); + // What was still open on Claude's own to-do list when you stopped it. + const todos = [...page.events].reverse().find((e) => e.todos?.length)?.todos; + const open = todos?.filter((t) => !t.done).map((t) => clip(t.text, 60)) ?? []; + const notDone = open.length + ? ` Not done yet: ${open.slice(0, 2).join('; ')}${open.length > 2 ? ` and ${open.length - 2} more` : ''}.` + : ''; + const story = turn.interrupted + ? `You stop Claude here${step ? `, during its step ${step}${stepWhat ? ` (${stepWhat})` : ''}` : ''}.${ + savedBefore ? ` Saved before the stop: ${plural(savedBefore, 'commit')}.` : '' + }${notDone}` + : page.noneWorked + ? 'Nothing Claude tried worked, so the job is not done.' + : turn.endedBy === 'next prompt' + ? 'The trail stops here: the next prompt starts a new turn.' + : running || commands + ? `Claude's part is done${turn.durationMs !== undefined ? ` in ${roughDuration(turn.durationMs)}` : ''}. ${[ + running ? `${running === 1 ? 'A helper is' : `${running} helpers are`} still working.` : '', + commands + ? `${commands === 1 ? 'A command is' : `${commands} commands are`} still running in the background.` + : '', + ] + .filter(Boolean) + .join(' ')}` + : later.length + ? `Claude's part is done${turn.durationMs !== undefined ? ` in ${roughDuration(turn.durationMs)}` : ''}. ${[ + laterHelpers + ? `${laterHelpers === 1 ? 'Its helper' : `Its ${laterHelpers} helpers`} finished later and reported back.` + : '', + laterCommands + ? `${laterCommands === 1 ? 'Its background command' : `${laterCommands} background commands`} finished later.` + : '', + ] + .filter(Boolean) + .join(' ')}` + : done; + const director = [ + turn.endedBy === 'next prompt' ? 'superseded by the next prompt' : (turn.endedBy ?? 'turn_duration'), + turn.durationMs !== undefined ? exactDuration(turn.durationMs) : '', + plural(page.toolCalls, 'tool call'), + page.filesChanged ? plural(page.filesChanged, 'file') + ' changed' : '', + page.helpers ? plural(page.helpers, 'helper') : '', + running ? `${running} still running` : '', + ] + .filter(Boolean) + .join(' · '); + const panel: StoryPanel = { + kind: 'complete', + events: [], + caption: { + story: turn.durationMs !== undefined && turn.durationMs < 1000 && story === done ? 'Done.' : story, + director, + simple: simpleEnding(turn, page), + }, + }; + // A local command answers itself: its own first line is the last word. + const said = turn.endedBy === 'local command' ? turn.events[0]?.output?.split('\n')[0] : undefined; + if (trailingSay) panel.balloon = { who: 'claude', text: trailingSay }; + // A local command's output (/compact's "Compacted") is the CLI's own, not Claude's words: a note. + else if (said) panel.balloon = { who: 'note', text: clip(said, 160) }; + if (page.filesChanged) { + panel.added = page.added; + panel.removed = page.removed; + } + return panel; +} + +/** One turn as one comic page. Pure: the same turn always draws the same page. */ +export function buildStoryPage(turn: StoryTurn): StoryPage { + let events = turn.events.map((e) => ({ ...e })); + let truncated = false; + if (events.length > STORY_LIMITS.eventsPerPage) { + const head = events[0]?.intent === 'prompt' ? [events[0]] : []; + events = [...head, ...events.slice(-(STORY_LIMITS.eventsPerPage - head.length))]; + truncated = true; + } + if (applyTextBudget(events)) truncated = true; + // Claude wrote its plan as a numbered list before starting work (no plan tool): draw it as one. + for (const e of events) { + if (e.intent === 'edit' || e.intent === 'test' || e.intent === 'build' || e.intent === 'save') break; + if (e.intent !== 'say') continue; + const todos = planFromText(e.label); + if (todos) e.todos = todos; + } + + const { panels: grouped, trailingSay, openNote, asks } = groupPanels(events); + const room = STORY_LIMITS.panelsPerPage - (turn.ended ? 1 : 0); + const drafts = capPanels(collapseLoops(grouped), room); + const panels = drafts.map((d, i) => decorate(d, events, turn, i)); + + const edits = events.flatMap(editsOf); + const base: Omit = { + ...summarizeTurn({ ...turn, events }), + partial: turn.partial, + truncated, + events, + filesChanged: unique(edits.map((e) => e.file)).length, + ...netCounts(edits), + // The advisor is a second opinion, not a helper that does work. + helpers: events.filter((e) => e.helper && e.tool !== 'Advisor' && e.intent !== 'prompt').length, + helpersRunning: events.filter((e) => e.helper?.running).length, + ...(openNote && turn.ended && !turn.interrupted ? { openNote } : {}), + ...(asks && turn.ended && !turn.interrupted ? { asks: true } : {}), + ...(events.some((e) => e.finishedLater && e.intent !== 'prompt') + ? { finishedLater: events.filter((e) => e.finishedLater && e.intent !== 'prompt').length } + : {}), + }; + const calls = events.filter((e) => !NOT_TOOL_CALLS.has(e.intent) && e.tool !== 'Advisor'); + // A check that found nothing answered its question: it did not fail to work. + if (calls.length && calls.every((e) => e.ok === false && !emptyAnswer(e))) base.noneWorked = true; + if (turn.endedAt) base.endedAt = turn.endedAt; + if (turn.durationMs !== undefined) base.durationMs = turn.durationMs; + if (turn.ended) { + panels.push(completePanel(turn, base, trailingSay)); + } else if (panels.length) { + const last = panels[panels.length - 1]; + last.live = true; + if (trailingSay && !last.balloon) last.balloon = { who: 'claude', text: trailingSay }; + } + return { ...base, panels }; +} + +/** + * A helper's own transcript as one page. Its first prompt is the job Claude gave it; a later prompt + * (Claude messaging it again) continues the same page. `finished` comes from the helper's metadata, + * since a helper's transcript has no `turn_duration` of its own. + */ +export function buildHelperPage(turns: StoryTurn[], finished?: boolean): StoryPage | null { + if (!turns.length) return null; + const first = turns[0]; + const last = turns[turns.length - 1]; + // One helper's whole run as one page: how it ended is the LAST turn's (the first one "ended" only + // because the helper was messaged again, which would read as superseded). + return buildStoryPage({ + ...first, + events: turns.flatMap((t) => t.events), + ended: finished ?? last.ended, + endedBy: last.endedBy === 'next prompt' ? undefined : last.endedBy, + interrupted: false, + endedAt: last.endedAt, + durationMs: undefined, + }); +} + +/** The turn list for the page picker, newest last, capped. */ +export function summarizeTurns(turns: StoryTurn[]): StoryTurnSummary[] { + return turns.slice(-STORY_LIMITS.turnsListed).map(summarizeTurn); +} diff --git a/src/story-reader.ts b/src/story-reader.ts new file mode 100644 index 000000000..9f666f52a --- /dev/null +++ b/src/story-reader.ts @@ -0,0 +1,299 @@ +/** + * @fileoverview Story Mode transcript reader: the IO half of Story Mode (the pure half is story-mode.ts). + * + * Reads the TAIL of a Claude Code transcript, never the whole file: transcripts reach tens of MB + * (Write/Edit results carry whole files). The first read takes the last `STORY_TAIL_BYTES` and drops + * the partial first line, and takes a bigger tail (up to 64 MB) only when that window holds no prompt; + * later reads, while the file only grows, read just the appended bytes. A line that is still being + * written (no newline yet) is left for the next read. + * + * Each transcript's reduced entries are cached (`createStoryReducer()` already dropped file contents, + * images and long outputs), bounded per transcript by entry count and character budget, and across + * transcripts by an LRU, so a live view refetching every second costs one small append read. + * Concurrent reads of one path share a single in-flight promise. + * + * All IO is async (`fs/promises`); nothing here runs on a path the browser supplied. The route decides + * which file is read (story-routes.ts). + */ + +import { constants } from 'node:fs'; +import { open, stat, type FileHandle } from 'node:fs/promises'; +import { LRUMap } from './utils/lru-map.js'; +import { createStoryReducer, type StoryEntry, type StoryReducer } from './story-mode.js'; + +/** How much of the file's end a first read takes. */ +export const STORY_TAIL_BYTES = 8 * 1024 * 1024; +/** + * Larger tails, tried in order only while the window holds no prompt at all: one long turn can be + * bigger than 8 MB on its own (tool results carry whole files; measured ~60 KB per result line on + * large local transcripts), and its page would then lose its first panels. + */ +const TAIL_STEPS = [STORY_TAIL_BYTES, 24 * 1024 * 1024, 64 * 1024 * 1024]; +/** Entries kept per transcript (the oldest go first). */ +const MAX_ENTRIES = 20_000; +/** Approximate characters kept per transcript across all entries. */ +const MAX_CHARS = 6_000_000; +/** Transcripts cached at once, and their characters together (the oldest go first past either). */ +const MAX_TRANSCRIPTS = 24; +const MAX_CACHE_CHARS = 30_000_000; + +interface CacheEntry { + sidechain: boolean; + size: number; + mtimeMs: number; + /** Byte offset up to which complete lines were consumed. */ + offset: number; + entries: StoryEntry[]; + chars: number; + reducer: StoryReducer; + /** The read began mid-file, so the oldest turn may be cut. */ + partial: boolean; + /** A turn started inside what was read (it may have been trimmed away since). */ + sawTurnStart: boolean; +} + +export interface StoryEntriesResult { + entries: StoryEntry[]; + partial: boolean; + /** Changes whenever the file does (size and mtime); the route builds its ETag from it. */ + version: string; +} + +const versionOf = (size: number, mtimeMs: number) => `${size}-${Math.trunc(mtimeMs)}`; +/** A turn starts inside the window: a prompt that was not queued into a running turn. */ +const hasTurnStart = (entries: StoryEntry[]) => entries.some((e) => e.k === 'prompt' && !e.queued); + +const cache = new LRUMap({ maxSize: MAX_TRANSCRIPTS }); +const inFlight = new Map>(); + +/** + * The version a read of `path` would report (size and mtime), from a stat only: lets the route answer + * 304 for an unchanged page without reading or parsing anything, cached or not. Null for anything + * but a regular file. + */ +export async function storyFileVersion(path: string): Promise { + try { + const info = await stat(path); + return info.isFile() ? versionOf(info.size, info.mtimeMs) : null; + } catch { + return null; + } +} + +/** Evicts the least recently used transcripts while the cache holds more characters than its budget. */ +function keepCacheInBudget(keep: string): void { + let total = 0; + for (const e of cache.values()) total += e.chars; + for (const key of cache.keysInOrder()) { + if (total <= MAX_CACHE_CHARS) break; + if (key === keep) continue; + total -= cache.get(key)?.chars ?? 0; + cache.delete(key); + } +} + +/** For tests: forget every cached transcript. */ +export function clearStoryReaderCache(): void { + cache.clear(); + inFlight.clear(); +} + +/** + * Opens `path` for reading only if it is a regular file, and keeps the handle: every later check and + * read goes through it, never through the path again. Opened non-blocking, so a FIFO (or a symlink + * to one) swapped in never pins a libuv worker waiting for a writer; a device or a FIFO is refused by + * the handle's own stat. + */ +export async function openRegularFile(path: string): Promise<{ handle: FileHandle; size: number; mtimeMs: number }> { + const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const info = await handle.stat(); + if (!info.isFile()) throw new Error('Transcript is not a regular file'); + return { handle, size: info.size, mtimeMs: info.mtimeMs }; + } catch (err) { + await handle.close(); + throw err; + } +} + +async function readRange(handle: FileHandle, start: number, end: number): Promise { + const length = Math.max(0, end - start); + const buf = Buffer.alloc(length); + let read = 0; + while (read < length) { + const { bytesRead } = await handle.read(buf, read, length - read, start + read); + if (bytesRead === 0) break; + read += bytesRead; + } + return read === length ? buf : buf.subarray(0, read); +} + +/** A small regular file read whole (a helper's `.meta.json`), or null when it is anything else or too big. */ +export async function readSmallRegularFile(path: string, maxBytes: number): Promise { + let opened: Awaited>; + try { + opened = await openRegularFile(path); + } catch { + return null; + } + try { + if (opened.size > maxBytes) return null; + return (await readRange(opened.handle, 0, opened.size)).toString('utf8'); + } finally { + await opened.handle.close(); + } +} + +const entryChars = (e: StoryEntry): number => JSON.stringify(e).length; + +/** Bytes of lines parsed between yields to the event loop: a big first read must not stall the server. */ +const YIELD_EVERY_BYTES = 1024 * 1024; +const yieldToLoop = () => new Promise((resolve) => setImmediate(resolve)); + +/** + * Parses the complete lines in `buf` into the entry. Lines are decoded one at a time (never the whole + * buffer as one string: a 64 MB tail would be ~128 MB of UTF-16), and the loop yields every + * `YIELD_EVERY_BYTES`, so even the largest read keeps the event loop moving. + */ +async function ingest(entry: CacheEntry, buf: Buffer, startsMidLine: boolean): Promise { + let begin = 0; + if (startsMidLine) { + const firstNewline = buf.indexOf(0x0a); + if (firstNewline < 0) return; // one huge partial line: nothing complete yet + begin = firstNewline + 1; + } + const lastNewline = buf.lastIndexOf(0x0a); + if (lastNewline < begin) { + // No complete line in what was read; consume nothing beyond a dropped partial head. + entry.offset += begin; + return; + } + entry.offset += lastNewline + 1; + let pos = begin; + let sinceYield = 0; + while (pos < lastNewline) { + let nl = buf.indexOf(0x0a, pos); + if (nl < 0 || nl > lastNewline) nl = lastNewline; + if (nl - pos > 1) { + let parsed: unknown = null; + try { + parsed = JSON.parse(buf.toString('utf8', pos, nl)); + } catch { + parsed = null; // a malformed line is skipped, never fatal + } + for (const reduced of entry.reducer.reduce(parsed)) { + entry.entries.push(reduced); + entry.chars += entryChars(reduced); + if (reduced.k === 'prompt' && !reduced.queued) entry.sawTurnStart = true; + } + } + sinceYield += nl - pos + 1; + pos = nl + 1; + if (sinceYield >= YIELD_EVERY_BYTES) { + sinceYield = 0; + // Trimmed as it grows, so the peak stays near the caps, not the size of the window. + trim(entry); + await yieldToLoop(); + } + } + trim(entry); +} + +/** + * Drops the oldest entries over the caps in ONE splice: dropping them one `shift()` at a time is + * quadratic and never yields (seconds on a long turn's tail). + */ +function trim(entry: CacheEntry): void { + const all = entry.entries; + let drop = 0; + let chars = entry.chars; + while (all.length - drop > MAX_ENTRIES || (chars > MAX_CHARS && all.length - drop > 1)) { + chars -= entryChars(all[drop]); + drop++; + } + if (!drop) return; + all.splice(0, drop); + entry.chars = chars; + entry.partial = true; +} + +async function load(path: string, sidechain: boolean): Promise { + const { handle, ...info } = await openRegularFile(path); + try { + return await loadFrom(handle, info, path, sidechain); + } finally { + await handle.close(); + } +} + +async function loadFrom( + handle: FileHandle, + info: { size: number; mtimeMs: number }, + path: string, + sidechain: boolean +): Promise { + const key = sidechain ? `${path}#sidechain` : path; + const cached = cache.get(key); + const version = versionOf(info.size, info.mtimeMs); + if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) { + return { entries: cached.entries, partial: cached.partial, version }; + } + if ( + cached && + info.size > cached.offset && + info.size - cached.offset <= STORY_TAIL_BYTES && + info.size >= cached.size + ) { + // The file only grew: read what was appended since the last complete line. (A file rewritten to + // a larger size would read as an append; transcripts are append-only, so it never is.) + const buf = await readRange(handle, cached.offset, info.size); + await ingest(cached, buf, false); + cached.size = info.size; + cached.mtimeMs = info.mtimeMs; + cache.set(key, cached); + keepCacheInBudget(key); + return { entries: cached.entries, partial: cached.partial, version }; + } + // First read, a rewrite, or more appended than a tail holds: start over from the tail, and take a + // bigger one while the window has no turn start in it. Each step re-reads from scratch with a + // fresh reducer, because a result can only be read once its tool call has been seen. + let fresh: CacheEntry | null = null; + for (const tail of TAIL_STEPS) { + const start = Math.max(0, info.size - tail); + fresh = { + sidechain, + size: info.size, + mtimeMs: info.mtimeMs, + offset: start, + entries: [], + chars: 0, + reducer: createStoryReducer({ sidechain }), + partial: start > 0, + sawTurnStart: false, + }; + await ingest(fresh, await readRange(handle, start, info.size), start > 0); + // A turn start seen and then trimmed away would be trimmed from a bigger tail too: stop there. + if (start === 0 || fresh.sawTurnStart || hasTurnStart(fresh.entries)) break; + } + const done = fresh as CacheEntry; + cache.set(key, done); + keepCacheInBudget(key); + return { entries: done.entries, partial: done.partial, version }; +} + +/** + * Reduced entries for the transcript at `path`, newest last. `sidechain` reads a helper's own + * transcript (every line there is a sidechain line). Throws when the file cannot be read; the caller + * turns that into an error response. + */ +export function readStoryEntries( + path: string, + { sidechain = false }: { sidechain?: boolean } = {} +): Promise { + const key = sidechain ? `${path}#sidechain` : path; + const running = inFlight.get(key); + if (running) return running; + const promise = load(path, sidechain).finally(() => inFlight.delete(key)); + inFlight.set(key, promise); + return promise; +} diff --git a/src/types/index.ts b/src/types/index.ts index 06dda8aa7..e67969088 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -81,3 +81,4 @@ export * from './foreign-tmux.js'; export * from './mcp-sync.js'; export * from './local-model.js'; export * from './local-model-install.js'; +export * from './story.js'; diff --git a/src/types/story.ts b/src/types/story.ts new file mode 100644 index 000000000..6ea3f5fe3 --- /dev/null +++ b/src/types/story.ts @@ -0,0 +1,257 @@ +/** + * @fileoverview Story Mode types: one Claude Code turn drawn as a comic page. + * + * Produced by the pure core in `src/story-mode.ts` and served by + * `GET /api/sessions/:id/story` (`src/web/routes/story-routes.ts`). The shape is + * EXPERIMENTAL (docs/api-reference.md), not part of the stable `/api/v1` contract yet. + * + * Every string in here comes from a transcript the agent wrote, so it is untrusted + * text: the browser renders it with `textContent` only, and a file path in here is + * opened through the existing file preview, never fetched directly. + */ + +/** What one transcript event means, read from the tool name and, for Bash, the command. */ +export type StoryIntent = + | 'prompt' + | 'read' + | 'search' + | 'look' + | 'web' + | 'plan' + | 'say' + | 'helper' + | 'message' + | 'edit' + | 'test' + | 'build' + | 'run' + | 'save' + | 'ask' + | 'compact' + | 'interrupt' + | 'notice' + | 'tool'; + +/** The comic panel a run of events is drawn as. */ +export type StoryPanelKind = + | 'mission' + | 'investigate' + | 'research' + | 'plan' + | 'backup' + | 'fix' + | 'check' + | 'loop' + | 'run' + | 'save' + | 'ask' + | 'compact' + | 'montage' + | 'complete'; + +export type StoryActor = 'you' | 'claude' | 'helper' | 'system'; + +/** Who started a turn: you, a background task reporting back, another session, or a schedule/SDK. */ +export type StoryTurnOrigin = 'human' | 'task' | 'peer' | 'scheduled' | 'system'; + +/** One hunk of an edit, in the shape of Claude Code's `structuredPatch` (lines keep their ` `/`+`/`-` prefix). */ +export interface StoryHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; + /** Lines appended at the end of the file (`>>`): where they start is not known, so no line numbers. */ + atEnd?: boolean; +} + +/** What one Edit/Write call changed. Counts are exact even when `hunks` was cut to size. */ +export interface StoryEdit { + file: string; + added: number; + removed: number; + created: boolean; + hunks: StoryHunk[]; + truncated: boolean; +} + +/** A helper (Agent/Task/Workflow call): what it was asked and what it answered. */ +export interface StoryHelper { + name: string; + type: string; + /** Claude's short description of the job ("Write a phone-size test"). */ + task?: string; + prompt: string; + result?: string; + toolUses?: number; + durationMs?: number; + /** Claude Code's id for the helper; its own steps are at `GET /api/sessions/:id/story/helpers/`. */ + agentId?: string; + /** Started in the background: Claude went on working, and the answer comes later in a notification. */ + background?: boolean; + /** A background helper that has not reported back yet. */ + running?: boolean; +} + +export interface StoryTests { + passed: number; + failed: number; + /** Errors outside the tests (pytest's collection errors): the run failed although no test did. */ + errors?: number; +} + +export interface StoryTodo { + text: string; + done: boolean; +} + +/** One row of "the wire": a prompt, a sentence Claude said, or one tool call with its result. */ +export interface StoryEvent { + id: string; + at: string; + actor: StoryActor; + tool: string; + label: string; + intent: StoryIntent; + /** + * False when the call failed: its result was flagged as an error, or it reported failed tests (an + * exit code a pipe hid). Absent while the call is still running. + */ + ok?: boolean; + pending?: boolean; + /** The one file this event concerns (Read, Edit, Write), as the transcript gave it. */ + file?: string; + /** Files a Bash call printed (`cat a b`, `head -n 20 c`), as the command named them (maybe relative). */ + files?: string[]; + line?: number; + /** Claude's own short description of a Bash call ("Run the test suite"). */ + description?: string; + /** A command sent to the background ("Command running in background") that has not reported back. */ + running?: boolean; + /** A command that ran in the background (its result came back later, as a report of its own). */ + background?: boolean; + /** Background work (this command, or this helper call's helper) that finished after its turn ended. */ + finishedLater?: boolean; + /** + * Sentences from further into a long message that a reader must not lose: an open problem, a step + * not taken, what you asked for. From the whole message, past the clip of `label`. + */ + keyPoints?: string[]; + /** Claude says a project rule asks for its commits ("CLAUDE.md's commit-after-each-change rule"). */ + citesSaveRule?: boolean; + /** A save that ships the work: `gh pr create` (`pr`) or `git push` (`push`). */ + ships?: 'pr' | 'push'; + /** How many commits a Bash call makes (`git commit` segments). */ + commits?: number; + /** The first commit's message, when the command spells it out (`-m "..."` or a here-document). */ + subject?: string; + /** Who a prompt came from (a prompt added mid-turn can come from someone other than the turn's starter). */ + origin?: StoryTurnOrigin; + output?: string; + outputTruncated?: boolean; + edit?: StoryEdit; + /** Files a Bash call wrote through a here-document (`cat > f <<'EOF'`), each as a whole-file edit. */ + edits?: StoryEdit[]; + /** The part of a Bash one-liner that decided its intent (the test run, the commit), for captions. */ + headline?: string; + helper?: StoryHelper; + tests?: StoryTests; + todos?: StoryTodo[]; + question?: { text: string; options: string[]; answer?: string }; +} + +export interface StoryPanel { + kind: StoryPanelKind; + /** Indices into the page's `events`. */ + events: number[]; + /** + * Three reading levels: `simple` for someone who has never seen a terminal (one short sentence in + * everyday words, no file names, commands, tools or jargon), `story` for anyone, `director` naming + * the tools. All three are written from the same facts, so none says more (or better) than happened. + */ + caption: { story: string; director: string; simple: string }; + /** `note`: words that start a turn but are not yours (a background job's report, another session). */ + balloon?: { who: 'you' | 'claude' | 'note'; text: string }; + files?: string[]; + notes?: string[]; + /** What a run panel's last command printed, for its terminal card: the first lines, the last ones when it failed. */ + printed?: string[]; + helpers?: Array<{ name: string; type: string; task?: string; event: number }>; + added?: number; + removed?: number; + ok?: boolean; + rounds?: number; + /** + * The turn's first save, when no prompt of the turn (nor a plan you approved) asked for one: `rule` + * when Claude said a project rule asks for it, `true` otherwise. + */ + unaskedSave?: true | 'rule'; + /** Second opinions (the advisor) Claude asked for right before this step: not a step of their own. */ + secondOpinion?: number; + /** A loop's test runs in order: whether each passed, `null` for one still running. */ + roundsOk?: (boolean | null)[]; + tests?: StoryTests; + todos?: StoryTodo[]; + question?: { text: string; answer?: string }; + /** The turn is still running and this is its newest panel. */ + live?: boolean; +} + +export interface StoryTurnSummary { + id: string; + at: string; + prompt: string; + origin: StoryTurnOrigin; + complete: boolean; + interrupted: boolean; + /** Ended only because the next prompt started (no end marker of its own: an abandoned or crashed turn). */ + superseded?: boolean; + toolCalls: number; +} + +export interface StoryPage extends StoryTurnSummary { + endedAt?: string; + durationMs?: number; + /** The turn began before the part of the transcript that was read. */ + partial: boolean; + /** Older events, outputs or hunks were dropped to keep the page small. */ + truncated: boolean; + panels: StoryPanel[]; + events: StoryEvent[]; + filesChanged: number; + added: number; + removed: number; + helpers: number; + /** Background helpers that had not reported back when this page was drawn. */ + helpersRunning: number; + /** Helpers and background commands of this turn that finished only after it ended. */ + finishedLater?: number; + /** Claude's last words leave something open: `catch` a problem, `note` something not done or checked yet. */ + openNote?: 'catch' | 'note'; + /** Claude's last words ask you something or offer more: the next move is yours. */ + asks?: boolean; + /** + * Every tool call of the turn failed (a blocked command, nothing that worked): no celebration. A + * command whose last check found nothing (grep, `git config`, exit code 1 after its answer) answered. + */ + noneWorked?: boolean; +} + +/** `GET /api/sessions/:id/story/helpers/:agentId`: one helper's own steps, as one page. */ +export type StoryHelperResponse = + | { supported: false; reason: string } + | { + supported: true; + helper: { agentId: string; type?: string; description?: string; finished?: boolean }; + page: StoryPage | null; + }; + +export type StoryResponse = + | { supported: false; reason: string } + | { + supported: true; + turns: StoryTurnSummary[]; + page: StoryPage | null; + /** The transcript is longer than the part that was read: older turns are not listed. */ + older?: boolean; + }; diff --git a/src/utils/claude-transcript.ts b/src/utils/claude-transcript.ts index 8ce01f8bd..9fab41efe 100644 --- a/src/utils/claude-transcript.ts +++ b/src/utils/claude-transcript.ts @@ -22,7 +22,7 @@ * recoverable pane into the collision this module exists to prevent. * * @dependencies none - * @consumedby session (relaunch resume pinning) + * @consumedby session (relaunch resume pinning), web/routes/story-routes (Story Mode transcript lookup) * * @module utils/claude-transcript */ @@ -47,6 +47,69 @@ export function claudeProjectsDir(configDir?: string): string { return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects'); } +/** + * The one top-level transcript a recovered session (`restored-`, how + * reconcileSessions() names a session whose mux record was lost) belongs to, or null when none or + * more than one matches. The same recovery `last-response` makes (session-routes.ts). + */ +export async function findRestoredClaudeTranscriptPath( + codemanSessionId: string, + configDir?: string +): Promise { + const restored = /^restored-([a-f0-9]{8,})$/i.exec(codemanSessionId); + if (!restored) return null; + const fragment = restored[1].toLowerCase(); + const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i; + const projectsDir = claudeProjectsDir(configDir); + let projectDirs: string[]; + try { + projectDirs = await readdir(projectsDir); + } catch { + return null; + } + const found = new Map(); + for (const projectDir of projectDirs) { + let files: string[]; + try { + files = await readdir(join(projectsDir, projectDir)); + } catch { + continue; + } + for (const file of files) { + const id = file.endsWith('.jsonl') ? file.slice(0, -'.jsonl'.length) : ''; + if (!id.toLowerCase().startsWith(fragment) || !uuid.test(id)) continue; + const path = join(projectsDir, projectDir, file); + if ((await stat(path).catch(() => null))?.isFile()) found.set(id, path); + } + } + return found.size === 1 ? [...found.values()][0] : null; +} + +/** + * Path of the transcript for `conversationId` under any project directory, or null. Unlike + * {@link claudeTranscriptExists} it answers with the file (Story Mode reads it), so the id is + * checked against a strict charset before it is joined into a path, and only a regular file counts. + */ +export async function findClaudeTranscriptPath(conversationId: string, configDir?: string): Promise { + if (!/^[a-zA-Z0-9._-]+$/.test(conversationId) || conversationId.startsWith('.')) return null; + const projectsDir = claudeProjectsDir(configDir); + let projectDirs: string[]; + try { + projectDirs = await readdir(projectsDir); + } catch { + return null; + } + for (const projectDir of projectDirs) { + const candidate = join(projectsDir, projectDir, `${conversationId}.jsonl`); + try { + if ((await stat(candidate)).isFile()) return candidate; + } catch { + // Not in this project directory; keep looking. + } + } + return null; +} + /** * True when a transcript for `conversationId` exists under any project * directory. Returns false for a missing projects dir or an unreadable one, diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 6932427dc..0012a4418 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -60,6 +60,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Settings surface** (`#appSettingsModal` + `#sessionOptionsModal` + `#createCaseModal`): one `set-*` language shared through a single `:is(...)` id scope in styles.css. App Settings' rail is a table of contents over ONE scrolling document (`switchSettingsTab` scrolls); Session Options and Add Case really switch (`switchOptionsTab` / `switchCaseModalTab`), and their larger per-modal size blocks are the design, not drift. ⚠️ **The load/save contract is `getElementById` by id**: renaming or dropping a control id silently stops it loading or saving. ⚠️ The Session Options "Session" entry still keys off `context` (label-only rename). ⚠️ Add Case keeps its legacy `.form-row` markup via an adapter; every `
` there needs `.set-adv-chev` plus both marker suppressions. ⚠️ Model cards and the effort segment are views over hidden `Font Size + @@ -4570,6 +4574,7 @@

Read My Mind

+ diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index c6e566768..60ff93055 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -596,7 +596,10 @@ html.mobile-init .file-browser-panel { /* Approvals bell: phones answer from the overview's NEEDS YOU rows instead (inline approve/deny in mobile-overview.js); the bell would only crowd the header it was designed to stay out of. */ - .btn-icon-header.btn-approvals { + .btn-icon-header.btn-approvals, + /* Story Mode: phones open it from Session Options (#sessionOptionsStoryBtn), not one more icon + in this header; the overlay itself is responsive. */ + .btn-icon-header.btn-story-header { display: none !important; } /* Split-pane is hidden here too, but the AUTHORITATIVE hard gate is the diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 8cd7a8743..13930c74e 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -520,6 +520,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings); document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false; document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; + document.getElementById('appSettingsShowStoryButton').checked = settings.showStoryButton ?? defaults.showStoryButton ?? false; document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true; document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false; document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue'; @@ -3131,6 +3132,7 @@ Object.assign(CodemanApp.prototype, { headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value, showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked, showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked, + showStoryButton: document.getElementById('appSettingsShowStoryButton').checked, showFileViewerButton: document.getElementById('appSettingsShowFileViewerButton').checked, showAttachmentsButton: document.getElementById('appSettingsShowAttachmentsButton').checked, showMonitor: document.getElementById('appSettingsShowMonitor').checked, @@ -4740,6 +4742,9 @@ Object.assign(CodemanApp.prototype, { responseViewerBtn.classList.toggle('btn-response-viewer-header--hidden', !showResponseViewer); } + // Story Mode (story-mode.js): opt-in, default OFF; turning it off also closes an open story. + this.applyStoryButtonVisibility?.(); + // Hide the attachments (history) button when disabled. Opt-in, default OFF — // marker class, base is display:inline-flex !important. const showAttachmentsButton = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false; @@ -5204,7 +5209,7 @@ Object.assign(CodemanApp.prototype, { _displaySettingKeySet() { const displayKeys = new Set([ 'showFontControls', 'showSystemStats', 'showGpuStats', 'showCpuMhz', 'headerStatsStyle', 'showTokenCount', 'showCost', - 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', + 'showLifecycleLog', 'showResponseViewer', 'showStoryButton', 'showRedrawButton', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'keyboardHoldsRows', 'cjkInputEnabled', 'extendedKeyboardBar', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css new file mode 100644 index 000000000..e25ba843e --- /dev/null +++ b/src/web/public/story-mode.css @@ -0,0 +1,1303 @@ +/* Story Mode (story-mode.js): a session's turns as comic pages. + Layout: a full-screen overlay (bar on top, the comic sheet below, "the wire" beside it in the + Director's cut) with a details drawer on the right. The comic keeps its own palette, the four + process inks, so it reads the same on every Codeman skin; a light skin gets the paper variant. + Fonts are self-hosted (CSP font-src 'self'); licenses in fonts/LICENSE-story-fonts.txt. */ + +@font-face { + font-family: 'Codeman Story Display'; + src: url('fonts/bowlby-one-sc-latin.woff2') format('woff2'); + font-display: swap; +} +@font-face { + font-family: 'Codeman Story Letter'; + src: url('fonts/patrick-hand-sc-latin.woff2') format('woff2'); + font-display: swap; +} + +/* Registered so they always compute to a plain value: story-mode.js reads --story-panel-min back + with parseFloat (an unregistered property holding calc() would come back as its raw text, and + caption fitting would silently stop). --story-k scales the comic text with the row height. */ +@property --story-panel-min { + syntax: ''; + inherits: true; + initial-value: 270px; +} +@property --story-k { + syntax: ''; + inherits: true; + initial-value: 1; +} + +/* Session Options → Story (story-mode.js): shown while Story Mode is on. Sits in the sheet's + header tray beside the close button, on every device. */ +#sessionOptionsModal .set-head-actions .story-options-btn { + display: inline-flex; + align-items: center; + height: 30px; + padding: 0 12px; + font: inherit; + font-size: 0.82rem; + font-weight: 620; + color: var(--text); + background: transparent; + border: 0; + border-radius: 9px; + box-shadow: inset 0 0 0 1px var(--border); + cursor: pointer; +} +#sessionOptionsModal .set-head-actions .story-options-btn[hidden] { + display: none; +} + +.story-overlay { + /* Night edition: dark panels, light ink for marks drawn on them, dark line art on objects. */ + --story-bg: #0d0d12; + --story-sheet: #15151c; + --story-panel: #1c1c25; + --story-ink: #edeae4; + --story-line: #121016; + --story-muted: #a49fae; + --story-rule: #2e2d38; + --story-cyan: #33b7ea; + --story-magenta: #ff4e97; + --story-yellow: #ffd43d; + --story-go: #3ec27c; + --story-pencil: #4f86b8; + --story-balloon: #f3f1ec; + --story-balloon-ink: #121016; + --story-cap: #ffd43d; + --story-cap-ink: #121016; + /* Accents as TEXT (tags, counts, labels): the accents themselves where they read on the sheet, + darker ones in the paper skin, where yellow or light green text on white fell under 4.5:1. */ + --story-go-text: #3ec27c; + --story-bad-text: #ff4e97; + --story-wait-text: #ffd43d; + --story-pencil-text: #6f9fcc; + --story-live-bg: #ff4e97; + --story-live-ink: #121016; + /* Text on a balloon (always light): a magenta dark enough for it. */ + --story-note-ink: #b8125f; + --story-display: 'Codeman Story Display', Impact, 'Arial Black', sans-serif; + --story-letter: 'Codeman Story Letter', 'Comic Sans MS', 'Chalkboard SE', cursive; + --story-body: 'Manrope', system-ui, sans-serif; + --story-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace; + --story-drawer-w: min(560px, 44vw); + /* The Director's cut wire beside the sheet grows with the window. */ + --story-wire-w: clamp(320px, 25vw, 460px); + + position: fixed; + inset: 0; + z-index: 2400; + display: grid; + grid-template-columns: minmax(0, 1fr); + /* The bar, the page, the composer. The bottom safe area is the composer's own padding, so its + background reaches the edge of the screen. */ + grid-template-rows: auto minmax(0, 1fr) auto; + background: var(--story-bg); + color: var(--story-ink); + font-family: var(--story-body); + color-scheme: dark; + padding-top: env(safe-area-inset-top, 0px); +} +.story-overlay.story-paper { + --story-bg: #ecede9; + --story-sheet: #fbfbf9; + --story-panel: #ffffff; + --story-ink: #18151d; + --story-line: #18151d; + --story-muted: #5f5a68; + --story-rule: #d5d3da; + --story-cyan: #0e9fd6; + --story-magenta: #e0237a; + --story-yellow: #ffd019; + --story-go: #1a9a55; + --story-pencil: #5fa6df; + --story-balloon: #ffffff; + --story-balloon-ink: #18151d; + --story-cap: #ffd019; + --story-cap-ink: #18151d; + --story-go-text: #0f6e3c; + --story-bad-text: #b8125f; + --story-wait-text: #8a6100; + --story-pencil-text: #2f6fa8; + --story-live-bg: #b8125f; + --story-live-ink: #ffffff; + color-scheme: light; +} +.story-overlay[hidden] { + display: none; +} +body.story-open { + overflow: hidden; +} +.story-overlay button { + font: inherit; + color: inherit; + cursor: pointer; +} +.story-overlay :focus-visible { + outline: 3px solid var(--story-cyan); + outline-offset: 2px; +} + +/* Bar */ +.story-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; + padding: 10px max(16px, env(safe-area-inset-right, 0px)) 10px max(16px, env(safe-area-inset-left, 0px)); + border-bottom: 2px solid var(--story-ink); + background: var(--story-sheet); +} +.story-brand { + display: flex; + align-items: baseline; + gap: 12px; + min-width: 0; + flex: 1 1 180px; +} +.story-title { + font: 28px/1 var(--story-display); + color: var(--story-yellow); + -webkit-text-stroke: 1.5px var(--story-line); + paint-order: stroke fill; + text-shadow: 3px 3px 0 var(--story-magenta); +} +.story-session { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 500 12.5px/1.2 var(--story-mono); + color: var(--story-muted); +} +.story-nav { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1 1 200px; +} +.story-turns { + min-width: 0; + width: 100%; + flex: 1; + max-width: 420px; + padding: 6px 26px 6px 8px; + font: 500 12.5px/1.2 var(--story-mono); + text-overflow: ellipsis; + color: var(--story-ink); + background: var(--story-panel); + border: 2px solid var(--story-ink); + border-radius: 0; +} +.story-btn { + display: inline-grid; + place-items: center; + min-width: 34px; + height: 34px; + padding: 0 8px; + font: 20px/1 var(--story-body) !important; + background: var(--story-panel); + border: 2px solid var(--story-ink); + border-radius: 0; +} +.story-save { + font: 600 12px/1 var(--story-mono) !important; + letter-spacing: 0.04em; + padding: 0 10px; +} +.story-btn:disabled { + opacity: 0.35; + cursor: default; +} +.story-seg { + display: inline-flex; + border: 2.5px solid var(--story-ink); +} +.story-seg-btn { + padding: 6px 12px 5px; + font: 18px/1 var(--story-letter) !important; + background: transparent; + border: 0; +} +.story-seg-btn[aria-pressed='true'] { + background: var(--story-ink); + color: var(--story-sheet) !important; +} +.story-live { + display: inline-flex; + align-items: center; + gap: 7px; + font: 600 12px/1 var(--story-mono); + letter-spacing: 0.1em; + color: var(--story-bad-text); +} +.story-live[hidden] { + display: none; +} +.story-live i { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--story-magenta); + animation: story-blink 1s steps(2, start) infinite; +} +@keyframes story-blink { + to { + visibility: hidden; + } +} + +/* A phone: two rows (title and close, then the view and Save image), not three with the close + button alone at the bottom. Tab order stays the DOM's; focus starts on close anyway. The turn + picker is in the composer at the bottom. */ +@media (max-width: 600px) { + .story-bar { + gap: 0 10px; + padding-block: 8px; + } + /* The row break: a full-width item between close (order 1) and the levels (order 3). The turn + picker used to force it; it lives in the composer now. Its height is the gap between the rows. */ + .story-bar::after { + content: ''; + order: 2; + flex-basis: 100%; + height: 8px; + } + .story-brand { + order: 0; + flex: 1 1 0; + } + .story-title { + font-size: 24px; + } + .story-close { + order: 1; + } + .story-seg, + .story-live, + .story-save { + order: 3; + } + /* Three reading levels and Save image share one row at 390px; LIVE goes up beside the title, + where the session name gives way to it (on the third row it wrapped to a fourth). */ + .story-seg-btn { + padding: 6px 7px 5px; + font-size: 15px !important; + } + .story-live { + order: 0; + } +} + +/* Main: the sheet and the wire */ +.story-main { + overflow: auto; + /* The scrollbar's room is kept whether the page scrolls or not, so the sheet's width (and the row + height story-mode.js derives from it) never depends on whether the rows overflow. */ + scrollbar-gutter: stable; + padding: 16px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px)); +} +.story-overlay.has-drawer .story-main { + padding-right: calc(var(--story-drawer-w) + 16px); +} +.story-note { + margin: 0 0 14px; + padding: 8px 12px; + font-size: 14px; + line-height: 1.45; + color: var(--story-ink); + border: 2px dashed var(--story-pencil); +} +.story-note[hidden] { + display: none; +} +.story-stage { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 16px; + align-items: start; +} +.story-overlay.is-director .story-stage { + grid-template-columns: minmax(0, 1fr) var(--story-wire-w); +} +/* The wire runs the full height beside the note too (it starts at the top of the main area): the + note ends where the sheet does, at the stage's 16px gap before the wire. */ +.story-overlay.is-director .story-note { + margin-right: calc(var(--story-wire-w) + 16px); +} +@media (max-width: 1100px) { + .story-overlay.is-director .story-stage { + grid-template-columns: minmax(0, 1fr); + } + .story-overlay.is-director .story-note { + margin-right: 0; + } +} +.story-sheet { + container: story-sheet / inline-size; + /* Down to the visible bottom even when a short page's rows stop short of it (a row is capped). */ + min-height: calc(var(--story-view-h, 0px) - var(--story-note-h, 0px)); + background: var(--story-sheet); +} +.story-page { + /* The shortest row: story-mode.js sets each row from the visible height (plain px values, in + grid-template-rows) and this to the shortest, for any row past those and as a panel's default. */ + --story-row-h: 270px; + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + /* A row grows past it only for words that need more room (the final panel's are in flow). */ + grid-auto-rows: minmax(var(--story-row-h), auto); + gap: 14px; + padding: 14px; +} + +/* Panels */ +.story-panel { + /* The panel's usual height, read back as its scene's stage: its row's, which story-mode.js writes + on the panel (plain px). A long caption or ending makes the panel taller (inline min-height), + and the scene then stands at the bottom, never scaled up. */ + --story-panel-min: var(--story-row-h); + position: relative; + margin: 0; + min-height: var(--story-panel-min); + overflow: hidden; + grid-column: span var(--story-span6, 2); + background: var(--story-panel); + border: 3px solid var(--story-ink); +} +.story-panel canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; +} +.story-panel.is-live { + border-color: var(--story-magenta); +} +.story-hit { + position: absolute; + z-index: 1; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; +} +.story-cap-note { + display: block; + margin-top: 3px; + font: 600 10.5px/1.25 var(--story-mono); + letter-spacing: 0.01em; + text-transform: none; + color: var(--story-cap-ink); + opacity: 0.78; +} +.story-cap-note[hidden], +.story-overlay.is-director .story-cap-note { + display: none; +} +.story-hit:hover, +.story-hit:focus-visible { + outline: 3px dashed var(--story-magenta) !important; + outline-offset: 2px; +} +/* A light band under the dashes: on a magenta halftone (the loop) the ring alone vanished. */ +.story-hit:focus-visible { + box-shadow: 0 0 0 5px var(--story-balloon); +} +.story-cap { + position: absolute; + z-index: 2; + left: 10px; + top: 10px; + margin: 0; + max-width: min(64%, 34ch); + padding: 5px 9px 6px; + background: var(--story-cap); + color: var(--story-cap-ink); + border: 2.5px solid var(--story-cap-ink); + font: calc(17px * var(--story-k, 1)) / 1.13 var(--story-letter); + text-wrap: pretty; +} +.story-cap-dir { + display: none; +} +.story-overlay.is-director .story-cap { + max-width: min(74%, 46ch); + font: 500 calc(11px * var(--story-k, 1)) / 1.45 var(--story-mono); +} +.story-overlay.is-director .story-cap-story { + display: none; +} +.story-overlay.is-director .story-cap-dir { + display: inline; +} +/* Simple: one plain sentence per panel, in the letter font and a size up, for a reader who has never + seen a terminal. The final panel keeps its full width (.story-splash .story-cap comes later). */ +.story-cap-simple { + display: none; +} +.story-overlay.is-simple .story-cap { + max-width: min(68%, 30ch); + font-size: calc(20px * var(--story-k, 1)); + line-height: 1.12; +} +.story-overlay.is-simple .story-cap-story, +.story-overlay.is-simple .story-cap-note { + display: none; +} +.story-overlay.is-simple .story-cap-simple { + display: inline; +} +.story-balloon { + position: absolute; + z-index: 3; + margin: 0; + max-width: 42%; + padding: 9px 15px 10px; + border-radius: 22px; + background: var(--story-balloon); + color: var(--story-balloon-ink); + border: 2.5px solid var(--story-balloon-ink); + font: calc(17px * var(--story-k, 1)) / 1.12 var(--story-letter); + text-wrap: balance; + overflow-wrap: anywhere; +} +.story-balloon[hidden] { + display: none; +} +.story-panel[data-kind='mission'] .story-balloon { + cursor: pointer; +} +/* Spoken, not shown: the status region a screen reader announces from. */ +.story-sr, +.story-vh { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +/* App toasts move to the bottom while Story Mode is open: at the top they cover its bar. They sit + above the composer, whose measured height (safe area included) story-mode.js sets on body. */ +body.story-open .toast-container { + top: auto; + bottom: calc(var(--story-compose-h, env(safe-area-inset-bottom, 0px)) + 0.75rem); +} +/* story-mode.js sets --story-lines when a balloon would leave its panel or cover the art or Claude's face. */ +.story-balloon-text { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--story-lines, none); + overflow: hidden; +} +.story-balloon::before, +.story-balloon::after { + content: ''; + position: absolute; +} +.story-balloon--claude { + right: 18%; + top: 28%; +} +.story-balloon--claude::before { + right: 22px; + bottom: -19px; + width: 22px; + height: 19px; + background: var(--story-balloon-ink); + clip-path: polygon(0 0, 100% 0, 85% 100%); +} +.story-balloon--claude::after { + right: 25.5px; + bottom: -14px; + width: 15px; + height: 16.5px; + background: var(--story-balloon); + clip-path: polygon(0 0, 100% 0, 84% 100%); +} +.story-balloon--you { + left: 16px; + /* Below the caption, which grows with the comic text (--story-k). */ + top: calc(74px * var(--story-k, 1)); + max-width: 54%; +} +.story-balloon--you::before { + left: -22px; + top: 34%; + width: 22px; + height: 22px; + background: var(--story-balloon-ink); + clip-path: polygon(100% 0, 100% 100%, 0 75%); +} +.story-balloon--you::after { + left: -15px; + top: calc(34% + 3.5px); + width: 17.5px; + height: 15px; + background: var(--story-balloon); + clip-path: polygon(100% 0, 100% 100%, 0 76%); +} +/* A turn somebody else started (a background task, another session): a notice, not your speech. */ +.story-balloon--note { + left: 16px; + top: calc(74px * var(--story-k, 1)); + max-width: 54%; + border-radius: 4px; + border-style: dashed; +} +.story-balloon--note::before { + content: 'Notice'; + position: static; + display: block; + margin-bottom: 3px; + font: 600 10px/1 var(--story-mono); + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--story-note-ink); +} +.story-balloon--note::after { + display: none; +} +/* Every balloon is placed by story-mode.js (_storyFitBalloon), from the scene's layout: these are its + places before that (or without a scene layout). */ +.story-panel[data-kind='loop'] .story-balloon--claude { + top: 10px; + right: 12px; +} +.story-panel[data-kind='ask'] .story-balloon--claude { + right: auto; + left: 14px; + top: 30%; + max-width: 46%; +} +.story-answer { + position: absolute; + z-index: 3; + left: 14px; + bottom: 14px; + margin: 0; + max-width: 50%; + padding: 5px 9px 4px; + font: 13px/1.2 var(--story-display); + letter-spacing: 0.03em; + color: var(--story-go-text); + background: var(--story-panel); + border: 3px solid var(--story-go); + transform: rotate(-4deg); + overflow-wrap: anywhere; +} +.story-answer[hidden], +.story-live-tag[hidden] { + display: none; +} +.story-live-tag { + position: absolute; + z-index: 4; + right: 8px; + top: 8px; + padding: 3px 7px 2px; + font: 600 10.5px/1 var(--story-mono); + letter-spacing: 0.1em; + color: var(--story-live-ink); + background: var(--story-live-bg); +} + +/* The final panel's words sit in flow, so a long ending or a second row of chips makes the panel + taller instead of being cut off at its edge. */ +.story-panel[data-kind='complete'] { + display: flex; +} +.story-splash { + position: relative; + z-index: 2; + box-sizing: border-box; + flex: 0 0 46%; + max-width: 46%; + margin: 12px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 8px; +} +.story-splash .story-cap, +.story-splash .story-balloon { + position: static; + align-self: flex-start; + max-width: 100%; +} +.story-splash .story-balloon::before, +.story-splash .story-balloon::after { + display: none; +} +.story-splash-title { + /* Room for the tilt: rotated about its bottom-left corner, its right end rises. */ + margin: 0.4em 0 0; + font: 400 calc(clamp(28px, 4.4cqi, 44px) * var(--story-k, 1)) / 0.92 var(--story-display); + color: var(--story-yellow); + -webkit-text-stroke: 2.5px var(--story-line); + paint-order: stroke fill; + text-shadow: 4px 4px 0 var(--story-line); + transform: rotate(-3deg); + transform-origin: left bottom; +} +.story-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} +.story-chips li > * { + display: block; + padding: 4px 8px; + font: 500 calc(12px * var(--story-k, 1)) / 1.2 var(--story-mono) !important; + background: var(--story-balloon); + color: var(--story-balloon-ink) !important; + border: 2px solid var(--story-line); +} +.story-chips button { + text-decoration: underline dotted; + text-underline-offset: 3px; +} +.story-chips button:hover { + background: var(--story-yellow); +} + +.story-pencils { + grid-column: span var(--story-span6, 2); + display: grid; + place-content: center; + gap: 4px; + text-align: center; + color: var(--story-pencil-text); + border: 2.5px dashed var(--story-pencil); +} +.story-pencils span { + font: 26px/1 var(--story-letter); + letter-spacing: 0.08em; +} +.story-pencils small { + font: 12px/1.3 var(--story-mono); +} + +.story-fresh .story-cap, +.story-fresh .story-splash-title { + animation: story-pop 0.45s cubic-bezier(0.3, 1.6, 0.5, 1) both; +} +.story-fresh .story-balloon, +.story-fresh .story-chips { + animation: story-pop 0.45s 0.35s cubic-bezier(0.3, 1.6, 0.5, 1) both; +} +@keyframes story-pop { + from { + opacity: 0; + transform: scale(0.6); + } + to { + opacity: 1; + } +} + +@container story-sheet (max-width: 1000px) { + .story-page { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .story-panel { + grid-column: span var(--story-span2, 1); + } + .story-pencils { + grid-column: span var(--story-span2, 1); + } +} +@container story-sheet (max-width: 600px) { + .story-page { + grid-template-columns: minmax(0, 1fr); + grid-auto-rows: auto; + gap: 10px; + padding: 10px; + } + .story-panel, + .story-pencils { + grid-column: auto; + } + .story-panel { + --story-panel-min: 300px; + } + .story-pencils { + height: 300px; + } + .story-panel[data-kind='complete'] { + --story-panel-min: 430px; + } + .story-splash { + flex: 1 1 auto; + max-width: none; + justify-content: flex-start; + /* Claude stands centred at the bottom on a phone: the words end above it. */ + margin-bottom: 170px; + } + .story-splash-title { + font-size: 36px; + } + .story-balloon { + max-width: 52%; + } +} + +/* The wire: beside the sheet, a column as tall as the main area's visible height (story-mode.js + measures it), sticky, its list scrolling inside. It starts at the top of the main area even when + a note sits above the sheet: pulled up by the note's height, which the note's own margin clears. */ +.story-wire { + display: flex; + flex-direction: column; + min-height: 0; + height: var(--story-view-h, calc(100vh - 120px)); + margin-top: calc(-1 * var(--story-note-h, 0px)); + position: sticky; + /* 0: a sticky box already stops inside its scroll container's padding. */ + top: 0; + background: var(--story-sheet); + border: 2.5px solid var(--story-ink); +} +.story-wire[hidden] { + display: none; +} +@media (max-width: 1100px) { + .story-wire { + position: static; + height: auto; + max-height: 460px; + margin-top: 0; + } +} +.story-wire-head { + padding: 11px 13px 10px; + border-bottom: 2px solid var(--story-ink); +} +.story-wire-title { + margin: 0; + font: 22px/1 var(--story-letter); +} +.story-wire-sub { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.4; + color: var(--story-muted); +} +.story-wire-list { + flex: 1; + min-height: 0; + margin: 0; + padding: 4px 0 10px; + list-style: none; + overflow: auto; +} +.story-wire-group { + padding: 12px 13px 3px; + font: 16px/1.1 var(--story-letter); + color: var(--story-muted); +} +.story-wire-group h3 { + margin: 0; + font: inherit; +} +.story-ev { + display: grid; + grid-template-columns: 62px minmax(0, 1fr); + gap: 8px; + padding: 4px 13px; + font: 11.5px/1.45 var(--story-mono); + font-variant-numeric: tabular-nums; +} +.story-ev-time { + color: var(--story-muted); +} +.story-ev-body { + overflow-wrap: anywhere; +} +.story-ev-who { + color: var(--story-muted); +} +.story-link { + padding: 0; + border: 0; + background: none; + text-align: left; + font: inherit !important; + text-decoration: underline dotted; + text-decoration-thickness: 1.5px; + text-underline-offset: 3px; + overflow-wrap: anywhere; +} +.story-link:hover { + color: var(--story-yellow) !important; +} +.story-tag { + display: inline-block; + padding: 0 5px; + font-style: normal; + font-size: 10.5px; + color: var(--story-muted); + border: 1.5px solid currentColor; + border-radius: 3px; +} +.story-tag--bad { + color: var(--story-bad-text); +} +.story-tag--ok { + color: var(--story-go-text); +} +.story-tag--pending { + color: var(--story-wait-text); +} +/* A row that just came in slides up a little and fades in: transform and opacity only, so the list's + height (and the glide to its bottom) never moves with it; 6px stays inside the list's bottom padding. */ +.story-wire-list > .is-new { + animation: story-wire-in 0.28s cubic-bezier(0.2, 0.7, 0.3, 1) both; +} +@keyframes story-wire-in { + from { + opacity: 0; + transform: translateY(6px); + } +} +.story-wire-list:focus { + outline: none; +} +.story-wire-list:focus-visible { + outline: 3px solid var(--story-cyan); + outline-offset: -3px; +} +/* The pill sits on the list's bottom edge: a zero-height foot, so showing it moves nothing. */ +.story-wire-foot { + position: relative; + height: 0; +} +.story-overlay .story-wire-pill { + position: absolute; + bottom: 10px; + left: 50%; + z-index: 1; + translate: -50% 0; + display: inline-flex; + align-items: center; + min-height: 30px; + padding: 4px 14px; + font: 700 12.5px/1 var(--story-mono); + white-space: nowrap; + color: var(--story-line); + background: var(--story-yellow); + border: 2px solid var(--story-line); + border-radius: 999px; + box-shadow: 0 3px 0 rgb(0 0 0 / 35%); + animation: story-wire-pill-in 0.2s ease-out both; +} +.story-overlay .story-wire-pill:hover { + box-shadow: 0 4px 0 rgb(0 0 0 / 45%); +} +.story-overlay .story-wire-pill[hidden] { + display: none; +} +@keyframes story-wire-pill-in { + from { + opacity: 0; + transform: translateY(6px); + } +} + +/* Drawer */ +.story-drawer { + position: absolute; + z-index: 5; + /* Below the bar, which stays usable while the drawer is open (story-mode.js measures it). */ + top: var(--story-bar-h, 0px); + right: 0; + bottom: 0; + width: var(--story-drawer-w); + display: flex; + flex-direction: column; + padding-top: env(safe-area-inset-top, 0px); + padding-right: env(safe-area-inset-right, 0px); + background: var(--story-sheet); + border-left: 3px solid var(--story-ink); +} +.story-drawer[hidden] { + display: none; +} +@media (max-width: 900px) { + .story-overlay { + --story-drawer-w: 100vw; + } + .story-overlay.has-drawer .story-main { + padding-right: max(16px, env(safe-area-inset-right, 0px)); + } + .story-drawer { + top: var(--story-bar-h, 18vh); + left: 0; + width: auto; + height: auto; + border-left: 0; + border-top: 3px solid var(--story-ink); + padding-left: env(safe-area-inset-left, 0px); + } +} +.story-drawer-head { + position: relative; + display: grid; + gap: 6px; + padding: 14px 60px 12px 18px; + border-bottom: 2px solid var(--story-ink); +} +.story-drawer-meta { + display: flex; + align-items: center; + gap: 10px; +} +.story-drawer-kind { + padding: 3px 8px 2px; + font: 16px/1 var(--story-letter); + background: var(--story-cap); + color: var(--story-cap-ink); + border: 2px solid var(--story-cap-ink); +} +.story-drawer-title { + margin: 0; + font: 500 13.5px/1.35 var(--story-mono); + overflow-wrap: anywhere; +} +.story-drawer-sub { + margin: 0; + font-size: 13.5px; + line-height: 1.4; + color: var(--story-muted); +} +.story-drawer-close { + position: absolute; + right: 12px; + top: 12px; +} +.story-drawer-body { + position: relative; + flex: 1; + min-height: 0; + overflow: auto; + padding: 4px 0 28px; +} +.story-btn-s { + padding: 4px 10px 3px; + font: 15px/1 var(--story-letter) !important; + background: var(--story-panel); + border: 2px solid var(--story-ink); +} +.story-out-files { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.story-df-head, +.story-out-head { + position: sticky; + top: -4px; + z-index: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 12px 18px 8px; + background: var(--story-sheet); + border-bottom: 1.5px solid var(--story-rule); +} +.story-df + .story-df .story-df-head, +.story-out + .story-out .story-out-head { + border-top: 2px solid var(--story-ink); +} +.story-df-path, +.story-out-cmd { + flex: 1 1 220px; + white-space: pre-wrap; + font: 500 12.5px/1.35 var(--story-mono); + font-variant-ligatures: none; + overflow-wrap: anywhere; +} +.story-cnt { + font: 500 12.5px/1 var(--story-mono); +} +.story-cnt-a { + color: var(--story-go-text); +} +.story-cnt-d { + color: var(--story-bad-text); +} +.story-code { + font: 12px/1.65 var(--story-mono); + /* Code as written: `!==` stays three characters. */ + font-variant-ligatures: none; +} +.story-ln { + display: grid; + grid-template-columns: 3.2em 3.2em 1.6em minmax(0, 1fr); + padding-right: 14px; +} +.story-ln-no { + padding-right: 8px; + text-align: right; + color: var(--story-muted); + user-select: none; + font-variant-numeric: tabular-nums; +} +.story-ln-mk { + text-align: center; + color: var(--story-muted); + user-select: none; +} +.story-ln-tx { + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.story-ln--add { + background: color-mix(in srgb, var(--story-go) 17%, transparent); +} +.story-ln--add .story-ln-mk { + color: var(--story-go-text); +} +.story-ln--del { + background: color-mix(in srgb, var(--story-magenta) 15%, transparent); +} +.story-ln--del .story-ln-mk { + color: var(--story-bad-text); +} +.story-ln--hunk { + display: block; + padding: 2px 18px; + color: var(--story-muted); + background: color-mix(in srgb, var(--story-cyan) 12%, transparent); +} +.story-arrive { + animation: story-arrive 1.1s ease-out both; +} +@keyframes story-arrive { + 0% { + clip-path: inset(0 100% 0 0); + background: color-mix(in srgb, var(--story-yellow) 45%, transparent); + } + 45% { + clip-path: inset(0 0 0 0); + background: color-mix(in srgb, var(--story-yellow) 45%, transparent); + } +} +.story-out-desc, +.story-empty, +.story-answer-line { + margin: 0; + padding: 8px 18px 0; + font-size: 13.5px; + color: var(--story-muted); +} +.story-pre { + margin: 0; + padding: 8px 18px; + font: 12px/1.6 var(--story-mono); + font-variant-ligatures: none; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.story-prompt { + font-family: inherit; + font-size: 14px; + line-height: 1.55; +} +.story-h { + margin: 16px 18px 0; + font: 17px/1.1 var(--story-letter); +} +.story-helper-list { + margin: 4px 0 0; + padding: 0; + list-style: none; +} +.story-helper-list .story-ev { + grid-template-columns: minmax(0, 1fr); + padding: 3px 18px; +} +.story-todos { + margin: 8px 0 0; + padding: 0 18px 0 40px; + font-size: 14.5px; + line-height: 1.6; +} +.story-todos .is-done { + color: var(--story-go); + text-decoration: line-through; +} +/* The answer you picked: chosen, not crossed off. */ +.story-todos .is-chosen { + color: var(--story-go); + font-weight: 650; +} +.story-todos .is-chosen::before { + content: '✓ '; +} + +/* Composer (story-mode.js _storyCompose*): a message to the session on the page, across the bottom. + Never in Save image (that draws the page only). The drawer ends above it, so a message can be + written while a drawer is open; story-mode.js measures it into --story-compose-h. */ +.story-overlay .story-drawer { + bottom: var(--story-compose-h, 0px); +} +.story-compose { + grid-row: 3; + min-width: 0; + padding: 8px max(16px, env(safe-area-inset-right, 0px)) calc(7px + env(safe-area-inset-bottom, 0px)) + max(16px, env(safe-area-inset-left, 0px)); + background: var(--story-sheet); + border-top: 2px solid var(--story-ink); +} +/* One prompt bar: the turns you sent (picker and arrows) on the left, the next message beside them, + the hint under the field. On a narrow screen the turns take a row of their own above it. */ +.story-compose-row { + display: grid; + grid-template-columns: minmax(220px, 400px) minmax(0, 1fr) auto; + grid-template-areas: + 'nav field send' + '. hint .'; + align-items: end; + gap: 4px 8px; +} +.story-compose .story-nav { + grid-area: nav; + min-width: 0; +} +/* As tall as the field beside it. */ +.story-compose .story-nav .story-btn { + min-width: 44px; + height: 44px; +} +.story-compose .story-turns { + max-width: none; + height: 44px; +} +@media (max-width: 760px) { + .story-compose-row { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'nav nav' + 'field send' + 'hint hint'; + } +} +.story-compose .story-compose-field { + grid-area: field; + min-width: 0; + box-sizing: border-box; + /* One line to about five (story-mode.js grows it to its text), then it scrolls inside. 16px keeps + iOS from zooming the page when the field takes focus. */ + min-height: 44px; + max-height: calc(5 * 1.4em + 22px); + margin: 0; + padding: 9px 11px; + font: 16px/1.4 var(--story-body); + color: var(--story-ink); + background: var(--story-panel); + border: 2px solid var(--story-ink); + border-radius: 0; + resize: none; + overflow-y: auto; +} +.story-compose .story-compose-field::placeholder { + color: var(--story-muted); + opacity: 1; +} +.story-compose .story-compose-field[readonly] { + color: var(--story-muted); + border-style: dashed; +} +.story-compose .story-compose-send { + grid-area: send; + min-width: 76px; + height: 44px; + padding: 0 16px; + font: 20px/1 var(--story-letter); + background: var(--story-cap); + color: var(--story-cap-ink); + border: 2.5px solid var(--story-cap-ink); + border-radius: 0; +} +.story-compose .story-compose-send:disabled { + opacity: 0.4; + cursor: default; +} +.story-compose-hint { + grid-area: hint; + margin: 0; + font: 12px/1.35 var(--story-body); + color: var(--story-muted); +} +/* Claude waits on you: the one hint that needs noticing (a text token, 4.5:1 in both skins). */ +.story-compose[data-state='waiting'] .story-compose-hint { + color: var(--story-wait-text); + font-weight: 650; +} + +/* Touch screens: every control a finger can hit (44 px), and the small type a little larger. */ +@media (pointer: coarse) { + .story-btn, + .story-drawer-close { + min-width: 44px; + min-height: 44px; + } + .story-seg-btn, + .story-save, + .story-btn-s, + .story-chips li > * { + min-height: 44px; + display: inline-flex; + align-items: center; + } + .story-link { + padding-block: 6px; + } + .story-turns { + min-height: 44px; + } + .story-overlay .story-wire-pill { + min-height: 44px; + } + /* "Enter sends. Shift+Enter adds a line." means nothing on a touch keyboard: the bar keeps its + height for the page, and the hints that matter (Claude waits, the session is gone) still show. */ + .story-compose[data-state='ready'] .story-compose-hint { + display: none; + } + .story-cap-dir, + .story-ev, + .story-code, + .story-pre { + font-size: 13px; + } +} +/* A phone on its side: the sheet's head takes less of the little height there is. */ +@media (max-height: 480px) { + .story-drawer-head { + gap: 2px; + padding-block: 6px; + } + .story-drawer-sub { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .story-fresh *, + .story-arrive, + .story-live i, + .story-wire-list > .is-new, + .story-overlay .story-wire-pill { + animation: none !important; + } +} diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js new file mode 100644 index 000000000..620b41e72 --- /dev/null +++ b/src/web/public/story-mode.js @@ -0,0 +1,4905 @@ +/** + * @fileoverview Story Mode: the active Claude Code session's turns drawn as comic pages, live. + * + * Opt-in header button (`showStoryButton`, per-device, default OFF; hidden on phones by mobile.css). + * The overlay asks `GET /api/sessions/:id/story` for one turn as a page of panels (server: + * src/story-mode.ts + src/web/routes/story-routes.ts) and redraws when the session shows activity: + * the transcript tool events and the stop / prompt-submitted hooks (app.js connectSSE registers + * them and calls `_storyOnActivity`), plus a slow poll while the turn is still running, since a + * transcript line can land without any of those events. One request in flight at a time, debounced. + * + * Every panel is a canvas drawn by a small frame engine in the style of Remotion: a scene is a pure + * function of its frame number, with `interpolate` and `spring` helpers. New panels animate in from + * frame 0; panels that were already there keep their clock, so a live refresh never restarts them. + * Under `prefers-reduced-motion` every panel is drawn once, at its finished frame. + * + * ⚠️ Everything on the page comes from the agent's transcript and is untrusted: it is written with + * `textContent` only, and the containers carry `data-i18n-skip`. A file path opens through the + * existing `openFilePreview()` (workspace confinement, attachment routes for outside paths, remote + * hosts), never a new file route; editing goes through the file viewer's own edit mode. + * ⚠️ The overlay owns Escape while it is open (a window capture listener runs before app.js's + * document listener), one layer per press: a file preview opened from a panel, then the drawer, + * then the overlay. A folder grid on top and a preview in edit mode keep their own keys. + * + * @mixin Extends CodemanApp.prototype via Object.assign + * @dependency app.js (this.activeSessionId, this.sessions, this.loadAppSettingsFromStorage, this.getDefaultSettings) + * @dependency api-client.js (_api), panels-ui.js (openFilePreview), constants.js (findFilePathLinks, resolveLinkedFilePath) + * @loadorder 12.58 of 16, after git-status-ui.js, before entrance-animations.js + */ + +/** Activity bursts (one tool call fires start and end) collapse into one refresh. */ +const STORY_REFRESH_DEBOUNCE_MS = 800; +/** While a turn runs, refresh this often even without events (text lines raise none). */ +const STORY_POLL_MS = 4000; +/** How often a finished newest page is checked for a turn no hook announced. */ +const STORY_IDLE_POLL_MS = 15000; +/** A /story request that has not settled by then counts as failed, so the view never wedges. */ +const STORY_REQUEST_TIMEOUT_MS = 30000; +/** + * Widest stage a scene is drawn on, in virtual units. A panel stretched across a whole row keeps its + * art together in a centered stage instead of spreading it to both ends; the background still fills it. + */ +const STORY_STAGE_MAX = 680; +/** + * Redraw rates (frames per second). The clocks run in real time whatever the rate; only how often a + * panel is repainted changes. An entrance plays smoothly, the newest panel of a running turn stays + * lively, and a settled panel keeps its blinks and bobs at a fraction of the cost (measured: every + * panel at display rate cost ~80% of a core with five panels on a 2x screen). + */ +const STORY_FPS_INTRO = 30; +/** Panel backgrounds kept for reuse (kind × size × palette). */ +const STORY_BG_CACHE = 32; +/** + * And at most this many bytes of them: rows grow with the window, and 32 backgrounds of a 1440p + * screen at 2x came to 342 MB (a panel still showing one keeps it; only reuse is lost). + */ +const STORY_BG_CACHE_BYTES = 160e6; +/** Horizontal bands a new background is built in, one per frame. */ +const STORY_BG_BANDS = 4; +const storyBgCache = new Map(); +/** Milliseconds of drawing per frame; panels past it wait for the next frame (no long tasks). */ +const STORY_FRAME_BUDGET_MS = 10; +/** Panels drawn per frame at most: the raster cost lands at paint time, where the JS budget cannot see it. */ +const STORY_DRAWS_PER_FRAME = 2; +const STORY_FPS_LIVE = 15; +const STORY_FPS_SETTLED = 6; +/** A settled panel animates this long, then holds still until the pointer wakes it again. */ +const STORY_SETTLED_AWAKE_MS = 20000; + +/** + * The comic engine: drawing kit, the agent character and one scene per panel kind. Pure drawing; + * no DOM beyond the canvas it is handed. `hit()` records clickable regions in virtual units. + */ +const StoryArt = (() => { + const FPS = 30; + const HERO = 72; + const VH = 260; + const TAU = Math.PI * 2; + const DISPLAY = "'Codeman Story Display', Impact, 'Arial Black', sans-serif"; + const LETTER = "'Codeman Story Letter', 'Comic Sans MS', 'Chalkboard SE', cursive"; + const MONO = "'JetBrains Mono', ui-monospace, Menlo, monospace"; + const C = {}; + + const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); + const easeOut = (t) => 1 - Math.pow(1 - t, 3); + function interpolate(x, [a, b], [c, d], ease) { + let t = clamp((x - a) / (b - a), 0, 1); + if (ease) t = ease(t); + return c + (d - c) * t; + } + function spring(f, { damping = 10, stiffness = 100, mass = 1 } = {}) { + if (f <= 0) return 0; + const w0 = Math.sqrt(stiffness / mass); + const z = damping / (2 * Math.sqrt(stiffness * mass)); + const t = f / FPS; + if (z < 1) { + const wd = w0 * Math.sqrt(1 - z * z); + return 1 - Math.exp(-z * w0 * t) * (Math.cos(wd * t) + ((z * w0) / wd) * Math.sin(wd * t)); + } + return 1 - Math.exp(-w0 * t) * (1 + w0 * t); + } + const rnd = (i) => { + const x = Math.sin(i * 127.1 + 311.7) * 43758.5453; + return x - Math.floor(x); + }; + + const TOKENS = ['panel', 'ink', 'line', 'cyan', 'magenta', 'yellow', 'go', 'balloon', 'balloon-ink']; + function readColors(root) { + const cs = getComputedStyle(root); + for (const t of TOKENS) + C[t.replace(/-(\w)/g, (_, c) => c.toUpperCase())] = cs.getPropertyValue(`--story-${t}`).trim(); + C.key = TOKENS.map((t) => cs.getPropertyValue(`--story-${t}`).trim()).join('|'); + } + + // ── Drawing kit ── + function rr(ctx, x, y, w, h, r) { + ctx.beginPath(); + if (ctx.roundRect) { + ctx.roundRect(x, y, w, h, r); + return; + } + r = Math.min(r, w / 2, h / 2); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + } + function inked(ctx, fill, lw = 3) { + if (fill) { + ctx.fillStyle = fill; + ctx.fill(); + } + ctx.lineWidth = lw; + ctx.strokeStyle = C.line; + ctx.stroke(); + } + function base(ctx, W, H) { + ctx.fillStyle = C.panel; + ctx.fillRect(0, 0, W, H); + } + /** While a background is built in bands (one per frame), the band being drawn: dots outside it are skipped. */ + let band = null; + function inBand(top, bottom, fn) { + band = { top, bottom }; + try { + fn(); + } finally { + band = null; + } + } + function halftone(ctx, W, H, color, fx, fy, { cell = 9, rmax = 3.6, reach = 1, alpha = 1 } = {}) { + const diag = Math.hypot(W, H) * reach; + const R = Math.hypot(W, H) / 2 + cell; + const k = Math.SQRT1_2; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + for (let gy = -R; gy <= R; gy += cell) { + for (let gx = -R; gx <= R; gx += cell) { + const ux = gx * k - gy * k + W / 2; + const uy = gx * k + gy * k + H / 2; + if (ux < -cell || uy < -cell || ux > W + cell || uy > H + cell) continue; + if (band && (uy < band.top - cell || uy > band.bottom + cell)) continue; + const r = rmax * (1 - Math.hypot(ux - fx, uy - fy) / diag); + if (r < 0.4) continue; + ctx.moveTo(ux + r, uy); + ctx.arc(ux, uy, r, 0, TAU); + } + } + ctx.fill(); + ctx.restore(); + } + function rays(ctx, W, H, cx, cy, n, color, alpha = 1, rot = 0) { + const R = Math.hypot(W, H) * 1.2; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const a0 = rot + (i / n) * TAU; + const a1 = rot + ((i + 0.5) / n) * TAU; + ctx.moveTo(cx, cy); + ctx.lineTo(cx + R * Math.cos(a0), cy + R * Math.sin(a0)); + ctx.lineTo(cx + R * Math.cos(a1), cy + R * Math.sin(a1)); + ctx.closePath(); + } + ctx.fill(); + ctx.restore(); + } + function focusLines(ctx, W, H, cx, cy, n, color, alpha) { + const R = Math.hypot(W, H); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.strokeStyle = color; + for (let i = 0; i < n; i++) { + const a = (i / n) * TAU + rnd(i) * 0.05; + const r0 = 104 + rnd(i + 50) * 70; + ctx.lineWidth = 0.6 + rnd(i + 20) * 1.8; + ctx.beginPath(); + ctx.moveTo(cx + Math.cos(a) * r0, cy + Math.sin(a) * r0); + ctx.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R); + ctx.stroke(); + } + ctx.restore(); + } + function burst(ctx, cx, cy, ro, ri, n, fill, rot = 0, seed = 1) { + ctx.beginPath(); + for (let i = 0; i < n * 2; i++) { + const a = rot + (i / (n * 2)) * TAU; + const r = i % 2 ? ri : ro * (0.82 + rnd(i + seed) * 0.3); + const px = cx + Math.cos(a) * r; + const py = cy + Math.sin(a) * r; + if (i) ctx.lineTo(px, py); + else ctx.moveTo(px, py); + } + ctx.closePath(); + ctx.lineJoin = 'miter'; + inked(ctx, fill, 3.5); + ctx.lineJoin = 'round'; + } + function fitSize(ctx, text, maxW, maxSize) { + ctx.font = `100px ${DISPLAY}`; + return Math.min(maxSize, (maxW / Math.max(1, ctx.measureText(text).width)) * 100); + } + function sfx(ctx, text, x, y, size, fill, rot = 0, scale = 1) { + if (scale <= 0.01) return; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(rot); + ctx.scale(scale, scale); + ctx.font = `${size}px ${DISPLAY}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.lineJoin = 'round'; + ctx.fillStyle = C.line; + ctx.fillText(text, size * 0.08, size * 0.08); + ctx.lineWidth = size * 0.14; + ctx.strokeStyle = C.line; + ctx.strokeText(text, 0, 0); + ctx.fillStyle = fill; + ctx.fillText(text, 0, 0); + ctx.restore(); + } + /** Text cut to fit a width, with an ellipsis. Canvas only: the DOM never sees it. */ + function fitText(ctx, text, maxW) { + if (ctx.measureText(text).width <= maxW) return text; + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (ctx.measureText(`${text.slice(0, mid)}…`).width <= maxW) lo = mid; + else hi = mid - 1; + } + return `${text.slice(0, lo)}…`; + } + function limb(ctx, a, b, color, w, bend = 0) { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const len = Math.hypot(dx, dy) || 1; + const cx = (a[0] + b[0]) / 2 - (dy / len) * bend; + const cy = (a[1] + b[1]) / 2 + (dx / len) * bend; + ctx.beginPath(); + ctx.moveTo(a[0], a[1]); + ctx.quadraticCurveTo(cx, cy, b[0], b[1]); + ctx.lineCap = 'round'; + ctx.strokeStyle = C.line; + ctx.lineWidth = w + 5; + ctx.stroke(); + ctx.strokeStyle = color; + ctx.lineWidth = w; + ctx.stroke(); + } + function hand(ctx, p, r = 6.5) { + ctx.beginPath(); + ctx.arc(p[0], p[1], r, 0, TAU); + inked(ctx, C.balloon, 2.5); + } + function face(ctx, hy, f, mood) { + const ey = hy - 3; + ctx.fillStyle = C.balloonInk; + ctx.strokeStyle = C.balloonInk; + ctx.lineCap = 'round'; + ctx.lineWidth = 3; + if (mood === 'happy') { + for (const ex of [-12, 12]) { + ctx.beginPath(); + ctx.arc(ex, ey + 4, 6, Math.PI * 1.1, Math.PI * 1.9); + ctx.stroke(); + } + } else if (mood === 'shock') { + for (const ex of [-12, 12]) { + ctx.beginPath(); + ctx.arc(ex, ey, 6.5, 0, TAU); + ctx.lineWidth = 2.5; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(ex, ey, 2.4, 0, TAU); + ctx.fill(); + } + } else { + const h = f % 117 > 112 ? 2 : mood === 'focus' ? 9 : 14; + for (const ex of [-12, 12]) { + rr(ctx, ex - 3, ey - h / 2, 6, h, 3); + ctx.fill(); + } + } + if (mood !== 'happy' && mood !== 'neutral') { + ctx.lineWidth = 2.6; + const up = mood === 'shock' ? 4 : 0; + for (const s of [-1, 1]) { + ctx.beginPath(); + if (mood === 'focus') { + ctx.moveTo(s * 6, hy - 13); + ctx.lineTo(s * 17, hy - 16); + } else { + ctx.moveTo(s * 7, hy - 16 - up); + ctx.lineTo(s * 17, hy - 19 - up); + } + ctx.stroke(); + } + } + ctx.lineWidth = 3; + ctx.beginPath(); + if (mood === 'happy' || mood === 'eager') { + ctx.moveTo(-9, hy + 11); + ctx.quadraticCurveTo(0, hy + 23, 9, hy + 11); + ctx.closePath(); + ctx.fill(); + } else if (mood === 'shock') { + ctx.ellipse(0, hy + 15, 4.5, 5.5, 0, 0, TAU); + ctx.fill(); + } else if (mood === 'focus') { + ctx.moveTo(-6, hy + 15); + ctx.lineTo(6, hy + 13); + ctx.stroke(); + } else { + ctx.arc(0, hy + 9, 6, Math.PI * 0.2, Math.PI * 0.8); + ctx.stroke(); + } + } + /** The agent: a terminal-headed character. (x, y) is the point between its feet. */ + /** Every edit an event made: an Edit/Write call's one, or the files a Bash here-document wrote. */ + const editsOf = (e) => (e?.edit ? [e.edit] : e?.edits || []); + /** + * Where Claude's head was last drawn (stage units): the speech balloon is placed over it, and no + * balloon covers `face`, the box of his head. + */ + let speaker = null; + /** A scene with no Claude in it names the spot its balloon should point at. */ + function aim(x, top) { + speaker = { x, top }; + } + function takeSpeaker() { + const s = speaker; + speaker = null; + return s; + } + function agent(ctx, x, y, o = {}) { + const s = o.scale ?? 1; + const body = o.body ?? C.cyan; + const L = o.l ?? [-32, -44]; + const R = o.r ?? [32, -44]; + const hy = -120 + (o.headBob ?? 0); + // Claude is the one drawn in his own colour (no `body` given); a helper never holds the balloon, + // not even the cyan fourth one. `rise`: how much higher a jump lifts him on other frames. + if (!o.body) { + const lift = o.rise ?? 0; + const t = o.tilt ?? 0; + // The head turns with the body about the feet: its centre moves sideways by the tilt. + const cy = (hy + 1) * s; + const fx = x - cy * Math.sin(t); + const fy = y + cy * Math.cos(t) - lift; + speaker = { + x, + top: y + (hy - 36) * s - lift, + face: { x: fx - 33 * s, y: fy - 28 * s, w: 66 * s, h: 56 * s + lift }, + }; + } + ctx.save(); + ctx.translate(x, y); + ctx.rotate(o.tilt ?? 0); + ctx.scale(s, s); + ctx.lineJoin = 'round'; + if (!o.noShadow) { + ctx.save(); + ctx.globalAlpha = 0.18; + ctx.fillStyle = C.line; + ctx.beginPath(); + ctx.ellipse(0, 2, 30, 6, 0, 0, TAU); + ctx.fill(); + ctx.restore(); + } + limb(ctx, [-11, -38], [-14, -4], body, 10); + limb(ctx, [11, -38], [14, -4], body, 10); + for (const fx of [-16, 16]) { + ctx.beginPath(); + ctx.ellipse(fx, -2, 9, 5, 0, 0, TAU); + inked(ctx, body, 2.5); + } + rr(ctx, -24, -88, 48, 54, 15); + inked(ctx, body, 3); + rr(ctx, -13, -76, 26, 17, 4); + inked(ctx, C.balloon, 2); + ctx.fillStyle = C.balloonInk; + ctx.font = `600 10px ${MONO}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('>_', 0, -67.5); + limb(ctx, [-19, -78], L, body, 9, o.bendL ?? -10); + limb(ctx, [19, -78], R, body, 9, o.bendR ?? 10); + hand(ctx, L); + hand(ctx, R); + ctx.beginPath(); + ctx.moveTo(0, hy - 27); + ctx.lineTo(0, hy - 41); + ctx.strokeStyle = C.line; + ctx.lineWidth = 3; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(0, hy - 45, 6, 0, TAU); + inked(ctx, C.yellow, 2.5); + rr(ctx, -33, hy - 27, 66, 56, 17); + inked(ctx, C.balloon, 3.2); + face(ctx, hy, o.frame ?? 0, o.mood ?? 'neutral'); + ctx.restore(); + const at = (p) => [x + p[0] * s, y + p[1] * s]; + return { l: at(L), r: at(R), head: at([0, hy]) }; + } + function labelLines(name) { + const dot = name.lastIndexOf('.'); + const stem = dot > 0 ? name.slice(0, dot) : name; + const ext = dot > 0 ? name.slice(dot) : ''; + // Break after a separator or between camelCase words, keeping every character of the name. + const words = stem + .replace(/([a-z])([A-Z])/g, '$1\u0001$2') + .split('\u0001') + .flatMap((part) => part.match(/[^\s_.-]+[\s_.-]*|[\s_.-]+/g) || []); + const lines = []; + let cur = ''; + for (const w of words) { + if ((cur + w).trimEnd().length > 9 && cur) { + lines.push(cur.trimEnd()); + cur = w; + } else cur += w; + } + cur = cur.trimEnd(); + if ((cur + ext).length > 11) lines.push(cur, ext); + else lines.push(cur + ext); + return lines.filter(Boolean).slice(0, 3); + } + function card(ctx, x, y, w, h, rot, label, hi) { + ctx.save(); + ctx.translate(x + w / 2, y + h / 2); + ctx.rotate(rot); + rr(ctx, -w / 2, -h / 2, w, h, 4); + inked(ctx, C.balloon, 2.5); + const lines = labelLines(label); + ctx.fillStyle = C.balloonInk; + ctx.font = `600 8px ${MONO}`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + lines.forEach((l, i) => ctx.fillText(fitText(ctx, l, w - 9), -w / 2 + 5, -h / 2 + 6 + i * 9)); + ctx.globalAlpha = 0.28; + for (let i = 0, yy = -h / 2 + 12 + lines.length * 9; yy < h / 2 - 7; i++, yy += 8) { + ctx.fillRect(-w / 2 + 5 + (i % 3 === 1 ? 6 : 0), yy, (w - 14) * (0.4 + rnd(i + label.length) * 0.5), 3.2); + } + ctx.globalAlpha = 1; + if (hi > 0) { + ctx.globalAlpha = hi; + rr(ctx, -w / 2 - 5, -h / 2 - 5, w + 10, h + 10, 8); + ctx.strokeStyle = C.magenta; + ctx.lineWidth = 3.5; + ctx.stroke(); + ctx.globalAlpha = 1; + } + ctx.restore(); + } + function magnifier(ctx, cx, cy, r, h) { + const dx = h[0] - cx; + const dy = h[1] - cy; + const d = Math.hypot(dx, dy) || 1; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(cx + (dx / d) * r, cy + (dy / d) * r); + ctx.lineTo(h[0] + (dx / d) * 6, h[1] + (dy / d) * 6); + ctx.strokeStyle = C.line; + ctx.lineWidth = 10; + ctx.stroke(); + ctx.strokeStyle = C.yellow; + ctx.lineWidth = 5; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, TAU); + ctx.save(); + ctx.globalAlpha = 0.3; + ctx.fillStyle = C.cyan; + ctx.fill(); + ctx.restore(); + ctx.lineWidth = 5; + ctx.strokeStyle = C.line; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(cx, cy, r * 0.62, Math.PI * 1.1, Math.PI * 1.45); + ctx.strokeStyle = C.balloon; + ctx.lineWidth = 3; + ctx.stroke(); + } + function cloud(ctx, cx, cy, w, h) { + const pts = []; + for (let i = 0; i < 11; i++) { + const a = (i / 11) * TAU; + pts.push([cx + Math.cos(a) * w * 0.42, cy + Math.sin(a) * h * 0.4, (w + h) * 0.1 * (0.85 + rnd(i + 3) * 0.3)]); + } + for (const [fill, pad] of [ + [C.line, 3], + [C.balloon, 0], + ]) { + ctx.fillStyle = fill; + for (const [x, y, r] of pts) { + ctx.beginPath(); + ctx.arc(x, y, r + pad, 0, TAU); + ctx.fill(); + } + ctx.beginPath(); + ctx.ellipse(cx, cy, w * 0.42 + pad, h * 0.4 + pad, 0, 0, TAU); + ctx.fill(); + } + } + function tag(ctx, x, y, text, director, alpha) { + ctx.save(); + ctx.globalAlpha = clamp(alpha, 0, 1); + ctx.font = director ? `600 10px ${MONO}` : `16px ${LETTER}`; + const shown = fitText(ctx, text, 110); + const w = ctx.measureText(shown).width; + rr(ctx, x - w / 2 - 7, y, w + 14, 19, 5); + inked(ctx, C.balloon, 2); + ctx.fillStyle = C.balloonInk; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(shown, x, y + 10.5); + ctx.restore(); + } + function pencil(ctx, h, t) { + const dx = t[0] - h[0]; + const dy = t[1] - h[1]; + const d = Math.hypot(dx, dy) || 1; + const ux = dx / d; + const uy = dy / d; + const back = [h[0] - ux * 16, h[1] - uy * 16]; + const neck = [t[0] - ux * 12, t[1] - uy * 12]; + ctx.lineCap = 'butt'; + ctx.beginPath(); + ctx.moveTo(back[0], back[1]); + ctx.lineTo(neck[0], neck[1]); + ctx.strokeStyle = C.line; + ctx.lineWidth = 12; + ctx.stroke(); + ctx.strokeStyle = C.yellow; + ctx.lineWidth = 7; + ctx.stroke(); + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(neck[0] - uy * 6, neck[1] + ux * 6); + ctx.lineTo(t[0], t[1]); + ctx.lineTo(neck[0] + uy * 6, neck[1] - ux * 6); + ctx.closePath(); + inked(ctx, C.balloon, 2); + ctx.beginPath(); + ctx.arc(t[0] - ux * 2, t[1] - uy * 2, 2.2, 0, TAU); + ctx.fillStyle = C.line; + ctx.fill(); + } + /** A page of code showing real diff lines (`lines` keep their ' '/'+'/'-' prefix). */ + function codePage(ctx, x, y, w, h, f, title, lines, stack) { + for (let k = Math.min(stack, 2); k > 0; k--) { + rr(ctx, x + k * 6, y - k * 6, w, h, 5); + inked(ctx, C.balloon, 2); + } + rr(ctx, x, y, w, h, 5); + inked(ctx, C.balloon, 2.5); + ctx.fillStyle = C.balloonInk; + ctx.font = `600 9px ${MONO}`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + ctx.fillText(fitText(ctx, title, w - 16), x + 8, y + 14); + ctx.font = `500 8px ${MONO}`; + const rows = Math.max(1, Math.floor((h - 26) / 11.5)); + if (!lines.length) { + // The edit is still running: rough lines and a blinking caret. + ctx.globalAlpha = 0.28; + for (let i = 0; i < Math.min(rows, 6); i++) { + ctx.fillRect(x + 10 + (i % 3 === 1 ? 10 : 0), y + 26 + i * 11.5, (w - 30) * (0.4 + rnd(i + 5) * 0.5), 4); + } + ctx.globalAlpha = 1; + if (f % 30 < 15) { + ctx.fillStyle = C.magenta; + ctx.fillRect(x + 10, y + 26 + Math.min(rows, 6) * 11.5, 6, 10); + } + return; + } + let k = 0; + lines.slice(0, rows).forEach((raw, i) => { + const ly = y + 30 + i * 11.5; + const mark = raw[0] === '+' ? '+ ' : raw[0] === '-' ? '− ' : ' '; + const text = fitText(ctx, mark + raw.slice(1).trim(), w - 16); + if (raw[0] !== '+' && raw[0] !== '-') { + ctx.globalAlpha = 0.62; + ctx.fillStyle = C.balloonInk; + ctx.fillText(text, x + 8, ly); + ctx.globalAlpha = 1; + return; + } + const p = interpolate(f, [18 + k * 6, 28 + k * 6], [0, 1], easeOut); + k++; + ctx.save(); + ctx.globalAlpha = 0.42; + ctx.fillStyle = raw[0] === '+' ? C.go : C.magenta; + ctx.fillRect(x + 3, ly - 8.5, (w - 6) * p, 11); + ctx.restore(); + ctx.save(); + ctx.beginPath(); + ctx.rect(x + 3, ly - 9, (w - 6) * p, 12); + ctx.clip(); + ctx.fillStyle = C.balloonInk; + ctx.fillText(text, x + 8, ly); + ctx.restore(); + }); + } + /** + * A check without counts (a build, a lint, a run whose summary was not recognised): one verdict + * stamp instead of a board, so no number is drawn that the run never reported. + */ + function verdict(ctx, x, y, label, ok, f) { + const w = 127; + const h = 92; + rr(ctx, x, y, w, h, 8); + inked(ctx, C.balloon, 2.5); + ctx.fillStyle = C.balloonInk; + ctx.font = `15px ${LETTER}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(fitText(ctx, label, w - 12), x + w / 2, y + 14); + const cx = x + w / 2; + const cy = y + 56; + const r = 22; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, TAU); + inked(ctx, ok === undefined ? C.balloon : ok ? C.go : C.magenta, 3); + ctx.save(); + ctx.translate(cx, cy); + ctx.strokeStyle = ok === undefined ? C.line : C.balloon; + ctx.lineWidth = 5; + ctx.lineCap = 'round'; + ctx.beginPath(); + if (ok === undefined) { + // Running: a hand that sweeps round the dial. + const a = (f / 9) % TAU; + ctx.moveTo(0, 0); + ctx.lineTo(Math.cos(a) * (r - 7), Math.sin(a) * (r - 7)); + } else if (ok) { + ctx.moveTo(-10, 1); + ctx.lineTo(-3, 8); + ctx.lineTo(11, -8); + } else { + ctx.moveTo(-8, -8); + ctx.lineTo(8, 8); + ctx.moveTo(8, -8); + ctx.lineTo(-8, 8); + } + ctx.stroke(); + ctx.restore(); + return { w, h }; + } + /** A board of check dots. `fail` dots are drawn red with a crack; `fill` is how many are in yet. */ + function board(ctx, x, y, label, dots, fail, fill) { + const cols = 7; + const g = 16; + const r = 5.5; + const rows = Math.ceil(dots / cols); + const w = 127; + const h = 30 + rows * g; + rr(ctx, x, y, w, h, 8); + inked(ctx, C.balloon, 2.5); + ctx.fillStyle = C.balloonInk; + ctx.font = `15px ${LETTER}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(fitText(ctx, label, w - 12), x + w / 2, y + 14); + for (let k = 0; k < dots; k++) { + const cx = x + 10 + (k % cols) * g + r; + const cy = y + 26 + Math.floor(k / cols) * g + r; + const on = k < fill; + const bad = on && k >= dots - fail; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, TAU); + inked(ctx, on ? (bad ? C.magenta : C.go) : C.balloon, 2); + if (bad) { + ctx.beginPath(); + ctx.moveTo(cx - r - 4, cy - r - 2); + ctx.lineTo(cx - 2, cy - 1); + ctx.lineTo(cx + 2, cy - 6); + ctx.lineTo(cx + r + 5, cy + 3); + ctx.strokeStyle = C.line; + ctx.lineWidth = 2; + ctx.stroke(); + } + } + return { w, h }; + } + /** A terminal card: the commands (`› …`), then, dimmer, the first lines one of them printed. */ + /** A test board's label: "1 test", "19 tests", or "1 error" when nothing even loaded. */ + function boardLabel(t, total) { + if (t && !t.passed && !t.failed && t.errors) return `${t.errors} error${t.errors === 1 ? '' : 's'}`; + return `${total} test${total === 1 ? '' : 's'}`; + } + /** `clear` ({ x, top, bottom }): a band something is held over (the magnifier); lines in it end left of `x`. */ + function terminal(ctx, x, y, w, h, lines, f, printed = [], clear = null) { + const room = (base) => + clear && base + 2 > clear.top && base - 8 < clear.bottom ? Math.min(w - 18, clear.x - x - 13) : w - 18; + rr(ctx, x, y, w, h, 7); + inked(ctx, C.line, 2.5); + ctx.fillStyle = C.balloonInk; + for (const [i, color] of [C.magenta, C.yellow, C.go].entries()) { + ctx.beginPath(); + ctx.arc(x + 11 + i * 10, y + 9, 3, 0, TAU); + ctx.fillStyle = color; + ctx.fill(); + } + ctx.font = `500 9px ${MONO}`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + const rows = Math.max(1, Math.floor((h - 24) / 14)); + lines.slice(0, rows).forEach((line, i) => { + const text = fitText(ctx, `› ${line}`, room(y + 30 + i * 14)); + const shown = Math.floor(interpolate(f, [8 + i * 12, 20 + i * 12], [0, text.length])); + ctx.fillStyle = i === Math.min(rows, lines.length) - 1 ? C.yellow : C.balloon; + ctx.fillText(text.slice(0, shown), x + 9, y + 30 + i * 14); + }); + const n = Math.min(rows, lines.length); + const out = printed.slice(0, Math.max(0, rows - n)); + out.forEach((line, i) => { + const text = fitText(ctx, line, room(y + 30 + (n + i) * 14)); + const at = 8 + (n + i) * 12; + const shown = Math.floor(interpolate(f, [at, at + 10], [0, text.length])); + ctx.save(); + ctx.globalAlpha = 0.72; + ctx.fillStyle = C.balloon; + ctx.fillText(text.slice(0, shown), x + 9, y + 30 + (n + i) * 14); + ctx.restore(); + }); + if (f % 30 < 15) { + ctx.fillStyle = C.balloon; + ctx.fillRect(x + 9, y + 22 + (n + out.length) * 14, 6, 10); + } + } + function globe(ctx, cx, cy, r, f) { + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, TAU); + inked(ctx, C.cyan, 3); + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, TAU); + ctx.clip(); + ctx.strokeStyle = C.line; + ctx.lineWidth = 2; + for (let k = -2; k <= 2; k++) { + ctx.beginPath(); + ctx.ellipse(cx, cy + k * r * 0.38, r, r * 0.12, 0, 0, TAU); + ctx.stroke(); + } + const spin = (f / 40) % 1; + for (let k = 0; k < 3; k++) { + const p = ((k / 3 + spin) % 1) * 2 - 1; + ctx.beginPath(); + ctx.ellipse(cx, cy, Math.abs(p) * r, r, 0, 0, TAU); + ctx.stroke(); + } + ctx.restore(); + } + function star4(ctx, x, y, r, fill) { + ctx.beginPath(); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * TAU - Math.PI / 2; + const rad = i % 2 ? r * 0.32 : r; + const px = x + Math.cos(a) * rad; + const py = y + Math.sin(a) * rad; + if (i) ctx.lineTo(px, py); + else ctx.moveTo(px, py); + } + ctx.closePath(); + inked(ctx, fill, 2); + } + function baseName(path) { + const s = String(path || '').replace(/\/+$/, ''); + return s.slice(s.lastIndexOf('/') + 1) || s; + } + + // ── Scenes. `p` = { panel, page, director }, `hit(id, x, y, w, h, label)` registers a click target. ── + const SCENES = { + mission: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.cyan, W, H, { rmax: 4.4, reach: 0.9 }); + }, + draw(ctx, W, H, f, p, hit) { + const ax = Math.max(W * 0.78, W - 104); + const from = p.page?.events[p.panel.events[0]]?.origin; + hit( + 'prompt', + ax - 56, + 110, + 112, + 140, + from === 'task' ? 'Read the report' : from === 'peer' ? 'Read the message' : 'Read the whole prompt' + ); + const a = agent(ctx, ax, 244, { + frame: f, + mood: 'listen', + headBob: Math.sin(f / 16) * 1.6, + l: [-30, -42], + r: [30, -42], + }); + sfx( + ctx, + '!', + a.head[0] + 46, + a.head[1] - 34, + 46, + C.yellow, + 0.2, + spring(f - 16, { damping: 8, stiffness: 160 }) + ); + }, + }, + investigate: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.yellow, 0, H, { rmax: 4.6 }); + }, + draw(ctx, W, H, f, p, hit) { + const files = p.panel.files || []; + const ax = W - 58; + if (!files.length) { + const tw = Math.min(W * 0.58, 220); + // On a narrow panel the magnifier is held over the terminal: the lines it passes end short of + // it (its rim's reach over every frame of its sway), so it never hides their words. + const lens = { x: ax - 122, top: 125, bottom: 187 }; + terminal(ctx, 14, 92, tw, 130, p.panel.notes?.length ? p.panel.notes : ['looking around'], f, [], lens); + hit('list', 14, 92, tw, 130, 'See what Claude looked at'); + } else { + const shown = files.length > 8 ? files.slice(0, 7) : files; + const extra = files.length - shown.length; + const labels = shown.map(baseName).concat(extra ? [`+${extra} more`] : []); + const cw = 60; + const ch = 78; + const n = labels.length; + const step = n > 1 ? Math.min(70, Math.max(8, (ax - 70 - 14 - cw) / (n - 1))) : 0; + for (let i = 0; i < n; i++) { + const pr = spring(f - 4 - i * 5, { damping: 11, stiffness: 150 }); + if (pr <= 0.001) continue; + const cx = 14 + i * step; + const cy = 128 + (i % 2) * 14 + Math.sin(i * 1.7) * 4 - (1 - pr) * 70; + ctx.save(); + ctx.globalAlpha = clamp(pr * 1.5, 0, 1); + card(ctx, cx, cy, cw, ch, (i - n / 2 + 0.5) * 0.06, labels[i], 0); + ctx.restore(); + if (extra && i === n - 1) hit('list', cx, cy, cw, ch, `See all ${files.length} files`); + else hit(`file:${i}`, cx, cy, cw, ch, `Open ${labels[i]}`); + } + } + const a = agent(ctx, ax, 246, { + frame: f, + mood: 'focus', + l: [-64 + Math.sin(f / 22) * 9, -74 + Math.cos(f / 17) * 6], + r: [28, -40], + }); + magnifier(ctx, a.l[0] - 24, a.l[1] - 16, 22, a.l); + }, + }, + research: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.cyan, 0, 0, { rmax: 3.6 }); + }, + draw(ctx, W, H, f, p, hit) { + const gx = Math.min(W * 0.32, 130); + const pr = spring(f - 2, { damping: 12, stiffness: 140 }); + globe(ctx, gx, 160, 58 * pr + 0.1, f); + hit('list', gx - 60, 100, 120, 120, 'See what Claude looked up'); + const notes = p.panel.notes || []; + ctx.font = `600 8.5px ${MONO}`; + notes.slice(0, 3).forEach((note, i) => { + const tp = spring(f - 20 - i * 6, { damping: 12, stiffness: 140 }); + if (tp <= 0.01) return; + const tx = gx + 60; + const ty = 100 + i * 34; + const tw = Math.max(40, Math.min(W - tx - 70, 190)); + ctx.save(); + ctx.globalAlpha = clamp(tp, 0, 1); + rr(ctx, tx, ty, tw, 24, 5); + inked(ctx, C.balloon, 2); + ctx.fillStyle = C.balloonInk; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(fitText(ctx, note, tw - 12), tx + 6, ty + 12.5); + ctx.restore(); + }); + agent(ctx, W - 44, 252, { frame: f, scale: 0.62, mood: 'focus', l: [-34, -70], r: [30, -44] }); + }, + }, + plan: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.magenta, W, 0, { rmax: 3.4, reach: 1.1, alpha: 0.9 }); + }, + draw(ctx, W, H, f, p, hit) { + const cx = W * 0.44; + const cy = 168; + const cw = Math.min(W * 0.66, 300); + const chh = 132; + const pc = spring(f - 2, { damping: 12, stiffness: 140 }); + if (pc > 0.01) { + ctx.save(); + ctx.translate(cx, cy); + ctx.scale(pc, pc); + cloud(ctx, 0, 0, cw, chh); + const items = p.panel.todos?.length + ? p.panel.todos.map((t) => ({ text: t.text, done: t.done, box: true })) + : (p.panel.notes || []).map((t) => ({ text: t, done: false, box: false })); + ctx.font = `14px ${LETTER}`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + const shown = items.slice(0, 4); + shown.forEach((item, i) => { + const ly = -((shown.length - 1) * 19) / 2 + i * 19; + const lx = -cw * 0.36; + if (item.box) { + ctx.strokeStyle = C.balloonInk; + ctx.lineWidth = 1.8; + ctx.strokeRect(lx, ly - 6, 12, 12); + if (item.done) { + ctx.beginPath(); + ctx.moveTo(lx + 2, ly); + ctx.lineTo(lx + 5, ly + 4); + ctx.lineTo(lx + 11, ly - 5); + ctx.strokeStyle = C.go; + ctx.lineWidth = 2.5; + ctx.stroke(); + } + } + ctx.fillStyle = C.balloonInk; + ctx.fillText(fitText(ctx, item.text, cw * 0.66), lx + (item.box ? 18 : 0), ly + 1); + }); + if (items.length > shown.length) { + ctx.fillText(`+${items.length - shown.length} more`, -cw * 0.36 + 18, ((shown.length + 1) * 19) / 2); + } + ctx.restore(); + hit('plan', cx - cw * 0.45 * pc, cy - chh * 0.45 * pc, cw * 0.9 * pc, chh * 0.9 * pc, 'See the plan'); + } + const a = agent(ctx, W - 38, 256, { frame: f, scale: 0.52, mood: 'focus', l: [-26, -60], r: [30, -44] }); + const from = [a.head[0] - 22, a.head[1] - 18]; + const to = [cx + cw * 0.36, cy + chh * 0.38]; + [4, 6, 8].forEach((r, k) => { + const t = (k + 1) / 4; + const pp = clamp((pc - 0.6) / 0.4, 0, 1); + if (pp <= 0) return; + ctx.beginPath(); + ctx.arc(from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t, r * pp, 0, TAU); + inked(ctx, C.balloon, 2); + }); + }, + }, + backup: { + bg(ctx, W, H) { + base(ctx, W, H); + rays(ctx, W, H, W / 2, 190, 28, C.yellow, 0.55, 0.05); + halftone(ctx, W, H, C.cyan, W / 2, H, { cell: 10, rmax: 3, reach: 0.8, alpha: 0.7 }); + }, + draw(ctx, W, H, f, p, hit) { + const mx = W / 2; + agent(ctx, mx, 248, { + frame: f, + mood: 'eager', + l: [-44, -118 + Math.sin(f / 9) * 3], + r: [44, -118 + Math.cos(f / 9) * 3], + bendL: 12, + bendR: -12, + }); + const helpers = (p.panel.helpers || []).slice(0, 4); + const spots = [0.16, 0.84, 0.31, 0.69]; + const colors = [C.magenta, C.yellow, C.go, C.cyan]; + if (!helpers.length) { + // A message to another session: an envelope flies off. + const pr = spring(f - 8, { damping: 13, stiffness: 80 }); + const x = mx + (W * 0.84 - mx) * pr; + const y = 150 - Math.sin(clamp(pr, 0, 1) * Math.PI) * 50; + rr(ctx, x - 22, y - 14, 44, 28, 3); + inked(ctx, C.balloon, 2.5); + ctx.beginPath(); + ctx.moveTo(x - 22, y - 14); + ctx.lineTo(x, y + 2); + ctx.lineTo(x + 22, y - 14); + ctx.strokeStyle = C.line; + ctx.lineWidth = 2; + ctx.stroke(); + hit('list', x - 26, y - 18, 52, 36, 'See the message'); + return; + } + helpers.forEach((h, k) => { + const delay = 8 + k * 6; + const pr = spring(f - delay, { damping: 13, stiffness: 80 }); + if (pr <= 0.001) return; + const tx = W * spots[k]; + const dir = Math.sign(tx - mx) || 1; + const x = mx + (tx - mx) * pr; + const y = 150 + (224 - 150) * pr - Math.sin(clamp(pr, 0, 1) * Math.PI) * 60; + if (pr < 0.97) { + ctx.save(); + ctx.globalAlpha = clamp(1 - pr, 0, 1); + ctx.strokeStyle = C.ink; + ctx.lineWidth = 2.5; + ctx.lineCap = 'round'; + for (let s = 0; s < 3; s++) { + ctx.beginPath(); + ctx.moveTo(x - dir * (24 + s * 6), y - 64 + s * 14); + ctx.lineTo(x - dir * (74 + s * 14), y - 70 + s * 14); + ctx.stroke(); + } + ctx.restore(); + } + agent(ctx, x, y, { + frame: f + delay * 7, + scale: 0.62, + body: colors[k], + mood: 'happy', + tilt: dir * 0.35 * (1 - clamp(pr, 0, 1)), + l: [-30, -50], + r: [30, -50], + }); + if (pr > 0.85) + tag( + ctx, + clamp(x, 64, W - 64), + y + 9, + p.director ? h.type : h.task || h.name, + p.director, + (pr - 0.85) / 0.15 + ); + hit( + `helper:${h.event}`, + x - 30, + y - 112, + 60, + 142, + h.type === 'advisor' ? 'See the second opinion' : `See what the helper did: ${h.task || h.name}` + ); + }); + }, + }, + fix: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.cyan, 0, H, { rmax: 3.6 }); + }, + draw(ctx, W, H, f, p, hit) { + const edits = p.panel.events.flatMap((i) => editsOf(p.page.events[i])); + const first = edits[0]; + const lines = first?.hunks?.[0]?.lines || []; + const pw = Math.min(W * 0.56, 196); + const ph = 128; + const x0 = 12 - (1 - spring(f, { damping: 14, stiffness: 120 })) * 70; + const y0 = 104; + const files = p.panel.files || []; + codePage(ctx, x0, y0, pw, ph, f, baseName(first?.file || files[0] || 'file'), lines, files.length - 1); + hit('diff', x0, y0, pw, ph, "See Claude's changes"); + const ax = Math.min(W - 50, x0 + pw + 200); + const tip = [x0 + pw - 14, y0 + ph * 0.5]; + const hp = [tip[0] + 20 + Math.sin(f / 2.6) * 3, tip[1] - 28 + Math.cos(f / 3.1) * 2.5]; + const a = agent(ctx, ax, 248, { frame: f, mood: 'focus', l: [hp[0] - ax, hp[1] - 248], r: [30, -42] }); + pencil(ctx, a.l, [tip[0] + Math.sin(f / 2.6) * 3, tip[1] + Math.cos(f / 3.1) * 2.5]); + // With several files the badge counts the card in front: a total beside one file's card read as + // that file's own. The Director's cut has every file's count. + const front = files.length > 1 && first ? edits.filter((d) => d.file === first.file) : null; + // A file created here counts what it ends with, never lines it removed from its own draft. + const fa = front ? front.reduce((n, d) => n + d.added, 0) : 0; + const fr = front ? front.reduce((n, d) => n + d.removed, 0) : 0; + const fresh = !!front?.some((d) => d.created); + const added = front ? (fresh ? Math.max(0, fa - fr) : fa) : p.panel.added; + const removed = front ? (fresh ? 0 : fr) : p.panel.removed; + if (added) + sfx( + ctx, + `+${added}`, + x0 + pw * 0.32, + y0 + ph + 2, + 26, + C.go, + -0.18, + spring(f - 44, { damping: 9, stiffness: 170 }) + ); + if (removed) + sfx( + ctx, + `−${removed}`, + x0 + pw * 0.74, + y0 + ph + 6, + 24, + C.magenta, + 0.12, + spring(f - 50, { damping: 9, stiffness: 170 }) + ); + }, + }, + check: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.yellow, W * 0.7, H * 0.5, { cell: 8, rmax: 4.2, reach: 0.9 }); + }, + draw(ctx, W, H, f, p, hit) { + const t = p.panel.tests; + const bad = t ? t.failed + (t.errors || 0) : 0; + const total = t ? t.passed + bad : 0; + const running = p.panel.ok === undefined; + let b; + if (total) { + const dots = Math.min(42, total); + const fail = bad ? Math.max(1, Math.round((bad / total) * dots)) : 0; + const fill = running ? (f * 0.6) % (dots + 6) : Math.min(dots, (f - 4) * 1.6); + b = board(ctx, 16, 106, boardLabel(t, total), dots, fail, fill); + } else { + b = verdict(ctx, 16, 106, 'checks', p.panel.ok, f); + } + hit('output', 16, 106, b.w, b.h, 'See the output'); + const cx = Math.min(W * 0.56, W - 120); + const cy = 166; + const bp = running ? 0 : spring(f - 30, { damping: 9, stiffness: 150 }); + if (bp > 0.01) { + ctx.save(); + ctx.translate(cx, cy); + ctx.scale(bp, bp); + burst(ctx, 0, 0, 60, 36, 13, p.panel.ok ? C.cyan : C.yellow, 0.1, p.panel.ok ? 9 : 3); + ctx.restore(); + const word = p.panel.ok ? 'POW!' : 'KRAK!'; + sfx(ctx, word, cx, cy, fitSize(ctx, word, 96, 30), p.panel.ok ? C.yellow : C.magenta, -0.1, bp); + } + if (p.panel.ok && f > 34) { + [ + [14, 100], + [142, 112], + [10, 230], + ].forEach(([x, y], k) => star4(ctx, x, y, 7 + Math.sin(f / 5 + k * 1.7) * 3, C.yellow)); + } + const mood = running ? 'focus' : p.panel.ok ? 'happy' : 'shock'; + const jumping = p.panel.ok && f > 30; + const jump = jumping ? Math.abs(Math.sin((f - 30) / 7)) * 12 : 0; + agent(ctx, W - 40, 254 - jump, { + frame: f, + scale: 0.66, + rise: jumping ? 12 - jump : 0, + mood, + tilt: p.panel.ok === false ? interpolate(f, [28, 36], [0, 0.14]) : 0, + l: p.panel.ok ? [-32, -58] : [-36, -98], + r: p.panel.ok ? [22, -152] : [32, -102], + }); + }, + }, + loop: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.magenta, W, 0, { cell: 8, rmax: 3.6 }); + }, + draw(ctx, W, H, f, p, hit) { + const edits = p.panel.events.flatMap((i) => editsOf(p.page.events[i])); + const last = edits[edits.length - 1]; + // No Claude here: the balloon points at the verdict, above the board and the rounds. + aim(W - 60, 128); + const pw = Math.min(W * 0.36, 150); + codePage(ctx, 12, 108, pw, 112, f, baseName(last?.file || 'file'), last?.hunks?.[0]?.lines || [], 0); + hit('diff', 12, 108, pw, 112, "See Claude's changes"); + const bx = 12 + pw + 34; + if (bx + 127 < W - 10) { + // The last run's real counts, or one verdict when it reported none. + const t = p.panel.tests; + const bad = t ? t.failed + (t.errors || 0) : 0; + const total = t ? t.passed + bad : 0; + let b; + if (total) { + const dots = Math.min(28, total); + const fail = bad ? Math.max(1, Math.round((bad / total) * dots)) : 0; + b = board(ctx, bx, 116, boardLabel(t, total), dots, fail, Math.min(dots, (f - 4) * 1.6)); + } else b = verdict(ctx, bx, 116, 'checks', p.panel.ok, f); + hit('output', bx, 116, b.w, b.h, 'See the test output'); + } + // The cycle arrow between the page and the board. + const ax = 12 + pw + 17; + // It turns while the rounds are still going; on a finished page it rests. + const spin = p.panel.live || f < HERO ? (f / 20) % TAU : 0.4; + ctx.save(); + ctx.translate(ax, 164); + ctx.rotate(spin); + ctx.beginPath(); + ctx.arc(0, 0, 14, 0.3, TAU - 0.6); + ctx.strokeStyle = C.line; + ctx.lineWidth = 6; + ctx.stroke(); + ctx.strokeStyle = C.yellow; + ctx.lineWidth = 3; + ctx.stroke(); + // The arrowhead at the arc's end. + const end = TAU - 0.6; + const hx = Math.cos(end) * 14; + const hy = Math.sin(end) * 14; + ctx.beginPath(); + ctx.moveTo(hx + 7, hy - 3); + ctx.lineTo(hx - 1, hy + 7); + ctx.lineTo(hx - 6, hy - 6); + ctx.closePath(); + inked(ctx, C.yellow, 2); + ctx.restore(); + // Every round's verdict, in order: a first run that failed (often the one you asked for) shows. + const marks = p.panel.roundsOk || []; + if (marks.length) { + const shown = marks.slice(-6); + const gap = 22; + const x0 = W - 60 - ((shown.length - 1) * gap) / 2; + shown.forEach((ok, k) => { + const pr = spring(f - 20 - k * 6, { damping: 10, stiffness: 160 }); + if (pr <= 0.01) return; + const cx = x0 + k * gap; + ctx.save(); + ctx.translate(cx, 214); + ctx.scale(pr, pr); + ctx.beginPath(); + ctx.arc(0, 0, 9, 0, TAU); + if (ok === null) { + // Still running: an open circle, no verdict yet. + ctx.setLineDash([3, 3]); + ctx.lineWidth = 2.5; + ctx.strokeStyle = C.balloon; + ctx.stroke(); + ctx.restore(); + return; + } + inked(ctx, ok ? C.go : C.magenta, 2.5); + ctx.strokeStyle = C.balloon; + ctx.lineWidth = 2.5; + ctx.lineCap = 'round'; + ctx.beginPath(); + if (ok) { + ctx.moveTo(-4, 0); + ctx.lineTo(-1, 3.5); + ctx.lineTo(4.5, -3.5); + } else { + ctx.moveTo(-3.5, -3.5); + ctx.lineTo(3.5, 3.5); + ctx.moveTo(3.5, -3.5); + ctx.lineTo(-3.5, 3.5); + } + ctx.stroke(); + ctx.restore(); + }); + } else + sfx( + ctx, + `×${p.panel.rounds || 1}`, + W - 60, + 214, + 30, + C.yellow, + -0.1, + spring(f - 20, { damping: 9, stiffness: 160 }) + ); + // The verdict stamp only where it clears the board (a narrow panel shows the board alone). + if (p.panel.ok !== undefined && W - 108 > bx + 131) { + const word = p.panel.ok ? 'POW!' : 'KRAK!'; + sfx( + ctx, + word, + W - 60, + 150, + fitSize(ctx, word, 90, 26), + p.panel.ok ? C.yellow : C.magenta, + 0.08, + spring(f - 40, { damping: 9, stiffness: 150 }) + ); + } + }, + }, + run: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.cyan, 0, H, { rmax: 3.4 }); + }, + draw(ctx, W, H, f, p, hit) { + const tw = Math.min(W * 0.62, 260); + const notes = p.panel.notes?.length ? p.panel.notes : ['…']; + terminal(ctx, 14, 92, tw, 140, notes.slice(0, p.panel.printed?.length ? 3 : 8), f, p.panel.printed || []); + hit('output', 14, 92, tw, 140, 'See the commands and their output'); + agent(ctx, W - 48, 250, { + frame: f, + mood: 'focus', + scale: 0.8, + l: [-40, -60 + Math.sin(f / 2) * 3], + r: [-20, -58 + Math.cos(f / 2) * 3], + }); + }, + }, + save: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.go, W, H, { rmax: 3.6, alpha: 0.7 }); + }, + draw(ctx, W, H, f, p, hit) { + const pr = spring(f - 4, { damping: 12, stiffness: 140 }); + const bx = 24; + const by = 120 + (1 - pr) * 40; + rr(ctx, bx, by, 120, 90, 6); + inked(ctx, C.yellow, 3); + rr(ctx, bx - 6, by - 14, 132, 22, 5); + inked(ctx, C.yellow, 3); + ctx.fillStyle = C.line; + ctx.fillRect(bx + 44, by + 18, 32, 8); + hit('output', bx - 6, by - 14, 132, 104, 'See what was saved'); + // The stamp says how it went: SAVED, or ERROR when the command failed; none while it runs. + const failed = p.panel.ok === false; + const stamp = failed ? 'ERROR' : 'SAVED'; + const sp = p.panel.ok === undefined ? 0 : spring(f - 28, { damping: 8, stiffness: 160 }); + if (sp > 0.01) { + ctx.save(); + ctx.translate(bx + 60, by + 56); + ctx.rotate(failed ? 0.14 : -0.18); + ctx.scale(sp, sp); + ctx.font = `20px ${DISPLAY}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const w = ctx.measureText(stamp).width + 18; + rr(ctx, -w / 2, -15, w, 30, 4); + ctx.strokeStyle = C.magenta; + ctx.lineWidth = 3; + ctx.stroke(); + ctx.fillStyle = C.magenta; + ctx.fillText(stamp, 0, 1); + ctx.restore(); + } + agent(ctx, W - 50, 250, { + frame: f, + mood: failed ? 'shock' : p.panel.ok === undefined ? 'focus' : 'happy', + scale: 0.8, + l: [-30, -52], + r: failed ? [30, -60] : [26, -128], + }); + }, + }, + ask: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.yellow, 0, H, { cell: 10, rmax: 3, alpha: 0.8 }); + focusLines(ctx, W, H, W * 0.64, 136, 84, C.ink, 0.45); + }, + draw(ctx, W, H, f, p, hit) { + const ax = W * 0.64; + const s = 1.7; + const yb = 338; + const hp = [ax - 46 + Math.sin(f / 7) * 1.5, 212]; + agent(ctx, ax, yb, { + frame: f, + scale: s, + mood: 'eager', + noShadow: true, + l: [(hp[0] - ax) / s, (hp[1] - yb) / s], + r: [28, -46], + }); + ctx.beginPath(); + ctx.arc(hp[0], hp[1], 17, 0, TAU); + inked(ctx, C.balloon, 3); + ctx.beginPath(); + ctx.arc(hp[0] - 2, hp[1] - 1, 8, 0, TAU); + inked(ctx, C.balloon, 2.5); + hit('ask', ax - 60, 80, 120, 150, 'See the question'); + }, + }, + compact: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.magenta, 0, 0, { rmax: 3.2, alpha: 0.7 }); + }, + draw(ctx, W, H, f, p, hit) { + const squeeze = interpolate(f, [10, 50], [1, 0.35], easeOut); + hit('output', 24, 110, 140, 130, 'See what was packed up'); + for (let k = 0; k < 6; k++) { + ctx.save(); + ctx.translate(40 + k * 4, 200 - k * 14 * squeeze); + ctx.rotate((rnd(k) - 0.5) * 0.2); + rr(ctx, 0, 0, 90, 12, 2); + inked(ctx, C.balloon, 2); + ctx.restore(); + } + rr(ctx, 30, 206, 120, 30, 5); + inked(ctx, C.yellow, 3); + agent(ctx, W - 50, 250, { frame: f, mood: 'focus', scale: 0.8, l: [-60, -90 + squeeze * 20], r: [24, -50] }); + }, + }, + montage: { + bg(ctx, W, H) { + base(ctx, W, H); + halftone(ctx, W, H, C.yellow, W / 2, H / 2, { rmax: 3.4 }); + }, + draw(ctx, W, H, f, p, hit) { + // One cell per kind of step the montage folded, in this order, at most four. + const evs = p.panel.events.map((i) => p.page.events[i]).filter(Boolean); + const has = (...intents) => evs.some((e) => intents.includes(e.intent)); + const cells = [ + has('read', 'search', 'look', 'web') && { icon: 'look' }, + has('run', 'tool', 'message', 'helper', 'plan') && { icon: 'run' }, + has('edit') && { icon: 'edit' }, + has('test', 'build') && { icon: 'check' }, + has('save') && { icon: 'save' }, + ] + .filter(Boolean) + .slice(0, 4); + const size = Math.min(70, (W - 120) / 2); + const x0 = 16; + const y0 = 92; + cells.forEach((c, k) => { + const pr = spring(f - k * 5, { damping: 12, stiffness: 150 }); + if (pr <= 0.01) return; + const x = x0 + (k % 2) * (size + 10); + const y = y0 + Math.floor(k / 2) * (size + 10); + ctx.save(); + ctx.globalAlpha = clamp(pr, 0, 1); + rr(ctx, x, y, size, size, 6); + inked(ctx, C.panel, 2.5); + const cx = x + size / 2; + const cy = y + size / 2; + if (c.icon === 'look') magnifier(ctx, cx - 4, cy - 4, size * 0.2, [cx + size * 0.28, cy + size * 0.28]); + else if (c.icon === 'run') terminal(ctx, x + 8, y + 12, size - 16, size - 24, ['…'], HERO); + else if (c.icon === 'edit') + pencil(ctx, [cx + size * 0.3, cy - size * 0.3], [cx - size * 0.2, cy + size * 0.2]); + else if (c.icon === 'save') { + rr(ctx, cx - size * 0.3, cy - size * 0.12, size * 0.6, size * 0.38, 3); + inked(ctx, C.yellow, 2.5); + rr(ctx, cx - size * 0.34, cy - size * 0.26, size * 0.68, size * 0.14, 3); + inked(ctx, C.yellow, 2.5); + } else star4(ctx, cx, cy, size * 0.3, C.go); + ctx.restore(); + }); + hit('list', x0, y0, size * 2 + 10, size * 2 + 10, 'See every step'); + agent(ctx, W - 46, 252, { frame: f, mood: 'eager', scale: 0.62, l: [-30, -60], r: [26, -120] }); + }, + }, + complete: { + fullWidth: true, + bg(ctx, W, H) { + base(ctx, W, H); + // Centred on Claude, who stands at the bottom of the stage (H - 60 is 200 on a normal panel). + rays(ctx, W, H, W > 420 ? W * 0.72 : W / 2, H - 60, 30, C.cyan, 0.32); + halftone(ctx, W, H, C.magenta, W, 0, { rmax: 3.4, reach: 0.85, alpha: 0.75 }); + }, + draw(ctx, W, H, f, p) { + const wide = W > 420; + const ax = wide ? W * 0.72 : W / 2; + const sc = wide ? 0.9 : 0.55; + if (p.page.interrupted) { + agent(ctx, ax, 250, { frame: f, scale: sc, mood: 'shock', l: [-40, -60], r: [40, -60] }); + const sp = spring(f - 10, { damping: 9, stiffness: 150 }); + if (sp > 0.01) { + ctx.save(); + ctx.translate(ax + 70 * sc + 30, 120); + ctx.scale(sp, sp); + ctx.beginPath(); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * TAU + Math.PI / 8; + ctx[i ? 'lineTo' : 'moveTo'](Math.cos(a) * 30, Math.sin(a) * 30); + } + ctx.closePath(); + inked(ctx, C.magenta, 3); + ctx.fillStyle = C.balloon; + ctx.font = `13px ${DISPLAY}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('STOP', 0, 1); + ctx.restore(); + } + return; + } + // Not a clean finish (nothing worked, work still out, or Claude named an open problem): no party. + const calm = + p.page.noneWorked || + p.page.helpersRunning || + p.page.finishedLater || + p.page.openNote || + p.page.events.some((e) => e.running && !e.helper); + if (calm) { + agent(ctx, ax, 250, { frame: f, scale: sc, mood: 'neutral', l: [-34, -48], r: [34, -48] }); + return; + } + const colors = [C.cyan, C.magenta, C.yellow, C.go]; + for (let i = 0; i < 42; i++) { + const x = rnd(i) * W; + const y = ((rnd(i + 99) * (H + 40) + f * (0.7 + rnd(i + 7) * 0.9)) % (H + 40)) - 20; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(i + f * 0.05 * (rnd(i + 3) - 0.5) * 4); + ctx.fillStyle = colors[i % 4]; + ctx.fillRect(-4, -2.5, 8, 5); + ctx.restore(); + } + const j = Math.abs(Math.sin(f / 8)) * 8; + if (p.page.helpers) { + const spread = wide ? 108 : 74; + const hs = wide ? 0.55 : 0.4; + agent(ctx, ax - spread, 250, { + frame: f + 30, + scale: hs, + body: C.magenta, + mood: 'happy', + l: [-30, -60], + r: [30, -104 + Math.sin(f / 6) * 8], + }); + if (p.page.helpers > 1) + agent(ctx, ax + spread, 250, { + frame: f + 60, + scale: hs, + body: C.yellow, + mood: 'happy', + l: [-30, -104 + Math.cos(f / 6) * 8], + r: [30, -60], + }); + } + agent(ctx, ax, 250 - j, { + frame: f, + scale: sc, + mood: 'happy', + l: [-40, -122], + r: [40, -122], + bendL: 12, + bendR: -12, + }); + }, + }, + }; + + /** + * A scene's layout at its finished frame, drawn on a 1x1 scratch canvas: its click regions and where + * Claude stands (stage units). Balloons are placed from this, never from the frame on screen, where + * an entrance still has objects in flight and the panel would change height with them. + */ + let scratch; + function layout(kind, W, p) { + const scene = SCENES[kind]; + if (scratch === undefined) { + const c = document.createElement('canvas'); + c.width = c.height = 1; + scratch = c.getContext('2d'); + } + if (!scene || !scratch) return null; + const hits = []; + takeSpeaker(); + scratch.save(); + scratch.setTransform(1, 0, 0, 1, 0, 0); + try { + scene.draw(scratch, W, VH, HERO, p, (id, x, y, w, h) => hits.push({ id, x, y, w, h })); + } finally { + scratch.restore(); + } + return { hits, speaker: takeSpeaker() }; + } + + return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf, inBand, layout }; +})(); + +/** Panel kinds as the wire's group headings name them. */ +const STORY_KIND_NAMES = { + mission: 'The mission', + investigate: 'Investigating', + research: 'Looking it up', + plan: 'The plan', + backup: 'Calling backup', + fix: 'The fix', + check: 'The check', + loop: 'Fix and test', + run: 'In the terminal', + save: 'Saving', + ask: 'Your call', + compact: 'Packing up', + montage: 'Montage', + complete: 'The end', +}; + +/** How wide a panel wants to be on the 6-column page. Rows are then filled exactly. */ +function storyPreferredSpan(panel) { + if (panel.kind === 'complete') return 6; + if (panel.kind === 'mission' || panel.kind === 'fix' || panel.kind === 'loop' || panel.kind === 'plan') return 3; + if (panel.kind === 'backup') return (panel.helpers?.length || 0) >= 2 ? 4 : 3; + return 2; +} + +/** Spans for a row width of `cols`: the last panel of a row stretches to fill it. */ +function storyLayoutSpans(prefs, cols) { + const spans = []; + let left = cols; + prefs.forEach((pref, i) => { + const want = Math.min(cols, Math.max(1, pref)); + if (want > left && left < cols) { + spans[i - 1] += left; + left = cols; + } + spans.push(want); + left -= want; + if (left === 0) left = cols; + }); + if (left !== cols && spans.length) spans[spans.length - 1] += left; + return spans; +} + +/** + * Reading levels, in the order the bar shows them: Simple for someone who has never seen a terminal, + * Advanced (the plain story), Director's cut (the wire and the tool captions). + */ +const STORY_LEVELS = ['simple', 'advanced', 'director']; +/** The level a reader gets until they pick one (owner: the Director's cut, for now). */ +const STORY_LEVEL_DEFAULT = 'director'; +/** Where the reader's pick is kept, per device. */ +const STORY_LEVEL_KEY = 'codeman:story-level'; + +/** Shortest row a page draws, in CSS px: the height the scenes and the comic text were drawn for. */ +const STORY_ROW_MIN = 270; +/** + * Tallest row, in CSS px: twice the drawn size. A row past it holds mostly background (a turn that + * just started is one row of two panels), and the comic text, which stops growing at + * STORY_TEXT_MAX, would look lost in art drawn more than twice its size. + */ +const STORY_ROW_MAX = 540; +/** + * The narrowest stage, in scene units, that keeps all of a scene's art on the panel: the mission's + * exclamation mark beside Claude's head (at 0.78 of the width) runs off the right edge below about + * 278. A row is never so tall that its narrowest panel's stage gets narrower, so a narrow panel + * stays about as wide as it is tall, never a portrait. + */ +const STORY_STAGE_MIN = 280; +/** + * The final panel's narrowest stage: under 420 units its scene centres Claude, behind the words on + * its left. Its row may still be taller (it takes what the narrow rows cannot); the scene then + * stands at the bottom, the words above it, as it does when the words make the panel taller. + */ +const STORY_FINAL_STAGE_MIN = 440; +/** The comic text grows with the rows up to this factor (`--story-k`); a caption in a narrow panel would wrap past its band beyond it. */ +const STORY_TEXT_MAX = 1.45; + +/** Each item's row, and the items in each row, for `spans` on a page `cols` wide. */ +function storyLayoutRows(spans, cols) { + const rowOf = []; + const rows = []; + let used = 0; + spans.forEach((span, i) => { + if (!used) rows.push([]); + rows[rows.length - 1].push(i); + rowOf.push(rows.length - 1); + used += span; + if (used >= cols) used = 0; + }); + return { rowOf, rows }; +} + +/** + * Row heights, in whole CSS px, that make a page that fits fill the visible height, and each item's + * stage (the height its scene is drawn at, `--story-panel-min`). The height is shared evenly; a row + * never goes under STORY_ROW_MIN (a longer page keeps its rows and scrolls) nor over its cap + * (STORY_ROW_MAX, and the height at which a panel's stage would fall under STORY_STAGE_MIN wide), + * and what a capped row cannot take goes to the rows that can (the final panel's full-width row, + * mostly). Rounded down, so the rows never overflow by a fraction and raise a scrollbar. + * `box` is the page's content box: `width` across, `height` the visible height the rows may take, + * `gap` between rows and columns; `stageMin` each item's narrowest stage in scene units (default + * STORY_STAGE_MIN; 0 for an item without art, the pencils); and `lastRow` the height the last row + * takes once its words made it taller (the final panel's are in flow): the other rows give that up, + * so the page still ends at the bottom. The text scale `k` (the shortest row over STORY_ROW_MIN) is + * settled before that, so the words that set `lastRow` never change with it. One column (a phone) + * gives null: its panels keep their natural heights. + * @returns {{ heights: number[], stage: number[], rowOf: number[], k: number, fill: number, caps: number[] } | null} + */ +function storyRowHeights(spans, cols, { width, height, gap = 0, stageMin = [], lastRow = 0 }) { + if (!(cols >= 2) || !spans?.length || !(width > 0) || !(height > 0)) return null; + const { rowOf, rows } = storyLayoutRows(spans, cols); + const col = (width - gap * (cols - 1)) / cols; + // The tallest row an item allows: its stage, its width wide in scene units, no narrower than `min`. + const tallest = (i, min) => { + const n = Math.min(cols, spans[i]); + return min > 0 ? ((n * col + (n - 1) * gap) * StoryArt.VH) / min : Infinity; + }; + const own = (i) => stageMin[i] ?? STORY_STAGE_MIN; + const caps = rows.map((items) => + Math.max(STORY_ROW_MIN, Math.min(STORY_ROW_MAX, ...items.map((i) => tallest(i, Math.min(own(i), STORY_STAGE_MIN))))) + ); + /** `total` shared by the rows in `which`, evenly, up to each row's cap; the rest to the others. */ + const share = (which, total) => { + const out = new Map(); + let open = which; + let left = total; + while (open.length) { + const even = left / open.length; + const full = open.filter((r) => caps[r] < even); + if (!full.length) { + for (const r of open) out.set(r, even); + break; + } + for (const r of full) { + out.set(r, caps[r]); + left -= caps[r]; + } + open = open.filter((r) => caps[r] >= even); + } + return which.map((r) => Math.max(STORY_ROW_MIN, Math.floor(out.get(r)))); + }; + const n = rows.length; + const avail = height - gap * (n - 1); + const all = rows.map((_, r) => r); + let heights = share(all, avail); + const k = Math.round(Math.min(STORY_TEXT_MAX, Math.min(...heights) / STORY_ROW_MIN) * 1000) / 1000; + const others = heights.slice(0, -1).reduce((a, h) => a + h, 0); + if (n > 1 && lastRow > heights[n - 1] && others + lastRow > avail) + heights = [...share(all.slice(0, -1), avail - lastRow), heights[n - 1]].map((h, r) => Math.min(h, heights[r])); + const stage = spans.map((_, i) => + Math.max(STORY_ROW_MIN, Math.min(heights[rowOf[i]], Math.floor(tallest(i, own(i))))) + ); + return { heights, stage, rowOf, k, fill: avail / n, caps }; +} + +/** + * How far down a speech balloon may reach, and how much taller its panel must grow for the balloon to + * keep `keep` px (its padding and the lines it must show). Everything is in panel px at the panel's + * usual height: `obstacles` ({ x0, x1, y }: the scene's objects and Claude's face) and `floor` (the + * bottom edge, Claude's head) stand on the stage, so a panel grown by g moves each of them down by g + * while the balloon keeps its `top`. The result therefore does not depend on the grown height: a + * second pass over a grown panel asks for the same growth, never more. + * An obstacle under the body (`x0`..`x1`) must start below it; one under the tail (`tail`: { x0, x1, + * h }, the point below the body) must start below the tail's tip. + */ +function storyBalloonRoom({ top, x0, x1, tail = null, obstacles = [], floor, keep, gap = 4 }) { + let limit = floor; + for (const o of obstacles) { + if (o.x1 > x0 - gap && o.x0 < x1 + gap) limit = Math.min(limit, o.y - gap); + if (tail && o.x1 > tail.x0 - gap && o.x0 < tail.x1 + gap) limit = Math.min(limit, o.y - tail.h - gap); + } + return { limit, grow: Math.max(0, top + keep - limit) }; +} + +Object.assign(CodemanApp.prototype, { + /** Per-device setting, default OFF. */ + isStoryButtonEnabled() { + const settings = this.loadAppSettingsFromStorage(); + const defaults = this.getDefaultSettings(); + return (settings.showStoryButton ?? defaults.showStoryButton ?? false) === true; + }, + + /** Called from applyHeaderVisibilitySettings() on boot and after every settings save. */ + applyStoryButtonVisibility() { + const on = this.isStoryButtonEnabled(); + document.querySelector('.btn-story-header')?.classList.toggle('btn-story-header--hidden', !on); + // Session Options carries a Story button too: the way in on phones, where the header has no room. + const optionsBtn = document.getElementById('sessionOptionsStoryBtn'); + if (optionsBtn) optionsBtn.hidden = !on; + if (!on) this.closeStoryMode(); + }, + + /** Session Options → Story: this session's story, whichever tab is active. */ + openStoryFromOptions() { + const id = this.editingSessionId; + if (!id) return; + this.closeSessionOptions?.(); + this.openStoryMode(id); + }, + + toggleStoryMode() { + if (this._story?.open) this.closeStoryMode(); + else this.openStoryMode(); + }, + + openStoryMode(sessionId = this.activeSessionId) { + if (!sessionId) { + this.showToast?.('Open a session first, then Story Mode draws what it does.', 'info'); + return; + } + const st = this._storyState(); + // Opened again while open (another session): one runtime, never two frame loops and polls. + if (st.open) this._storyStopRuntime(); + else st.returnFocus = document.activeElement; + st.open = true; + st.sessionId = sessionId; + st.turnId = null; + st.data = null; + st.error = null; + st.etag = null; + st.helperSteps = new Map(); + st.epoch++; + st.first = true; + st.drawer = null; + st.drawerHolds = false; + // What was said and shown belongs to the last session drawn, not this one. + st.announced = undefined; + st.knownTurns = undefined; + st.shownPageId = undefined; + st.picked = false; + // A request still out from before is ignored (its epoch is old); it must not block this one. + st.inFlight = 0; + st.again = false; + st.root.hidden = false; + document.body.classList.add('story-open'); + this._storyApplyTheme(); + const session = this.sessions?.get(sessionId); + st.sessionLabel.textContent = session + ? this.getSessionName?.(session) || session.name || sessionId.slice(0, 8) + : sessionId.slice(0, 8); + st.root.setAttribute('aria-label', `Story Mode: ${st.sessionLabel.textContent}`); + this._storyRender(); + this._storyStartRuntime(); + this._storyRefresh(); + st.closeBtn.focus({ preventScroll: true }); + }, + + /** Hides the overlay and stops everything it runs. Safe to call when it is not open. */ + closeStoryMode() { + const st = this._story; + if (!st?.open) return; + st.open = false; + st.epoch++; + st.root.hidden = true; + document.body.classList.remove('story-open'); + this._storyStopRuntime(); + this._storyCloseDrawer({ refocus: false }); + for (const rt of st.panels) this._storyDropPanel(rt); + // Full-size backing canvases (megabytes each at 2x): not kept once Story Mode is closed. + storyBgCache.clear(); + st.panels = []; + st.pageEl.replaceChildren(); + st.wireList.replaceChildren(); + st.data = null; + st.etag = null; + st.helperSteps = null; + const back = st.returnFocus; + st.returnFocus = null; + if (back && document.contains(back)) back.focus({ preventScroll: true }); + }, + + /** SSE activity (transcript tool events, stop and prompt hooks) for any session. */ + _storyOnActivity(e) { + const st = this._story; + if (!st?.open) return; + let data = {}; + try { + data = e?.data ? JSON.parse(e.data) : {}; + } catch { + return; + } + if (data.sessionId !== st.sessionId || st.turnId) return; + this._storyScheduleRefresh(); + }, + + // ── State and DOM ── + + _storyState() { + if (this._story) return this._story; + const el = (tag, cls, text) => { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; + }; + const btn = (cls, text, label) => { + const b = el('button', cls, text); + b.type = 'button'; + if (label) b.setAttribute('aria-label', label); + return b; + }; + const root = el('div', 'story-overlay'); + root.id = 'storyOverlay'; + root.hidden = true; + root.setAttribute('role', 'dialog'); + root.setAttribute('aria-modal', 'true'); + root.setAttribute('aria-label', 'Story Mode'); + // A file preview opened from a panel sits beside the overlay, not in it: while it is up the + // overlay is no longer modal, or a screen reader that honours aria-modal would hide it. + root.addEventListener('focusin', () => { + if (!document.getElementById('filePreviewOverlay')?.classList.contains('visible')) + root.setAttribute('aria-modal', 'true'); + }); + + const bar = el('div', 'story-bar'); + const brand = el('div', 'story-brand'); + const title = el('span', 'story-title', 'Story'); + title.id = 'storyTitle'; + const sessionLabel = el('span', 'story-session'); + sessionLabel.dataset.i18nSkip = ''; + brand.append(title, sessionLabel); + + const nav = el('div', 'story-nav'); + const prev = btn('story-btn story-prev', '‹', 'Previous turn'); + const turnSelect = el('select', 'story-turns'); + turnSelect.setAttribute('aria-label', 'Turn'); + turnSelect.dataset.i18nSkip = ''; + const next = btn('story-btn story-next', '›', 'Next turn'); + nav.append(prev, turnSelect, next); + + const seg = el('div', 'story-seg'); + seg.setAttribute('role', 'group'); + seg.setAttribute('aria-label', 'Reading level'); + const levelBtns = { + simple: btn('story-seg-btn', 'Simple'), + advanced: btn('story-seg-btn', 'Advanced'), + director: btn('story-seg-btn', "Director's cut"), + }; + levelBtns.simple.title = 'Plain words, for anyone'; + levelBtns.advanced.title = 'The story, with file names'; + levelBtns.director.title = 'Every step and tool, with the wire'; + seg.append(...STORY_LEVELS.map((lv) => levelBtns[lv])); + + const live = el('span', 'story-live'); + live.append(el('i'), el('span', null, 'LIVE')); + live.hidden = true; + const saveBtn = btn('story-btn story-save', 'Save image', 'Save this page as an image'); + saveBtn.title = 'Save this page as a PNG to share'; + const closeBtn = btn('story-btn story-close', '×', 'Close Story Mode'); + // The turn picker lives in the composer at the bottom (_storyComposeBuild): the prompts you sent and + // the one you write next, in one bar. + bar.append(brand, seg, live, saveBtn, closeBtn); + // What a screen reader hears as the page changes: a new turn, a finished one. + const announcer = el('p', 'story-sr'); + announcer.dataset.i18nSkip = ''; // it reads out the agent's prompts + announcer.setAttribute('role', 'status'); + announcer.setAttribute('aria-live', 'polite'); + + const main = el('div', 'story-main'); + const note = el('p', 'story-note'); + note.setAttribute('role', 'status'); + note.hidden = true; + note.dataset.i18nSkip = ''; + const stage = el('div', 'story-stage'); + const sheet = el('div', 'story-sheet'); + const pageEl = el('section', 'story-page'); + pageEl.dataset.i18nSkip = ''; + sheet.append(pageEl); + const wire = el('aside', 'story-wire'); + wire.setAttribute('aria-label', 'Every event behind this page'); + const wireHead = el('header', 'story-wire-head'); + const wireCount = el('p', 'story-wire-sub'); + wireHead.append(el('p', 'story-wire-title', 'The wire'), wireCount); + const wireList = el('ol', 'story-wire-list'); + wireList.dataset.i18nSkip = ''; + const { foot: wireFoot, pill: wirePill } = this._storyWireControls(wire, wireList); + wire.append(wireHead, wireList, wireFoot); + stage.append(sheet, wire); + main.append(note, stage); + + const drawer = el('aside', 'story-drawer'); + drawer.hidden = true; + drawer.setAttribute('role', 'dialog'); + const drawerHead = el('header', 'story-drawer-head'); + // A sheet comes down the way it went up: drag its head down to close it. + let dragFrom = null; + drawerHead.addEventListener('touchstart', (e) => (dragFrom = e.touches[0]?.clientY ?? null), { passive: true }); + drawerHead.addEventListener( + 'touchend', + (e) => { + const y = e.changedTouches[0]?.clientY; + if (dragFrom !== null && y !== undefined && y - dragFrom > 70) this._storyCloseDrawer(); + dragFrom = null; + }, + { passive: true } + ); + const drawerKind = el('span', 'story-drawer-kind'); + const drawerLive = el('span', 'story-live story-drawer-live'); + drawerLive.append(el('i'), el('span', null, 'LIVE')); + drawerLive.hidden = true; + const drawerMeta = el('div', 'story-drawer-meta'); + drawerMeta.append(drawerKind, drawerLive); + const drawerTitle = el('p', 'story-drawer-title'); + drawerTitle.id = 'storyDrawerTitle'; + drawerKind.id = 'storyDrawerKind'; + drawer.setAttribute('aria-labelledby', 'storyDrawerKind storyDrawerTitle'); + const drawerSub = el('p', 'story-drawer-sub'); + drawerTitle.dataset.i18nSkip = ''; + drawerSub.dataset.i18nSkip = ''; + const drawerClose = btn('story-btn story-drawer-close', '×', 'Close details'); + drawerHead.append(drawerMeta, drawerTitle, drawerSub, drawerClose); + const drawerBody = el('div', 'story-drawer-body'); + drawerBody.dataset.i18nSkip = ''; + drawer.append(drawerHead, drawerBody); + + root.append(bar, main, drawer, announcer); + document.body.appendChild(root); + + const st = { + open: false, + epoch: 0, + sessionId: null, + turnId: null, + data: null, + error: null, + // The reading level; `director` stays a plain boolean for the wire and the scenes. + level: STORY_LEVEL_DEFAULT, + director: false, + first: true, + panels: [], + drawer: null, + drawerSeen: new Set(), + inFlight: false, + again: false, + refreshTimer: null, + pollTimer: null, + raf: 0, + returnFocus: null, + root, + bar, + sessionLabel, + turnSelect, + prev, + next, + levelBtns, + live, + closeBtn, + saveBtn, + note, + stage, + mainEl: main, + pageEl, + wire, + wireCount, + wireList, + wirePill, + navEl: nav, + drawerEl: drawer, + drawerKind, + drawerLive, + drawerTitle, + drawerSub, + drawerBody, + }; + this._story = st; + this._storyComposeBuild(st); + + closeBtn.addEventListener('click', () => this.closeStoryMode()); + saveBtn.addEventListener('click', () => this.saveStoryImage()); + drawerClose.addEventListener('click', () => this._storyCloseDrawer()); + for (const lv of STORY_LEVELS) + levelBtns[lv].addEventListener('click', () => this._storySetLevel(lv, { remember: true })); + turnSelect.addEventListener('change', () => this._storyPickTurn(turnSelect.value)); + prev.addEventListener('click', () => this._storyStepTurn(-1)); + next.addEventListener('click', () => this._storyStepTurn(1)); + // One delegate for every clickable thing on the page and in the drawer. + root.addEventListener('click', (e) => { + const t = e.target.closest?.('[data-story-open]'); + if (t && root.contains(t)) { + e.preventDefault(); + this._storyOpenTarget(t.dataset.storyOpen, t); + } + }); + this._storySetLevel(this._storyStoredLevel()); + return st; + }, + + _storyApplyTheme() { + const st = this._story; + const scheme = getComputedStyle(document.documentElement).colorScheme || ''; + st.root.classList.toggle('story-paper', /light/.test(scheme) && !/dark/.test(scheme)); + StoryArt.readColors(st.root); + for (const rt of st.panels) rt.bgKey = ''; + }, + + /** The reading level this device last picked; an unknown or unreadable value is the default. */ + _storyStoredLevel() { + try { + const level = localStorage.getItem(STORY_LEVEL_KEY); + if (STORY_LEVELS.includes(level)) return level; + } catch { + // Storage refused (a private window, blocked site data): the default level. + } + return STORY_LEVEL_DEFAULT; + }, + + /** Kept for callers of the two-level toggle: on is the Director's cut, off the Advanced story. */ + _storySetDirector(on) { + this._storySetLevel(on ? 'director' : 'advanced'); + }, + + /** + * Switches the reading level. Only the reader's own pick (`remember`) is kept for the next visit: + * opening Story Mode never writes the default back, so changing the default stays a one-line change. + */ + _storySetLevel(level, { remember = false } = {}) { + const st = this._story; + if (!STORY_LEVELS.includes(level)) level = STORY_LEVEL_DEFAULT; + const on = level === 'director'; + st.level = level; + st.director = on; + st.root.classList.toggle('is-simple', level === 'simple'); + st.root.classList.toggle('is-director', on); + for (const lv of STORY_LEVELS) st.levelBtns[lv].setAttribute('aria-pressed', String(lv === level)); + if (remember) { + try { + localStorage.setItem(STORY_LEVEL_KEY, level); + } catch { + // Not kept: the level still applies to this visit. + } + } + st.wire.hidden = !on; + if (on) this._storyRenderWire(); + // Each level has its own words (and Simple hides Claude's balloons): the panels refill and refit + // to them. Drawn within the frame budget, never every panel in one task. + const page = st.data?.supported ? st.data.page : null; + st.panels.forEach((rt, i) => { + if (page && rt.panel) this._storyFillPanelText(rt, page, i); + else this._storyFitCaption(rt); + this._storyQueueDraw(rt); + }); + }, + + _storyPickTurn(id) { + const st = this._story; + st.picked = true; // a turn you chose is not news to announce + st.drawerHolds = false; // your choice wins over a page held for a drawer + const turns = st.data?.turns || []; + st.turnId = id && id !== turns[turns.length - 1]?.id ? id : null; + st.first = true; + st.etag = null; + st.epoch++; + this._storyCloseDrawer({ refocus: false }); + this._storyRefresh(); + }, + + _storyStepTurn(delta) { + const st = this._story; + const turns = st.data?.turns || []; + const current = st.data?.page?.id; + const idx = turns.findIndex((t) => t.id === current); + const target = turns[idx + delta]; + if (target) this._storyPickTurn(target.id); + }, + + // ── Composer: a message to the session on the page ── + + /** + * The bar under the page: a field and Send. It talks to the session the overlay shows + * (`st.sessionId`), never the active tab, since Session Options opens Story for any session. + * Drafts live in memory per session id for the life of the page (prompts carry secrets, so they + * are never persisted). The bar is outside `.story-main`, so a drawer that makes the page inert + * leaves it usable, and outside the page, so Save image never draws it. + */ + _storyComposeBuild(st) { + const el = (tag, cls, text) => { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; + }; + const box = el('div', 'story-compose'); + const inner = el('div', 'story-compose-inner'); + const row = el('div', 'story-compose-row'); + const field = el('textarea', 'story-compose-field'); + field.rows = 1; + field.dataset.i18nSkip = ''; // what is typed is the user's, never translated + field.setAttribute('autocapitalize', 'sentences'); + field.setAttribute('enterkeyhint', 'send'); + field.spellcheck = true; + const hint = el('p', 'story-compose-hint'); + hint.id = 'storyComposeHint'; + field.setAttribute('aria-describedby', hint.id); + const send = el('button', 'story-compose-send', 'Send'); + send.type = 'button'; + send.title = 'Send to Claude (Enter). Shift+Enter adds a line.'; + // i18n.js skips