From 2653db10b5dc4a986e32ad82f629b68b7c33e8ed Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 09:22:06 +0200 Subject: [PATCH] feat(story-mode): draw a Claude session's turns as comic pages, live Story Mode is an opt-in header button (showStoryButton, per device, default off, kept off phones) that opens the active Claude Code session as comic pages: one page per prompt, panels for reading code, planning, helpers, edits, tests (POW/KRAK), questions and saving, each with a plain story caption and a Director's cut caption naming the tools. Files open in the existing file preview, the code page opens the exact diff, a test board opens the output, and a running turn redraws as Claude works. Server: GET /api/sessions/:id/story (experimental) reads a bounded tail of the session's own transcript. A pure core (src/story-mode.ts) reduces lines, joins tool calls with their results, splits turns at prompts and turn_duration, and groups events into at most 12 panels; Bash calls are sorted by what the command does. The reader (src/story-reader.ts) caches reduced entries and reads only appended bytes while the file grows. Frontend: story-mode.js (canvas frame engine, live refresh from transcript and hook SSE events plus a poll while a turn runs, details drawer, the wire) and story-mode.css, with two self-hosted OFL comic fonts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/story-mode.md | 5 + CLAUDE.md | 2 + docs/api-reference.md | 13 + docs/wiki/Settings-Reference.md | 9 +- src/story-mode.ts | 1186 ++++++++ src/story-reader.ts | 159 ++ src/types/index.ts | 1 + src/types/story.ts | 169 ++ src/utils/claude-transcript.ts | 27 +- src/web/CLAUDE.md | 4 +- src/web/public/app.js | 12 + src/web/public/fonts/LICENSE-story-fonts.txt | 193 ++ .../public/fonts/bowlby-one-sc-latin.woff2 | Bin 0 -> 23708 bytes .../public/fonts/patrick-hand-sc-latin.woff2 | Bin 0 -> 23292 bytes src/web/public/index.html | 6 +- src/web/public/mobile.css | 5 +- src/web/public/settings-ui.js | 7 +- src/web/public/story-mode.css | 848 ++++++ src/web/public/story-mode.js | 2389 +++++++++++++++++ src/web/public/styles.css | 9 + src/web/routes/index.ts | 1 + src/web/routes/story-routes.ts | 76 + src/web/schemas.ts | 1 + src/web/server.ts | 2 + test/routes/story-routes.test.ts | 157 ++ test/story-fixtures.ts | 142 + test/story-mode.test.ts | 329 +++ 27 files changed, 5746 insertions(+), 6 deletions(-) create mode 100644 .changeset/story-mode.md create mode 100644 src/story-mode.ts create mode 100644 src/story-reader.ts create mode 100644 src/types/story.ts create mode 100644 src/web/public/fonts/LICENSE-story-fonts.txt create mode 100644 src/web/public/fonts/bowlby-one-sc-latin.woff2 create mode 100644 src/web/public/fonts/patrick-hand-sc-latin.woff2 create mode 100644 src/web/public/story-mode.css create mode 100644 src/web/public/story-mode.js create mode 100644 src/web/routes/story-routes.ts create mode 100644 test/routes/story-routes.test.ts create mode 100644 test/story-fixtures.ts create mode 100644 test/story-mode.test.ts diff --git a/.changeset/story-mode.md b/.changeset/story-mode.md new file mode 100644 index 00000000..04c73031 --- /dev/null +++ b/.changeset/story-mode.md @@ -0,0 +1,5 @@ +--- +'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. The **Director's cut** switch names every tool call instead and lists all of them beside the page. Everything is clickable: a file opens in the file viewer, the code page shows the exact diff of Claude's edits, a test board shows the output with clickable `file:line` links, and a helper shows what it was asked and what it answered. A turn that is still running redraws as Claude works, new panels inking in where blue-pencil roughs stood. The data comes from `GET /api/sessions/:id/story`, an experimental read-only endpoint. diff --git a/CLAUDE.md b/CLAUDE.md index 76422a7c..25878898 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **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). → [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/docs/api-reference.md b/docs/api-reference.md index 847df3ca..6f166cd0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1050,6 +1050,19 @@ Both routes accept two optional query parameters, which the UI sends from its pe Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running. +## Story Mode (experimental) + +`GET /api/sessions/:id/story[?turn=]` is what Story Mode reads (the opt-in header button, App Settings → Header buttons → Story Mode, per device, off by default). 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. + +Read-only. The session is resolved 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 for it, else `.jsonl` under the Claude projects directory. Only the tail of the transcript is read (8 MB), and file contents, original files and images never leave the server. + +`data` is one of: + +- `{ supported: false, reason }`: the session's CLI does not write a Claude Code transcript (registry `capabilities.transcript` is not `claude-jsonl`), or the session is remote or Docker, whose transcript is not on this host. +- `{ supported: true, turns, page }`: `turns` lists up to 60 turns, oldest first (`{ id, at, prompt, origin, complete, interrupted, toolCalls }`; `origin` is `human`, `task`, `peer` or `system`). `page` is the newest turn, or the one `turn=` names, or `null` when there is none (an unknown id included). + +A `page` carries the turn's summary fields plus `events` (one per prompt, sentence Claude wrote and tool call, with `intent`, `ok` when the call returned, `edit` hunks in Claude Code's own `structuredPatch` shape, the tail of `output`, `helper`, `tests` counts), `panels` (each `{ kind, events, caption: { story, director }, balloon?, files?, ... }`, at most 12 per page, indices into `events`), `filesChanged`, `added`, `removed`, `helpers`, `partial` (the turn began before the part that was read) and `truncated` (older outputs were dropped to keep the page small). A transcript that cannot be read is a `500` with `OPERATION_FAILED`. + ## 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 8f764750..100ea0ee 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -60,11 +60,18 @@ supervised by systemd or launchd; npm installs report as non-updatable. See 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, 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. +**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. +The **Director's cut** switch names every tool call instead. Click a file to open it, the code +page for the exact diff, a test board for the output. An unfinished turn redraws as Claude +works. + **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/story-mode.ts b/src/story-mode.ts new file mode 100644 index 00000000..f6a380b8 --- /dev/null +++ b/src/story-mode.ts @@ -0,0 +1,1186 @@ +/** + * @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. + * + * 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: 600, + 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. */ + pageTextBudget: 400_000, + turnsListed: 60, +}); + +// ========== Entries (stage 1 output) ========== + +interface ReducedInput { + command?: string; + 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 } + | { k: 'text'; at: string; text: string } + | { 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 }; + answer?: string; + tests?: StoryTests; + } + | { k: 'end'; at: string; durationMs?: number } + | { k: 'compact'; at: string } + | { k: 'interrupt'; at: 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)]; + +/** The first sentence of a message, without markdown, for a speech balloon. */ +export function firstSentence(text: string, max = 160): string { + const plain = text + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`([^`]*)`/g, '$1') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[*_#>]+/g, '') + .replace(/\s+/g, ' ') + .trim(); + const match = /^(.+?[.!?])(\s|$)/.exec(plain); + return clip(match ? match[1] : plain, max); +} + +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`; +} + +const fmtCounts = (added: number, removed: number): string => + [added ? `+${added}` : '', removed ? `−${removed}` : ''].filter(Boolean).join(' ') || '±0'; + +// ========== Bash commands and test counts ========== + +/** Ordered: the first match wins, so `npm test > out.log` is a test, not an edit. */ +const BASH_INTENTS: Array<[StoryIntent, RegExp]> = [ + [ + 'test', + /(^|[\s;&|(])(npm (run )?test\b|npx (vitest|jest|mocha|playwright test)\b|vitest\b|jest\b|pytest\b|python3? -m pytest\b|go test\b|cargo test\b|bun test\b|yarn test\b|pnpm (run )?test\b|deno test\b|rspec\b|phpunit\b|mix test\b|dotnet test\b)/, + ], + [ + 'build', + /(^|[\s;&|(])(npm run (build|typecheck|lint|check|format)\S*|tsc\b|npx (tsc|eslint|prettier)\b|eslint\b|prettier\b|make\b|cargo (build|check|clippy)\b|go (build|vet)\b|gradlew?\b|mvn\b|ruff\b|mypy\b|cmake\b|ninja\b)/, + ], + ['save', /\bgit (commit|push|tag|merge|rebase|cherry-pick|revert|am)\b|\bgh (pr|release) (create|merge)\b/], + [ + 'edit', + /\bsed -i\b|\bperl -p?i\b|(^|[\s;&|])cat\s+>|\btee\b|(^|[^>&2])>\s*[\w./~-]+\.\w+\b|\bpatch\b|\bgit apply\b/, + ], + [ + 'look', + /^\s*(cd\s+\S+\s*(&&|;)\s*)?(command\s+)?(cat|head|tail|sed -n|grep|rg|find|ls|wc|awk|jq|tree|stat|file|du|diff|less|xxd|od|git (status|diff|log|show|branch|blame|ls-files|rev-parse|remote)|gh (pr|issue|run|api) (view|list|diff|checks))\b/, + ], +]; + +/** What a Bash command does, from the command text alone. Never executes or parses it as shell. */ +export function classifyBashCommand(command: string): StoryIntent { + const cmd = command.slice(0, 2000); + for (const [intent, pattern] of BASH_INTENTS) if (pattern.test(cmd)) return intent; + return 'run'; +} + +/** + * 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 { + const lines = output.split('\n').slice(-40).reverse(); + 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 { passed: Number(passed[1]), failed }; + } + return null; +} + +// ========== Stage 1: reduce one transcript line ========== + +const SYSTEM_REMINDER_RE = /[\s\S]*?<\/system-reminder>/g; +/** 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)>/; + +function readPrompt(raw: string): { text: string; kind: 'prompt' | 'task' | 'interrupt' } | null { + const text = raw.replace(SYSTEM_REMINDER_RE, '').trim(); + if (!text) return null; + 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' }; + } + if (/^/.test(text)) { + const summary = /([\s\S]*?)<\/summary>/.exec(text)?.[1]; + return { text: (summary || 'A background task finished.').trim(), kind: 'task' }; + } + return { text, kind: 'prompt' }; +} + +function originOf(turnOrigin: unknown, kind: 'prompt' | 'task'): StoryTurnOrigin { + if (turnOrigin === 'human') return 'human'; + if (turnOrigin === 'task_notification' || kind === 'task') return 'task'; + if (turnOrigin === 'peer') return 'peer'; + if (typeof turnOrigin === 'string') return 'system'; + return 'human'; +} + +function reduceInput(name: string, input: unknown): 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': + return { command: s(i.command, STORY_LIMITS.labelChars), description: s(i.description, 160) }; + 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': + return { file: s(i.file_path, 1000) }; + 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"). */ +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. + */ +export function createStoryReducer(): 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') { + const answer = blockText(extra.content) || text; + result.helper = { + result: answer ? clip(answer, STORY_LIMITS.helperChars) : undefined, + toolUses: num(extra.totalToolUseCount), + durationMs: num(extra.totalDurationMs), + }; + } 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') { + const tests = parseTestCounts(tail.text); + if (tests) result.tests = tests; + } + } + out.push(result); + calls.delete(id); + } + if (results.length) return out; + } + if (entry.isMeta === true || entry.turnCompanion === true) return out; + const prompt = readPrompt(blockText(content)); + if (!prompt) return out; + if (prompt.kind === 'interrupt') return [{ k: 'interrupt', at }]; + return [ + { + k: 'prompt', + id: str(entry.uuid) ?? `prompt-${at}`, + at, + text: clip(prompt.text, STORY_LIMITS.promptChars), + origin: originOf(entry.turnOrigin, prompt.kind), + queued: entry.promptSource === 'queued', + }, + ]; + } + + function reduceAssistant(entry: Record, at: string): StoryEntry[] { + const message = isRecord(entry.message) ? entry.message : {}; + const content = message.content; + if (typeof content === 'string') + return content.trim() ? [{ k: 'text', at, text: clip(content, STORY_LIMITS.textChars) }] : []; + 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({ k: 'text', at, text: clip(block.text, STORY_LIMITS.textChars) }); + } else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { + const input = reduceInput(block.name, block.input); + remember(block.id, block.name, input); + out.push({ k: 'tool', at, id: block.id, name: block.name, input }); + } + } + return out; + } + + return { + reduce(line: unknown): StoryEntry[] { + if (!isRecord(line) || line.isSidechain === true) return []; + const at = str(line.timestamp) ?? ''; + switch (line.type) { + case 'user': + return reduceUser(line, at); + case 'assistant': + return reduceAssistant(line, at); + case 'system': + if (line.subtype === 'turn_duration') return [{ k: 'end', at, durationMs: num(line.durationMs) }]; + if (line.subtype === 'compact_boundary') return [{ k: 'compact', at }]; + 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; +} + +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 'ExitPlanMode': + case 'AskUserQuestion': + return 'ask'; + case 'Agent': + case 'Task': + case 'Workflow': + return 'helper'; + case 'SendMessage': + return 'message'; + case 'Edit': + case 'MultiEdit': + case 'Write': + case 'NotebookEdit': + return 'edit'; + case 'Bash': + return 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 'TodoWrite': + return plural(i.todos?.length ?? 0, 'item'); + case 'AskUserQuestion': + return i.question ?? ''; + case 'ExitPlanMode': + return 'Plan ready for your OK'; + 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.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 (i.todos) ev.todos = i.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.edit = r.edit; + ev.file = r.edit.file; + } + if (r.tests) ev.tests = r.tests; + if (r.helper && ev.helper) { + const helper: StoryHelper = { ...ev.helper }; + 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; + 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(); + let say = 0; + const ensure = (at: string): StoryTurn => { + if (!cur) { + cur = { + id: `partial-${at}`, + at, + prompt: '', + origin: 'human', + partial: true, + 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: 'you', tool: 'Prompt', label: e.text, intent: 'prompt' }; + if (cur && !cur.ended && e.queued) { + cur.events.push(ev); + } else { + cur = { + id: e.id, + at: e.at, + prompt: e.text, + origin: e.origin, + partial: false, + events: [ev], + ended: false, + interrupted: false, + }; + turns.push(cur); + byId.clear(); + } + break; + } + case 'text': + ensure(e.at).events.push({ + id: `say-${say++}`, + at: e.at, + actor: 'claude', + tool: 'Text', + label: e.text, + intent: 'say', + }); + break; + case 'tool': { + const ev = toolEvent(e, 'claude'); + ensure(e.at).events.push(ev); + byId.set(e.id, ev); + 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; + if (e.durationMs !== undefined) t.durationMs = e.durationMs; + } + break; + } + case 'compact': + ensure(e.at).events.push({ + id: `compact-${say++}`, + at: e.at, + actor: 'system', + tool: 'Compact', + label: 'Context compacted', + intent: 'compact', + }); + 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; + t.ended = true; + t.endedAt = e.at; + break; + } + } + } + return turns; +} + +const NOT_TOOL_CALLS = new Set(['prompt', 'say', 'interrupt', 'compact']); +const countToolCalls = (events: StoryEvent[]) => events.filter((e) => !NOT_TOOL_CALLS.has(e.intent)).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, + 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, +}; +/** 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', 'save', 'montage']); + +interface DraftPanel { + kind: StoryPanelKind; + events: number[]; + balloon?: { who: 'you' | 'claude'; text: string }; + rounds?: number; +} + +/** Keeps the newest outputs, hunks and helper texts within the page budget. */ +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 = e.edit ? e.edit.hunks.reduce((n, h) => n + h.lines.reduce((m, l) => m + 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.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; +} + +function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay?: string } { + const panels: DraftPanel[] = []; + let pendingSay: string | undefined; + events.forEach((ev, idx) => { + if (ev.intent === 'say') { + pendingSay ??= firstSentence(ev.label); + return; + } + const kind = 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); + if (pendingSay && !last.balloon) last.balloon = { who: 'claude', text: pendingSay }; + } else { + const panel: DraftPanel = { kind, events: [idx] }; + if (kind === 'mission') panel.balloon = { who: 'you', text: clip(ev.label, 220) }; + else if (pendingSay) panel.balloon = { who: 'claude', text: pendingSay }; + panels.push(panel); + } + pendingSay = undefined; + }); + return { panels, trailingSay: pendingSay }; +} + +/** 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++; + } + const run = panels.slice(i, j); + if (run.length >= 4) { + out.push({ + kind: 'loop', + events: run.flatMap((p) => p.events), + balloon: run[0].balloon, + rounds: run.filter((p) => p.kind === 'check').length, + }); + i = j; + } else { + out.push(panels[i]); + i++; + } + } + return out; +} + +/** Folds the smallest neighbouring pair into a montage until the page fits. */ +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; + let bestSize = Infinity; + for (const strict of [true, false]) { + for (let k = firstMergeable; k < out.length - 1; k++) { + const a = out[k]; + const b = out[k + 1]; + const ok = strict + ? MINOR.has(a.kind) && MINOR.has(b.kind) + : ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask'); + const size = a.events.length + b.events.length; + if (ok && size < bestSize) { + best = k; + bestSize = size; + } + } + if (best >= 0) break; + } + 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(' · '); +} + +const shortCommand = (e: StoryEvent) => clip(e.label.split('\n')[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'); + return parts.length ? parts.join(', ') : 'a few more steps'; +} + +function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, index: number): StoryPanel { + const evs = draft.events.map((i) => events[i]); + const panel: StoryPanel = { kind: draft.kind, events: draft.events, caption: { story: '', director: '' } }; + if (draft.balloon) panel.balloon = draft.balloon; + const edits = evs.filter((e) => e.edit).map((e) => e.edit as StoryEdit); + const checks = evs.filter((e) => e.intent === 'test' || e.intent === 'build'); + const lastCheck = checks[checks.length - 1]; + 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; + + 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.', + system: 'Something automatic starts this turn.', + }; + story = index > 0 ? 'You add something while Claude works.' : lines[turn.origin]; + director = `Prompt · ${plural(words, 'word')}${turn.origin === 'human' ? '' : ` · ${turn.origin}`}`; + break; + } + case 'investigate': { + const files = unique(evs.filter((e) => e.file && e.intent === 'read').map((e) => e.file as string)); + panel.files = files.slice(0, 12); + panel.notes = evs + .filter((e) => e.intent !== 'read') + .map(shortCommand) + .slice(0, 4); + story = files.length + ? `Claude reads ${plural(files.length, 'file')} to find its way around.` + : evs.every((e) => e.intent === 'search') + ? 'Claude searches the code.' + : 'Claude looks around the project.'; + break; + } + case 'research': + panel.notes = evs.map(shortCommand).slice(0, 4); + story = 'Claude looks things up on the web.'; + 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')}.` : 'Claude thinks it through.'; + if (todos) director = `${tools} · ${todos.filter((t) => t.done).length}/${todos.length} done`; + break; + } + case 'backup': { + const helpers = draft.events + .filter((i) => events[i].helper) + .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); + story = helpers.length + ? `Claude calls in ${plural(helpers.length, 'helper')}.` + : 'Claude sends a message to another session.'; + const inside = evs.reduce((n, e) => n + (e.helper?.toolUses ?? 0), 0); + director = `${tools}${helpers.length ? ` · ${unique(helpers.map((h) => h.type)).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)); + panel.files = files.slice(0, 12); + panel.added = edits.reduce((n, e) => n + e.added, 0); + panel.removed = edits.reduce((n, e) => n + e.removed, 0); + if (draft.kind === 'fix') { + const created = unique(edits.filter((e) => e.created).map((e) => e.file)).length; + story = + created && created === files.length + ? `Claude writes ${plural(created, 'new file')}.` + : files.length + ? `Claude changes ${plural(files.length, 'file')}.` + : 'Claude changes a file.'; + const perFile = files.slice(0, 3).map((f) => { + const mine = edits.filter((e) => e.file === f); + return `${basename(f)} ${fmtCounts( + mine.reduce((n, e) => n + e.added, 0), + mine.reduce((n, e) => n + e.removed, 0) + )}`; + }); + director = `${tools}${perFile.length ? ` · ${perFile.join(' · ')}` : ''}`; + } else { + panel.rounds = draft.rounds ?? checks.length; + if (lastCheck?.ok !== undefined) panel.ok = lastCheck.ok; + story = `Fix and test, ${plural(panel.rounds, 'round')}.${panel.ok === true ? ' It passes in the end.' : panel.ok === false ? ' Still failing.' : ''}`; + director = `${plural(checks.length, 'check')} · ${plural(edits.length, 'edit')} · ${fmtCounts(panel.added, panel.removed)} · last: ${ + lastCheck?.ok === undefined ? 'running' : lastCheck.ok ? 'ok' : 'error' + }`; + } + break; + } + case 'check': { + const tests = [...checks].reverse().find((e) => e.tests)?.tests; + if (tests) panel.tests = tests; + if (lastCheck?.ok !== undefined) panel.ok = lastCheck.ok; + panel.notes = notesOf(checks); + const isTest = checks.some((e) => e.intent === 'test'); + const failedFirst = checks.slice(0, -1).some((e) => e.ok === false); + if (lastCheck?.ok === undefined) story = isTest ? 'Claude runs the tests.' : 'Claude runs the checks.'; + else if (lastCheck.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.'; + director = checks + .slice(-3) + .map((e) => `${shortCommand(e)} → ${e.ok === undefined ? 'running' : e.ok ? 'ok' : 'error'}`) + .join(' · '); + if (tests) director += ` · ${tests.passed} passed${tests.failed ? `, ${tests.failed} failed` : ''}`; + break; + } + case 'run': + panel.notes = notesOf(evs); + story = evs.every((e) => e.tool !== 'Bash') ? 'Claude uses a tool.' : 'Claude works in the terminal.'; + director = `${tools} · ${shortCommand(evs[0])}`; + break; + case 'save': { + panel.notes = notesOf(evs); + const labels = evs.map((e) => e.label).join('\n'); + story = /\bgh pr create\b/.test(labels) + ? 'Claude opens a pull request.' + : /\bgit push\b/.test(labels) + ? 'Claude saves the work and sends it up.' + : 'Claude saves the work.'; + director = evs.map(shortCommand).slice(0, 2).join(' · '); + 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 + ? 'Claude shows you its plan and waits for your OK.' + : q?.answer + ? 'Claude asks you, and you answer.' + : 'Claude asks you something.'; + director = `${tools}${q?.answer ? ` · answer: ${clip(q.answer, 60)}` : ''}`; + break; + } + case 'compact': + story = 'Claude packs up its notes to free memory.'; + director = 'compact_boundary'; + break; + case 'montage': + panel.notes = notesOf(evs.filter((e) => e.tool === 'Bash')); + story = `Claude keeps going: ${montageSummary(evs)}.`; + break; + case 'complete': + break; + } + panel.caption = { story, director }; + return panel; +} + +function completePanel(turn: StoryTurn, page: Omit, trailingSay?: string): StoryPanel { + const story = turn.interrupted + ? 'You stop Claude here.' + : turn.durationMs !== undefined + ? `Done in ${roughDuration(turn.durationMs)}.` + : 'Done.'; + const director = [ + turn.interrupted ? 'interrupted' : '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') : '', + ] + .filter(Boolean) + .join(' · '); + const panel: StoryPanel = { kind: 'complete', events: [], caption: { story, director } }; + if (trailingSay) panel.balloon = { who: 'claude', text: trailingSay }; + 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; + + const { panels: grouped, trailingSay } = 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.filter((e) => e.edit).map((e) => e.edit as StoryEdit); + const base: Omit = { + ...summarizeTurn({ ...turn, events }), + partial: turn.partial, + truncated, + events, + filesChanged: unique(edits.map((e) => e.file)).length, + added: edits.reduce((n, e) => n + e.added, 0), + removed: edits.reduce((n, e) => n + e.removed, 0), + helpers: events.filter((e) => e.helper).length, + }; + 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 }; +} + +/** 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 00000000..c6fa1859 --- /dev/null +++ b/src/story-reader.ts @@ -0,0 +1,159 @@ +/** + * @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; 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 { open, stat } 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; +/** 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. */ +const MAX_TRANSCRIPTS = 6; + +interface CacheEntry { + 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; +} + +export interface StoryEntriesResult { + entries: StoryEntry[]; + partial: boolean; +} + +const cache = new LRUMap({ maxSize: MAX_TRANSCRIPTS }); +const inFlight = new Map>(); + +/** For tests: forget every cached transcript. */ +export function clearStoryReaderCache(): void { + cache.clear(); + inFlight.clear(); +} + +async function readRange(path: string, start: number, end: number): Promise { + const length = Math.max(0, end - start); + const buf = Buffer.alloc(length); + const handle = await open(path, 'r'); + try { + 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); + } finally { + await handle.close(); + } +} + +const entryChars = (e: StoryEntry): number => JSON.stringify(e).length; + +function ingest(entry: CacheEntry, buf: Buffer, startsMidLine: boolean): void { + 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; + } + const text = buf.subarray(begin, lastNewline).toString('utf8'); + entry.offset += lastNewline + 1; + for (const line of text.split('\n')) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + continue; // a malformed line is skipped, never fatal + } + for (const reduced of entry.reducer.reduce(parsed)) { + entry.entries.push(reduced); + entry.chars += entryChars(reduced); + } + } + while (entry.entries.length > MAX_ENTRIES || (entry.chars > MAX_CHARS && entry.entries.length > 1)) { + const dropped = entry.entries.shift(); + if (dropped) entry.chars -= entryChars(dropped); + entry.partial = true; + } +} + +async function load(path: string): Promise { + const info = await stat(path); + if (!info.isFile()) throw new Error('Transcript is not a regular file'); + const cached = cache.get(path); + if (cached && cached.size === info.size && cached.mtimeMs === info.mtimeMs) { + return { entries: cached.entries, partial: cached.partial }; + } + 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. + const buf = await readRange(path, cached.offset, info.size); + ingest(cached, buf, false); + cached.size = info.size; + cached.mtimeMs = info.mtimeMs; + cache.set(path, cached); + return { entries: cached.entries, partial: cached.partial }; + } + // First read, a rewrite, or more appended than a tail holds: start over from the tail. + const start = Math.max(0, info.size - STORY_TAIL_BYTES); + const fresh: CacheEntry = { + size: info.size, + mtimeMs: info.mtimeMs, + offset: start, + entries: [], + chars: 0, + reducer: createStoryReducer(), + partial: start > 0, + }; + const buf = await readRange(path, start, info.size); + ingest(fresh, buf, start > 0); + cache.set(path, fresh); + return { entries: fresh.entries, partial: fresh.partial }; +} + +/** + * Reduced entries for the transcript at `path`, newest last. Throws when the file cannot be read; + * the caller turns that into an error response. + */ +export function readStoryEntries(path: string): Promise { + const running = inFlight.get(path); + if (running) return running; + const promise = load(path).finally(() => inFlight.delete(path)); + inFlight.set(path, promise); + return promise; +} diff --git a/src/types/index.ts b/src/types/index.ts index 1af34926..2933a52b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -74,3 +74,4 @@ export * from './user.js'; export * from './webview.js'; export * from './intent.js'; export * from './mcp-sync.js'; +export * from './story.js'; diff --git a/src/types/story.ts b/src/types/story.ts new file mode 100644 index 00000000..0fe2e9eb --- /dev/null +++ b/src/types/story.ts @@ -0,0 +1,169 @@ +/** + * @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' + | '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' | '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[]; +} + +/** 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; +} + +export interface StoryTests { + passed: number; + failed: 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 tool result was flagged as an error; 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; + line?: number; + /** Claude's own short description of a Bash call ("Run the test suite"). */ + description?: string; + output?: string; + outputTruncated?: boolean; + edit?: StoryEdit; + 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[]; + caption: { story: string; director: string }; + balloon?: { who: 'you' | 'claude'; text: string }; + files?: string[]; + notes?: string[]; + helpers?: Array<{ name: string; type: string; task?: string; event: number }>; + added?: number; + removed?: number; + ok?: boolean; + rounds?: number; + 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; + 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; +} + +export type StoryResponse = + | { supported: false; reason: string } + | { supported: true; turns: StoryTurnSummary[]; page: StoryPage | null }; diff --git a/src/utils/claude-transcript.ts b/src/utils/claude-transcript.ts index 8ce01f8b..7fda9d1f 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,31 @@ export function claudeProjectsDir(configDir?: string): string { return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects'); } +/** + * 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 d7d9de77..828f0b96 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -54,6 +54,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 + @@ -4272,6 +4275,7 @@ + diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 44553dd4..04d91b40 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -589,7 +589,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: opt-in and desktop-first for now; the overlay itself is responsive, a phone + entry point is a follow-up rather than one more icon in this header. */ + .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 7c8cb2fc..aabc6bed 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -422,6 +422,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'; @@ -2634,6 +2635,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, @@ -4137,6 +4139,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; @@ -4600,7 +4605,7 @@ Object.assign(CodemanApp.prototype, { _displaySettingKeySet() { const displayKeys = new Set([ 'showFontControls', 'showSystemStats', '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', '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 00000000..04606879 --- /dev/null +++ b/src/web/public/story-mode.css @@ -0,0 +1,848 @@ +/* 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; +} + +.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; + --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); + + position: fixed; + inset: 0; + z-index: 2400; + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + background: var(--story-bg); + color: var(--story-ink); + font-family: var(--story-body); + color-scheme: dark; + padding-top: env(safe-area-inset-top, 0px); + padding-bottom: env(safe-area-inset-bottom, 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; + 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 8px; + font: 500 12.5px/1.2 var(--story-mono); + 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-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-magenta); +} +.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; + } +} + +/* Main: the sheet and the wire */ +.story-main { + overflow: auto; + padding: 16px max(16px, env(safe-area-inset-right, 0px)) 40px max(16px, env(safe-area-inset-left, 0px)); +} +.story-overlay.has-drawer .story-main { + padding-right: calc(var(--story-drawer-w) + 16px); +} +.story-note { + max-width: 1240px; + margin: 0 auto 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; + max-width: 1240px; + margin: 0 auto; +} +.story-overlay.is-director .story-stage { + grid-template-columns: minmax(0, 1fr) 320px; +} +@media (max-width: 1100px) { + .story-overlay.is-director .story-stage { + grid-template-columns: minmax(0, 1fr); + } +} +.story-sheet { + container: story-sheet / inline-size; + background: var(--story-sheet); +} +.story-page { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-auto-rows: 270px; + gap: 14px; + padding: 14px; +} + +/* Panels */ +.story-panel { + position: relative; + margin: 0; + 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-hit:hover, +.story-hit:focus-visible { + outline: 3px dashed var(--story-magenta) !important; + outline-offset: 2px; +} +.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: 17px/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 11px/1.45 var(--story-mono); +} +.story-overlay.is-director .story-cap-story { + display: none; +} +.story-overlay.is-director .story-cap-dir { + 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: 17px/1.12 var(--story-letter); + text-wrap: balance; + overflow-wrap: anywhere; +} +.story-balloon[hidden] { + display: none; +} +.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; + top: 74px; + 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%); +} +.story-panel[data-kind='investigate'] .story-balloon--claude { + right: auto; + left: 30%; + top: 30%; + max-width: 38%; +} +.story-panel[data-kind='backup'] .story-balloon--claude { + right: auto; + left: 56%; + top: 25%; + max-width: 40%; +} +.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); + 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: #fff; + background: var(--story-magenta); +} + +.story-splash { + position: absolute; + z-index: 2; + left: 12px; + top: 12px; + bottom: 12px; + width: 46%; + 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 { + margin: 0; + font: 400 clamp(28px, 4.4cqi, 44px) / 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 12px/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); + 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; + height: 300px; + } + .story-panel[data-kind='complete'] { + height: 430px; + } + .story-splash { + right: 12px; + bottom: auto; + width: auto; + } + .story-splash-title { + font-size: 36px; + } + .story-balloon { + max-width: 52%; + } +} + +/* The wire */ +.story-wire { + display: flex; + flex-direction: column; + min-height: 0; + max-height: calc(100vh - 120px); + position: sticky; + 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; + max-height: 460px; + } +} +.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-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-magenta); +} +.story-tag--ok { + color: var(--story-go); +} +.story-tag--pending { + color: var(--story-yellow); +} + +/* Drawer */ +.story-drawer { + position: absolute; + z-index: 5; + top: 0; + 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: auto; + left: 0; + width: auto; + height: 82vh; + border-left: 0; + border-top: 3px solid var(--story-ink); + padding-left: env(safe-area-inset-left, 0px); + padding-bottom: env(safe-area-inset-bottom, 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-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; + font: 500 12.5px/1.35 var(--story-mono); + overflow-wrap: anywhere; +} +.story-cnt { + font: 500 12.5px/1 var(--story-mono); +} +.story-cnt-a { + color: var(--story-go); +} +.story-cnt-d { + color: var(--story-magenta); +} +.story-code { + font: 12px/1.65 var(--story-mono); +} +.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); +} +.story-ln--del { + background: color-mix(in srgb, var(--story-magenta) 15%, transparent); +} +.story-ln--del .story-ln-mk { + color: var(--story-magenta); +} +.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); + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.story-h { + margin: 16px 18px 0; + font: 17px/1.1 var(--story-letter); +} +.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; +} + +@media (prefers-reduced-motion: reduce) { + .story-fresh *, + .story-arrive, + .story-live i { + animation: none !important; + } +} diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js new file mode 100644 index 00000000..6efeb5f3 --- /dev/null +++ b/src/web/public/story-mode.js @@ -0,0 +1,2389 @@ +/** + * @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): the drawer closes first, then the overlay, and nothing closes while the file + * preview or folder grid is open on top of it. + * + * @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; + +/** + * 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); + } + 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; + 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. */ + 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); + 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) : ''; + const words = stem + .replace(/([a-z])([A-Z])/g, '$1 $2') + .split(/[\s_-]+/) + .filter(Boolean); + const lines = []; + let cur = ''; + for (const w of words) { + if ((cur + w).length > 9 && cur) { + lines.push(cur); + cur = w; + } else cur += w; + } + 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 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 }; + } + function terminal(ctx, x, y, w, h, lines, f) { + 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}`, w - 18); + 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); + }); + if (f % 30 < 15) { + const n = Math.min(rows, lines.length); + ctx.fillStyle = C.balloon; + ctx.fillRect(x + 9, y + 22 + n * 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) { + const ax = Math.max(W * 0.78, W - 104); + 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) { + terminal( + ctx, + 14, + 92, + Math.min(W * 0.58, 220), + 130, + p.panel.notes?.length ? p.panel.notes : ['looking around'], + f + ); + hit('list', 14, 92, Math.min(W * 0.58, 220), 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, `See what ${h.name} did`); + }); + }, + }, + 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.map((i) => p.page.events[i]).filter((e) => e && e.edit); + const first = edits[0]?.edit; + 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 = W - 50; + 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]); + if (p.panel.added) + sfx( + ctx, + `+${p.panel.added}`, + x0 + pw * 0.32, + y0 + ph + 2, + 26, + C.go, + -0.18, + spring(f - 44, { damping: 9, stiffness: 170 }) + ); + if (p.panel.removed) + sfx( + ctx, + `−${p.panel.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 total = t ? t.passed + t.failed : 0; + const dots = total ? Math.min(42, Math.max(7, total)) : 21; + const fail = p.panel.ok === false ? (t && total ? Math.max(1, Math.round((t.failed / total) * dots)) : 1) : 0; + const running = p.panel.ok === undefined; + const fill = running ? (f * 0.6) % (dots + 6) : Math.min(dots, (f - 4) * 1.6); + const label = total ? `${total} checks` : 'checks'; + const b = board(ctx, 16, 106, label, dots, fail, fill); + hit('output', 16, 106, b.w, b.h, 'See the output'); + const cx = Math.min(W * 0.62, W - 100); + const cy = 150; + 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 jump = p.panel.ok && f > 30 ? Math.abs(Math.sin((f - 30) / 7)) * 12 : 0; + agent(ctx, W - 40, 254 - jump, { + frame: f, + scale: 0.66, + 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.map((i) => p.page.events[i]).filter((e) => e && e.edit); + const last = edits[edits.length - 1]?.edit; + 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) { + const dots = 14; + const b = board(ctx, bx, 116, 'checks', dots, p.panel.ok === false ? 2 : 0, Math.min(dots, (f - 4) * 1.6)); + 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; + const spin = (f / 20) % TAU; + 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(); + ctx.restore(); + sfx( + ctx, + `×${p.panel.rounds || 1}`, + W - 60, + 96, + 30, + C.yellow, + -0.1, + spring(f - 20, { damping: 9, stiffness: 160 }) + ); + if (p.panel.ok !== undefined) { + 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, f); + 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'); + const sp = spring(f - 28, { damping: 8, stiffness: 160 }); + if (sp > 0.01) { + ctx.save(); + ctx.translate(bx + 60, by + 56); + ctx.rotate(-0.18); + ctx.scale(sp, sp); + ctx.font = `20px ${DISPLAY}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const w = ctx.measureText('SAVED').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('SAVED', 0, 1); + ctx.restore(); + } + agent(ctx, W - 50, 250, { frame: f, mood: 'happy', scale: 0.8, l: [-30, -52], r: [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) { + const squeeze = interpolate(f, [10, 50], [1, 0.35], easeOut); + 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) { + const cells = [ + { icon: 'look', label: 'reads' }, + { icon: 'run', label: 'runs' }, + { icon: 'edit', label: 'edits' }, + { icon: 'check', label: 'checks' }, + ]; + 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 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: { + bg(ctx, W, H) { + base(ctx, W, H); + rays(ctx, W, H, W > 420 ? W * 0.72 : W / 2, 200, 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; + } + 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, + }); + }, + }, + }; + + return { FPS, HERO, VH, C, readColors, spring, SCENES }; +})(); + +/** 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; +} + +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); + if (!on) this.closeStoryMode(); + }, + + 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(); + st.returnFocus = document.activeElement; + st.open = true; + st.sessionId = sessionId; + st.turnId = null; + st.data = null; + st.error = null; + st.epoch++; + st.first = true; + st.drawer = null; + st.root.hidden = false; + document.body.classList.add('story-open'); + this._storyApplyTheme(); + const session = this.sessions?.get(sessionId); + st.sessionLabel.textContent = session?.name || sessionId.slice(0, 8); + 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); + st.panels = []; + st.pageEl.replaceChildren(); + st.wireList.replaceChildren(); + st.data = 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-labelledby', 'storyTitle'); + + 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 storyBtn = btn('story-seg-btn', 'Story'); + const dirBtn = btn('story-seg-btn', "Director's cut"); + seg.append(storyBtn, dirBtn); + + const live = el('span', 'story-live'); + live.append(el('i'), el('span', null, 'LIVE')); + live.hidden = true; + const closeBtn = btn('story-btn story-close', '×', 'Close Story Mode'); + bar.append(brand, nav, seg, live, closeBtn); + + const main = el('div', 'story-main'); + const note = el('p', 'story-note'); + 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 = ''; + wire.append(wireHead, wireList); + stage.append(sheet, wire); + main.append(note, stage); + + const drawer = el('aside', 'story-drawer'); + drawer.hidden = true; + drawer.setAttribute('aria-label', 'Details'); + const drawerHead = el('header', 'story-drawer-head'); + 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'); + 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); + document.body.appendChild(root); + + const st = { + open: false, + epoch: 0, + sessionId: null, + turnId: null, + data: null, + error: null, + 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, + storyBtn, + dirBtn, + live, + closeBtn, + note, + stage, + pageEl, + wire, + wireCount, + wireList, + drawerEl: drawer, + drawerKind, + drawerLive, + drawerTitle, + drawerSub, + drawerBody, + }; + this._story = st; + + closeBtn.addEventListener('click', () => this.closeStoryMode()); + drawerClose.addEventListener('click', () => this._storyCloseDrawer()); + storyBtn.addEventListener('click', () => this._storySetDirector(false)); + dirBtn.addEventListener('click', () => this._storySetDirector(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._storySetDirector(false); + 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 = ''; + }, + + _storySetDirector(on) { + const st = this._story; + st.director = on; + st.root.classList.toggle('is-director', on); + st.storyBtn.setAttribute('aria-pressed', String(!on)); + st.dirBtn.setAttribute('aria-pressed', String(on)); + st.wire.hidden = !on; + if (on) this._storyRenderWire(); + for (const rt of st.panels) this._storyDrawPanel(rt); + }, + + _storyPickTurn(id) { + const st = this._story; + const turns = st.data?.turns || []; + st.turnId = id && id !== turns[turns.length - 1]?.id ? id : null; + st.first = true; + 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); + }, + + // ── Fetching ── + + _storyScheduleRefresh(delay = STORY_REFRESH_DEBOUNCE_MS) { + const st = this._story; + if (!st?.open) return; + clearTimeout(st.refreshTimer); + st.refreshTimer = setTimeout(() => this._storyRefresh(), delay); + }, + + async _storyRefresh() { + const st = this._story; + if (!st?.open) return; + if (st.inFlight) { + st.again = true; + return; + } + st.inFlight = true; + const epoch = st.epoch; + try { + const qs = st.turnId ? `?turn=${encodeURIComponent(st.turnId)}` : ''; + const res = await this._api(`/api/sessions/${encodeURIComponent(st.sessionId)}/story${qs}`); + if (epoch !== st.epoch || !st.open) return; + let body = null; + try { + body = res ? await res.json() : null; + } catch { + body = null; + } + if (epoch !== st.epoch || !st.open) return; + if (!res || !res.ok || !body?.success) { + st.error = + body?.error || (res?.status === 404 ? 'This session is gone.' : 'Story Mode could not read this session.'); + } else { + st.error = null; + st.data = body.data; + } + this._storyRender(); + } finally { + st.inFlight = false; + if (st.again) { + st.again = false; + this._storyScheduleRefresh(250); + } + } + }, + + // ── Runtime: frame loop, observers, poll, Escape ── + + _storyStartRuntime() { + const st = this._story; + st.ro = new ResizeObserver((entries) => { + for (const entry of entries) { + const rt = st.panels.find((p) => p.el === entry.target); + if (rt) this._storySizePanel(rt); + } + }); + st.io = new IntersectionObserver((entries) => { + for (const entry of entries) { + const rt = st.panels.find((p) => p.el === entry.target); + if (rt) rt.visible = entry.isIntersecting; + } + }); + for (const rt of st.panels) { + st.ro.observe(rt.el); + st.io.observe(rt.el); + } + let last = 0; + const reduced = matchMedia('(prefers-reduced-motion: reduce)'); + const loop = (t) => { + if (!st.open) return; + const dt = last ? Math.min(100, t - last) : 16; + last = t; + if (!reduced.matches && !document.hidden) { + for (const rt of st.panels) { + if (!rt.visible) continue; + rt.clock += (dt / 1000) * StoryArt.FPS; + this._storyDrawPanel(rt); + } + } + st.raf = requestAnimationFrame(loop); + }; + st.raf = requestAnimationFrame(loop); + st.pollTimer = setInterval(() => { + const page = st.data?.page; + if (!st.open || document.hidden || st.turnId) return; + if (!page || !page.complete) this._storyRefresh(); + }, STORY_POLL_MS); + st.keyHandler = (e) => { + if (e.key !== 'Escape' || !st.open) return; + // The file preview or folder grid opened from a panel is on top: its own Escape closes it. + if (document.getElementById('filePreviewOverlay')?.classList.contains('visible')) return; + if (document.getElementById('folderGridOverlay')?.classList.contains('visible')) return; + e.preventDefault(); + e.stopPropagation(); + if (st.drawer) this._storyCloseDrawer(); + else this.closeStoryMode(); + }; + window.addEventListener('keydown', st.keyHandler, true); + st.skinObserver = new MutationObserver(() => { + this._storyApplyTheme(); + for (const rt of st.panels) this._storyDrawPanel(rt); + }); + st.skinObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-skin', 'class'] }); + document.fonts?.ready?.then(() => { + if (!st.open) return; + for (const rt of st.panels) { + rt.bgKey = ''; + this._storyDrawPanel(rt); + } + }); + }, + + _storyStopRuntime() { + const st = this._story; + cancelAnimationFrame(st.raf); + st.raf = 0; + clearTimeout(st.refreshTimer); + clearInterval(st.pollTimer); + st.refreshTimer = null; + st.pollTimer = null; + st.ro?.disconnect(); + st.io?.disconnect(); + st.skinObserver?.disconnect(); + st.ro = st.io = st.skinObserver = null; + if (st.keyHandler) window.removeEventListener('keydown', st.keyHandler, true); + st.keyHandler = null; + }, + + // ── Rendering ── + + _storyRender() { + const st = this._story; + if (!st?.open) return; + const data = st.data; + const page = data?.supported ? data.page : null; + const turns = data?.supported ? data.turns : []; + + // Bar: turn picker, prev/next, LIVE. + const sel = st.turnSelect; + sel.replaceChildren( + ...turns.map((t, i) => { + const o = document.createElement('option'); + o.value = t.id; + o.textContent = `${i + 1}. ${t.prompt || (t.origin === 'task' ? 'Background task' : 'Earlier work')}`; + return o; + }) + ); + if (page) sel.value = page.id; + sel.disabled = turns.length < 2; + const idx = page ? turns.findIndex((t) => t.id === page.id) : -1; + st.prev.disabled = idx <= 0; + st.next.disabled = idx < 0 || idx >= turns.length - 1; + st.live.hidden = !(page && !page.complete && !page.interrupted && !st.turnId); + + // Note line: errors, unsupported sessions, empty sessions, cut pages. + let note = ''; + if (st.error) note = st.error; + else if (!data) note = 'Reading the session…'; + else if (!data.supported) note = data.reason; + else if (!page) note = 'Nothing to draw yet. The page fills in as soon as Claude starts working in this session.'; + else if (page.partial) + note = 'This turn started before the part of the transcript Story Mode reads, so its first panels are missing.'; + else if (page.truncated) note = 'A long turn: the oldest outputs were left out to keep the page fast.'; + st.note.textContent = note; + st.note.hidden = !note; + + this._storyRenderPanels(page); + if (st.director) this._storyRenderWire(); + if (st.drawer) this._storyRenderDrawer(false); + // Panels on screen when a page first loads are drawn finished; only later arrivals animate in. + if (data) st.first = false; + }, + + _storyRenderPanels(page) { + const st = this._story; + const want = page ? page.panels : []; + const working = !!page && !page.complete && !page.interrupted; + // While the turn runs, the pencils placeholder takes the next slot, so the newest panel keeps + // its own width instead of stretching to the end of the row. + const prefs = want.map(storyPreferredSpan).concat(working ? [2] : []); + const spans6 = storyLayoutSpans(prefs, 6); + const spans2 = storyLayoutSpans( + prefs.map((p) => (p >= 4 ? 2 : 1)), + 2 + ); + const fresh = !st.first; + want.forEach((panel, i) => { + let rt = st.panels[i]; + if (rt && rt.kind !== panel.kind) { + this._storyDropPanel(rt); + rt = null; + } + if (!rt) { + rt = this._storyCreatePanel(panel.kind, fresh); + const after = st.panels[i - 1]?.el; + if (after) after.after(rt.el); + else st.pageEl.prepend(rt.el); + st.panels[i] = rt; + st.ro?.observe(rt.el); + st.io?.observe(rt.el); + } + rt.panel = panel; + rt.el.style.setProperty('--story-span6', String(spans6[i])); + rt.el.style.setProperty('--story-span2', String(spans2[i])); + this._storyFillPanelText(rt, page, i); + this._storyDrawPanel(rt); + }); + for (const rt of st.panels.splice(want.length)) this._storyDropPanel(rt); + + // While the turn runs, a blue-pencil rough stands where the next panel will be inked. + let pencils = st.pageEl.querySelector('.story-pencils'); + if (working && !pencils) { + pencils = document.createElement('div'); + pencils.className = 'story-pencils'; + const word = document.createElement('span'); + word.textContent = 'Pencils'; + const small = document.createElement('small'); + small.textContent = 'Claude is working…'; + pencils.append(word, small); + } + if (working) { + pencils.style.setProperty('--story-span6', String(spans6[want.length])); + pencils.style.setProperty('--story-span2', String(spans2[want.length])); + st.pageEl.append(pencils); + } else pencils?.remove(); + }, + + _storyCreatePanel(kind, fresh) { + const fig = document.createElement('figure'); + fig.className = `story-panel${fresh ? ' story-fresh' : ''}`; + fig.dataset.kind = kind; + const canvas = document.createElement('canvas'); + canvas.setAttribute('aria-hidden', 'true'); + const cap = document.createElement('p'); + cap.className = 'story-cap'; + const capStory = document.createElement('span'); + capStory.className = 'story-cap-story'; + const capDir = document.createElement('span'); + capDir.className = 'story-cap-dir'; + cap.append(capStory, capDir); + const balloon = document.createElement('p'); + balloon.className = 'story-balloon'; + balloon.hidden = true; + const liveTag = document.createElement('span'); + liveTag.className = 'story-live-tag'; + liveTag.textContent = 'LIVE'; + liveTag.hidden = true; + fig.append(canvas, cap, balloon, liveTag); + let splash = null; + if (kind === 'complete') { + splash = document.createElement('div'); + splash.className = 'story-splash'; + const titleEl = document.createElement('p'); + titleEl.className = 'story-splash-title'; + const chips = document.createElement('ul'); + chips.className = 'story-chips'; + splash.append(titleEl, cap, balloon, chips); + fig.append(splash); + } + let answer = null; + if (kind === 'ask') { + answer = document.createElement('p'); + answer.className = 'story-answer'; + answer.hidden = true; + fig.append(answer); + } + return { + el: fig, + canvas, + ctx: canvas.getContext('2d'), + kind, + panel: null, + capStory, + capDir, + balloon, + liveTag, + splash, + answer, + clock: fresh && !matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : StoryArt.HERO, + w: 0, + h: 0, + dpr: 1, + bg: null, + bgKey: '', + hitEls: new Map(), + visible: true, + }; + }, + + _storyDropPanel(rt) { + this._story?.ro?.unobserve(rt.el); + this._story?.io?.unobserve(rt.el); + rt.el.remove(); + rt.bg = null; + }, + + _storyFillPanelText(rt, page, index) { + const p = rt.panel; + rt.capStory.textContent = p.caption.story; + rt.capDir.textContent = p.caption.director || p.caption.story; + const balloonText = p.kind === 'ask' && p.question?.text ? p.question.text : p.balloon?.text; + rt.balloon.hidden = !balloonText; + rt.balloon.textContent = balloonText || ''; + rt.balloon.className = `story-balloon ${p.kind === 'mission' || p.balloon?.who === 'you' ? 'story-balloon--you' : 'story-balloon--claude'}`; + rt.liveTag.hidden = !p.live; + rt.el.classList.toggle('is-live', !!p.live); + rt.el.setAttribute('aria-label', `Panel ${index + 1}: ${p.caption.story}`); + if (rt.answer) { + rt.answer.hidden = !p.question?.answer; + rt.answer.textContent = p.question?.answer ? `You chose: ${p.question.answer}` : ''; + } + if (rt.splash) { + rt.splash.querySelector('.story-splash-title').textContent = page.interrupted ? 'Stopped!' : 'Mission complete!'; + const chips = rt.splash.querySelector('.story-chips'); + const items = []; + if (page.durationMs !== undefined) items.push({ text: this._storyDuration(page.durationMs) }); + if (page.filesChanged) + items.push({ + text: `${page.filesChanged} file${page.filesChanged === 1 ? '' : 's'} changed`, + open: 'diff:page', + }); + items.push({ text: `${page.toolCalls} tool call${page.toolCalls === 1 ? '' : 's'}`, open: 'list:page' }); + if (page.helpers) items.push({ text: `${page.helpers} helper${page.helpers === 1 ? '' : 's'}` }); + chips.replaceChildren( + ...items.map((item) => { + const li = document.createElement('li'); + const inner = document.createElement(item.open ? 'button' : 'span'); + if (item.open) { + inner.type = 'button'; + inner.dataset.storyOpen = item.open; + } + inner.textContent = item.text; + li.append(inner); + return li; + }) + ); + } + }, + + _storyDuration(ms) { + const s = Math.round(ms / 1000); + if (s < 60) return `${s} s`; + const m = Math.floor(s / 60); + return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min`; + }, + + _storySizePanel(rt) { + const w = rt.el.clientWidth; + const h = rt.el.clientHeight; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + if (w === rt.w && h === rt.h && dpr === rt.dpr) return; + rt.w = w; + rt.h = h; + rt.dpr = dpr; + rt.canvas.width = Math.max(1, Math.round(w * dpr)); + rt.canvas.height = Math.max(1, Math.round(h * dpr)); + rt.bgKey = ''; + this._storyDrawPanel(rt); + }, + + _storyDrawPanel(rt) { + const st = this._story; + if (!rt.panel || !rt.w || !rt.h) return; + const scene = StoryArt.SCENES[rt.kind]; + if (!scene) return; + const s = rt.h / StoryArt.VH; + const W = rt.w / s; + const k = rt.dpr * s; + const key = `${rt.canvas.width}x${rt.canvas.height}|${StoryArt.C.key}`; + if (rt.bgKey !== key) { + rt.bg ??= document.createElement('canvas'); + rt.bg.width = rt.canvas.width; + rt.bg.height = rt.canvas.height; + const b = rt.bg.getContext('2d'); + b.setTransform(k, 0, 0, k, 0, 0); + scene.bg(b, W, StoryArt.VH); + rt.bgKey = key; + } + const ctx = rt.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, rt.canvas.width, rt.canvas.height); + ctx.drawImage(rt.bg, 0, 0); + ctx.setTransform(k, 0, 0, k, 0, 0); + const hits = []; + const hit = (id, x, y, w, h, label) => hits.push({ id, x, y, w, h, label }); + scene.draw(ctx, W, StoryArt.VH, rt.clock, { panel: rt.panel, page: st.data?.page, director: st.director }, hit); + this._storySyncHits(rt, hits, s); + }, + + /** Canvas click regions become real buttons: focusable, labelled, keyboard-reachable. */ + _storySyncHits(rt, hits, s) { + const seen = new Set(); + for (const h of hits) { + seen.add(h.id); + let b = rt.hitEls.get(h.id); + if (!b) { + b = document.createElement('button'); + b.type = 'button'; + b.className = 'story-hit'; + b.setAttribute('aria-label', h.label); + b.title = h.label; + rt.el.appendChild(b); + rt.hitEls.set(h.id, b); + } + b.dataset.storyOpen = `${h.id}@${rt.el.dataset.kind}:${this._story.panels.indexOf(rt)}`; + const L = Math.round(h.x * s); + const T = Math.round(h.y * s); + const Wd = Math.round(h.w * s); + const Ht = Math.round(h.h * s); + const k = `${L},${T},${Wd},${Ht}`; + if (b._k !== k) { + b._k = k; + b.style.cssText = `left:${L}px;top:${T}px;width:${Wd}px;height:${Ht}px`; + } + } + for (const [id, b] of rt.hitEls) { + if (!seen.has(id)) { + b.remove(); + rt.hitEls.delete(id); + } + } + }, + + // ── The wire ── + + _storyRenderWire() { + const st = this._story; + const page = st.data?.supported ? st.data.page : null; + const list = st.wireList; + if (!page) { + list.replaceChildren(); + st.wireCount.textContent = ''; + return; + } + const panelOf = new Map(); + page.panels.forEach((p, i) => p.events.forEach((e) => panelOf.set(e, i))); + const rows = []; + let lastPanel = -2; + page.events.forEach((ev, i) => { + const pi = panelOf.has(i) ? panelOf.get(i) : lastPanel; + if (pi !== lastPanel && pi >= 0) { + const g = document.createElement('li'); + g.className = 'story-wire-group'; + g.textContent = `Panel ${pi + 1} · ${STORY_KIND_NAMES[page.panels[pi].kind] || page.panels[pi].kind}`; + rows.push(g); + lastPanel = pi; + } + rows.push(this._storyEventRow(ev, i)); + }); + list.replaceChildren(...rows); + st.wireCount.textContent = `${page.toolCalls} tool call${page.toolCalls === 1 ? '' : 's'}, ${page.events.length} events. Underlined parts open.`; + }, + + _storyEventRow(ev, index) { + const li = document.createElement('li'); + li.className = 'story-ev'; + li.dataset.actor = ev.actor; + const t = document.createElement('span'); + t.className = 'story-ev-time'; + const d = new Date(ev.at); + t.textContent = Number.isNaN(d.getTime()) + ? '' + : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); + const body = document.createElement('span'); + body.className = 'story-ev-body'; + const who = document.createElement('span'); + who.className = 'story-ev-who'; + who.textContent = ev.actor; + const tool = document.createElement('b'); + tool.textContent = ev.tool; + const target = this._storyEventTarget(ev, index); + let label; + if (target) { + label = document.createElement('button'); + label.type = 'button'; + label.className = 'story-link'; + label.dataset.storyOpen = target; + } else { + label = document.createElement('span'); + } + label.textContent = ev.label; + const tag = document.createElement('i'); + tag.className = `story-tag story-tag--${ev.ok === false ? 'bad' : ev.pending ? 'pending' : ev.intent}`; + tag.textContent = ev.ok === false ? 'error' : ev.pending ? 'running' : ev.intent; + body.append(who, ' ', tool, ' ', label, ' ', tag); + li.append(t, body); + return li; + }, + + /** What a click on an event opens, as a `data-story-open` target, or null. */ + _storyEventTarget(ev, index) { + if (ev.edit) return `diff:event:${index}`; + if (ev.helper) return `helper:event:${index}`; + if (ev.output !== undefined || ev.outputTruncated) return `output:event:${index}`; + if (ev.file) return `file:event:${index}`; + if (ev.question) return `ask:event:${index}`; + if (ev.todos) return `plan:event:${index}`; + return null; + }, + + // ── Click targets and the drawer ── + + /** + * Targets: `@:` from a canvas hit, `:event:` from the wire or the + * drawer, `:page` from the final panel's chips. + */ + _storyOpenTarget(target, trigger) { + const st = this._story; + const page = st.data?.page; + if (!page) return; + const at = target.indexOf('@'); + if (at >= 0) { + const what = target.slice(0, at); + const panelIndex = Number(target.slice(target.lastIndexOf(':') + 1)); + const panel = page.panels[panelIndex]; + if (!panel) return; + if (what.startsWith('file:')) { + const file = panel.files?.[Number(what.slice(5))]; + if (file) this.openFilePreview?.(file, st.sessionId); + return; + } + const [view, arg] = what.split(':'); + if (view === 'helper') { + this._storyOpenDrawer({ view: 'helper', event: page.events[Number(arg)]?.id }, trigger); + return; + } + this._storyOpenDrawer({ view, panel: panelIndex, kind: panel.kind }, trigger); + return; + } + const [view, scope, arg] = target.split(':'); + if (scope === 'page') { + this._storyOpenDrawer({ view, page: true }, trigger); + return; + } + const ev = page.events[Number(arg)]; + if (!ev) return; + if (view === 'file') { + this.openFilePreview?.(ev.file, st.sessionId); + return; + } + this._storyOpenDrawer({ view, event: ev.id }, trigger); + }, + + _storyOpenDrawer(source, trigger) { + const st = this._story; + if (!st.drawer) st.drawerReturn = trigger || null; + st.drawer = source; + st.drawerSeen = new Set(); + st.drawerEl.hidden = false; + st.root.classList.add('has-drawer'); + this._storyRenderDrawer(true); + st.drawerEl.querySelector('.story-drawer-close')?.focus({ preventScroll: true }); + }, + + _storyCloseDrawer({ refocus = true } = {}) { + const st = this._story; + if (!st?.drawer) return; + st.drawer = null; + st.drawerEl.hidden = true; + st.root.classList.remove('has-drawer'); + st.drawerBody.replaceChildren(); + const back = st.drawerReturn; + st.drawerReturn = null; + if (refocus && back && document.contains(back)) back.focus({ preventScroll: true }); + }, + + /** The events a drawer shows, recomputed from the newest page so a live view keeps up. */ + _storyDrawerEvents(source, page) { + if (source.event) { + const i = page.events.findIndex((e) => e.id === source.event); + return i >= 0 ? [{ ev: page.events[i], i }] : []; + } + let indices; + if (source.page) indices = page.events.map((_, i) => i); + else { + const panel = page.panels[source.panel]; + // A refresh can regroup panels; follow the panel by kind if the index moved. + const match = panel && panel.kind === source.kind ? panel : page.panels.find((p) => p.kind === source.kind); + indices = match ? match.events : []; + } + return indices.map((i) => ({ ev: page.events[i], i })).filter((x) => x.ev); + }, + + _storyRenderDrawer(first) { + const st = this._story; + const source = st.drawer; + const page = st.data?.page; + if (!source || !page) return; + const items = this._storyDrawerEvents(source, page); + const body = st.drawerBody; + const prevScroll = body.scrollTop; + const el = (tag, cls, text) => { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; + }; + const live = !page.complete && !page.interrupted && !st.turnId; + st.drawerLive.hidden = !live; + let kind = ''; + let title = ''; + let sub = ''; + const out = []; + let firstNew = null; + const isNew = (id) => !first && !st.drawerSeen.has(id); + + if (source.view === 'diff') { + const edits = items.filter((x) => x.ev.edit); + kind = live ? 'Live diff' : 'Diff'; + const added = edits.reduce((n, x) => n + x.ev.edit.added, 0); + const removed = edits.reduce((n, x) => n + x.ev.edit.removed, 0); + const files = new Set(edits.map((x) => x.ev.edit.file)).size; + title = files === 1 ? edits[0].ev.edit.file : `${files} files`; + sub = `${edits.length} edit${edits.length === 1 ? '' : 's'} · +${added} −${removed}${live ? ' · new edits slide in as Claude makes them' : ''}`; + if (!edits.length) out.push(el('p', 'story-empty', 'No changes in this part of the turn yet.')); + for (const { ev } of edits) { + const section = this._storyDiffSection(ev, el); + if (isNew(ev.id)) { + section.classList.add('story-arrive'); + firstNew ??= section; + } + out.push(section); + } + } else if (source.view === 'output' || source.view === 'list') { + const withOutput = items.filter((x) => x.ev.tool !== 'Text' && x.ev.intent !== 'prompt'); + kind = source.view === 'list' ? 'Steps' : 'Output'; + title = source.page ? 'Every step of this turn' : STORY_KIND_NAMES[source.kind] || 'Steps'; + sub = `${withOutput.length} step${withOutput.length === 1 ? '' : 's'}`; + if (!withOutput.length) out.push(el('p', 'story-empty', 'Nothing here yet.')); + for (const { ev, i } of withOutput) { + const section = this._storyOutputSection(ev, i, el); + if (isNew(ev.id)) { + section.classList.add('story-arrive'); + firstNew ??= section; + } + out.push(section); + } + } else if (source.view === 'helper') { + const ev = items[0]?.ev; + kind = 'Helper'; + title = ev?.helper ? `${ev.helper.name} (${ev.helper.type})` : 'Helper'; + const h = ev?.helper; + sub = h + ? [ + h.toolUses !== undefined ? `${h.toolUses} tool calls` : '', + h.durationMs !== undefined ? this._storyDuration(h.durationMs) : '', + ev.pending ? 'still working' : '', + ] + .filter(Boolean) + .join(' · ') + : ''; + if (h) { + out.push(el('h4', 'story-h', 'Claude asked'), el('pre', 'story-pre', h.prompt || '(no prompt)')); + out.push( + el('h4', 'story-h', 'The helper answered'), + this._storyLinkifiedPre(h.result || (ev.pending ? 'Still working…' : '(no answer)')) + ); + } + } else if (source.view === 'plan') { + const ev = [...items].reverse().find((x) => x.ev.todos)?.ev; + kind = 'Plan'; + title = 'The plan'; + const ul = el('ul', 'story-todos'); + for (const t of ev?.todos || []) { + const li = el('li', t.done ? 'is-done' : '', t.text); + ul.append(li); + } + if (!ev) for (const x of items) ul.append(el('li', '', x.ev.label)); + out.push(ul); + } else if (source.view === 'ask') { + const ev = items.find((x) => x.ev.question)?.ev; + kind = 'Your call'; + title = ev?.question?.text || 'Question'; + if (ev?.question?.options?.length) { + const ul = el('ul', 'story-todos'); + for (const o of ev.question.options) ul.append(el('li', o === ev.question.answer ? 'is-done' : '', o)); + out.push(ul); + } + out.push( + el( + 'p', + 'story-answer-line', + ev?.question?.answer ? `You chose: ${ev.question.answer}` : 'Waiting for your answer in the terminal.' + ) + ); + } + + st.drawerKind.textContent = kind; + st.drawerTitle.textContent = title; + st.drawerSub.textContent = sub; + body.replaceChildren(...out); + for (const { ev } of items) st.drawerSeen.add(ev.id); + if (first) body.scrollTop = 0; + else if (firstNew) body.scrollTop = Math.max(0, firstNew.offsetTop - 12); + else body.scrollTop = prevScroll; + }, + + _storyDiffSection(ev, el) { + const edit = ev.edit; + const section = el('section', 'story-df'); + const head = el('div', 'story-df-head'); + const name = el('span', 'story-df-path', edit.file); + const cnt = el('span', 'story-cnt'); + cnt.append( + el('span', 'story-cnt-a', edit.added ? `+${edit.added}` : ''), + ' ', + el('span', 'story-cnt-d', edit.removed ? `−${edit.removed}` : '') + ); + const open = document.createElement('button'); + open.type = 'button'; + open.className = 'story-btn-s'; + open.textContent = 'Open file'; + open.addEventListener('click', () => this.openFilePreview?.(edit.file, this._story.sessionId)); + head.append(name, cnt, open); + section.append(head); + const code = el('div', 'story-code'); + for (const h of edit.hunks || []) { + code.append( + el('div', 'story-ln story-ln--hunk', `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`) + ); + let o = h.oldStart; + let n = h.newStart; + for (const line of h.lines) { + const mark = line[0]; + const row = el('div', `story-ln${mark === '+' ? ' story-ln--add' : mark === '-' ? ' story-ln--del' : ''}`); + row.append( + el('span', 'story-ln-no', mark === '+' ? '' : String(o)), + el('span', 'story-ln-no', mark === '-' ? '' : String(n)), + el('span', 'story-ln-mk', mark === '+' ? '+' : mark === '-' ? '−' : ''), + el('span', 'story-ln-tx', line.slice(1) || ' ') + ); + if (mark !== '+') o++; + if (mark !== '-') n++; + code.append(row); + } + } + section.append(code); + if (edit.truncated) + section.append(el('p', 'story-empty', 'Some lines of this edit were left out. Open the file to see all of it.')); + return section; + }, + + _storyOutputSection(ev, index, el) { + const section = el('section', 'story-out'); + const head = el('div', 'story-out-head'); + const cmd = el('span', 'story-out-cmd', `${ev.tool === 'Bash' ? '$ ' : `${ev.tool} `}${ev.label}`); + const status = el( + 'span', + `story-tag story-tag--${ev.ok === false ? 'bad' : ev.pending ? 'pending' : 'ok'}`, + ev.ok === false ? 'error' : ev.pending ? 'running' : 'ok' + ); + head.append(cmd, status); + section.append(head); + if (ev.description) section.append(el('p', 'story-out-desc', ev.description)); + const target = this._storyEventTarget(ev, index); + if (ev.output !== undefined) { + section.append(this._storyLinkifiedPre(ev.output)); + if (ev.outputTruncated) section.append(el('p', 'story-empty', 'Only the end of this output is kept.')); + } else if (ev.outputTruncated) { + section.append(el('p', 'story-empty', 'This output was left out to keep a long turn fast.')); + } else if (target && !target.startsWith('output:')) { + const b = el('button', 'story-btn-s', ev.edit ? 'See the change' : ev.file ? 'Open file' : 'Open'); + b.type = 'button'; + b.dataset.storyOpen = target; + section.append(b); + } + return section; + }, + + /** Output text with every file path as a link into the file preview (the terminal's own matcher). */ + _storyLinkifiedPre(text) { + const pre = document.createElement('pre'); + pre.className = 'story-pre'; + const value = String(text || ''); + const links = typeof findFilePathLinks === 'function' ? findFilePathLinks(value).filter((l) => !l.folder) : []; + const workingDir = this.sessions?.get(this._story.sessionId)?.workingDir; + let last = 0; + for (const link of links) { + pre.append(document.createTextNode(value.slice(last, link.index))); + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'story-link'; + b.textContent = link.path; + const resolved = + typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(link.path, workingDir) : link.path; + b.addEventListener('click', () => this.openFilePreview?.(resolved, this._story.sessionId)); + pre.append(b); + last = link.index + link.path.length; + } + pre.append(document.createTextNode(value.slice(last))); + return pre; + }, +}); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 15517d20..5d4065bf 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -13257,6 +13257,15 @@ kbd { display: none !important; } +/* "Story Mode" header toggle (story-mode.js, App Settings → Header buttons), opt-in. Same marker + pattern as the Response Viewer; mobile.css keeps it off phones. */ +.btn-story-header { + display: inline-flex !important; +} +.btn-story-header.btn-story-header--hidden { + display: none !important; +} + /* "Approvals" header bell: appears ONLY while prompts are pending (JS toggles the marker class on count changes), so it ships hidden and stays out of the default header. Same marker pattern as the attachments button. */ diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index aa1d721f..95baa3aa 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -15,6 +15,7 @@ export { registerApprovalRoutes } from './approval-routes.js'; export { registerRebootRestoreRoutes } from './reboot-restore-routes.js'; export { registerReadMyMindRoutes } from './readmymind-routes.js'; export { registerGitStatusRoutes } from './git-status-routes.js'; +export { registerStoryRoutes } from './story-routes.js'; export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js'; export { registerCaseRoutes } from './case-routes.js'; export { registerWorktreeRoutes } from './worktree-routes.js'; diff --git a/src/web/routes/story-routes.ts b/src/web/routes/story-routes.ts new file mode 100644 index 00000000..db810966 --- /dev/null +++ b/src/web/routes/story-routes.ts @@ -0,0 +1,76 @@ +/** + * @fileoverview `GET /api/sessions/:id/story`: one turn of a Claude Code session as a comic page + * (Story Mode, `story-mode.js` in the browser). EXPERIMENTAL: the payload shape is not frozen. + * + * Query: `turn=` picks a turn from the `turns` list; without it the newest turn is drawn. + * Answers `{ supported: false, reason }` for a session it cannot draw, decided from the CLI's + * registry capability (`capabilities.transcript === 'claude-jsonl'`, never the mode id) and from + * where the transcript lives: a remote or Docker session's transcript is not on this host. + * + * Read-only: unlike `last-response` it never adopts a conversation id or persists anything. The file + * read is the session's own transcript, chosen here and never named by the request: the path the + * Claude hook reported for this session, else `.jsonl` under the projects directory. + * Reading is bounded and async (story-reader.ts); what comes back is untrusted text the browser + * renders with `textContent`, and file paths in it open through the existing file preview. + */ + +import type { FastifyInstance } from 'fastify'; +import { ApiErrorCode, createErrorResponse, type ApiResponse, type StoryResponse } from '../../types.js'; +import { getCli } from '../../config/cli-registry/registry.js'; +import { buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js'; +import { readStoryEntries } from '../../story-reader.js'; +import { findClaudeTranscriptPath } from '../../utils/claude-transcript.js'; +import { findSessionOrFail } from '../route-helpers.js'; +import type { ConfigPort, SessionPort } from '../ports/index.js'; + +/** The hook-reported path when it looks like a transcript, else a lookup by conversation id. */ +async function resolveTranscriptPath(hookPath: string | null, conversationId: string): Promise { + if (hookPath && hookPath.startsWith('/') && hookPath.endsWith('.jsonl')) return hookPath; + return findClaudeTranscriptPath(conversationId); +} + +export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & ConfigPort): void { + app.get('/api/sessions/:id/story', async (req, reply): Promise> => { + const { id } = req.params as { id: string }; + const { turn } = req.query as { turn?: unknown }; + const session = findSessionOrFail(ctx, id, req); + + if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') { + return { + success: true, + data: { supported: false, reason: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' }, + }; + } + if (session.remote || session.docker) { + return { + success: true, + data: { + supported: false, + reason: + 'Story Mode reads transcripts on this machine. This session keeps its transcript on another host or inside a container.', + }, + }; + } + + const transcriptPath = await resolveTranscriptPath( + ctx.getTranscriptPath(id), + session.claudeSessionId || session.id + ); + if (!transcriptPath) return { success: true, data: { supported: true, turns: [], page: null } }; + + let entries; + try { + ({ entries } = await readStoryEntries(transcriptPath)); + } catch { + reply.code(500); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Could not read the session transcript'); + } + + const turns = buildStoryTurns(entries); + const wanted = typeof turn === 'string' && turn ? turns.find((t) => t.id === turn) : turns[turns.length - 1]; + return { + success: true, + data: { supported: true, turns: summarizeTurns(turns), page: wanted ? buildStoryPage(wanted) : null }, + }; + }); +} diff --git a/src/web/schemas.ts b/src/web/schemas.ts index d7231743..0d792281 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1584,6 +1584,7 @@ export const SettingsUpdateSchema = z showCost: z.boolean().optional(), showLifecycleLog: z.boolean().optional(), showResponseViewer: z.boolean().optional(), + showStoryButton: z.boolean().optional(), showMonitor: z.boolean().optional(), showProjectInsights: z.boolean().optional(), showFileBrowser: z.boolean().optional(), diff --git a/src/web/server.ts b/src/web/server.ts index f388268c..d06fe538 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -189,6 +189,7 @@ import { registerRebootRestoreRoutes, registerReadMyMindRoutes, registerGitStatusRoutes, + registerStoryRoutes, registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, @@ -1152,6 +1153,7 @@ export class WebServer extends EventEmitter { registerRebootRestoreRoutes(this.app, ctx); registerReadMyMindRoutes(this.app, ctx); registerGitStatusRoutes(this.app, ctx); + registerStoryRoutes(this.app, ctx); registerStatusTelemetryRoutes(this.app, ctx); registerSystemRoutes(this.app, ctx); registerCaseRoutes(this.app, ctx); diff --git a/test/routes/story-routes.test.ts b/test/routes/story-routes.test.ts new file mode 100644 index 00000000..045b931a --- /dev/null +++ b/test/routes/story-routes.test.ts @@ -0,0 +1,157 @@ +/** + * @fileoverview GET /api/sessions/:id/story (Story Mode) against invented transcripts in a temp dir, + * plus the reader's incremental tail read. Port: N/A (app.inject()). + */ +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerStoryRoutes } from '../../src/web/routes/story-routes.js'; +import { clearStoryReaderCache, readStoryEntries } from '../../src/story-reader.js'; +import { bash, demoTurn, end, prompt, read, resetClock, said } from '../story-fixtures.js'; + +const URL = '/api/sessions/test-session-1/story'; +const jsonl = (lines: unknown[]) => lines.map((l) => JSON.stringify(l)).join('\n') + '\n'; + +let dir: string; +let transcript: string; + +async function setup(opts: { hookPath?: string | null; mode?: string; remote?: unknown; docker?: unknown } = {}) { + const h = await createRouteTestHarness((app, ctx) => registerStoryRoutes(app, ctx)); + const session = h.ctx._session as unknown as Record; + if (opts.mode) session.mode = opts.mode; + if (opts.remote) session.remote = opts.remote; + if (opts.docker) session.docker = opts.docker; + h.ctx.getTranscriptPath.mockReturnValue(opts.hookPath === undefined ? transcript : opts.hookPath); + return h; +} + +beforeEach(() => { + resetClock(); + clearStoryReaderCache(); + dir = mkdtempSync(join(tmpdir(), 'story-route-')); + transcript = join(dir, 'conversation.jsonl'); +}); +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('GET /api/sessions/:id/story', () => { + it('draws the newest turn of the hook-reported transcript', async () => { + writeFileSync(transcript, jsonl([prompt('first job'), said('Done.'), end(2000), ...demoTurn()])); + const { app } = await setup(); + const res = await app.inject({ method: 'GET', url: URL }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.data.supported).toBe(true); + expect(body.data.turns.map((t: { prompt: string }) => t.prompt)).toEqual([ + 'first job', + 'The checkout button does nothing on phones. Can you fix it?', + ]); + expect(body.data.page.panels.map((p: { kind: string }) => p.kind)).toEqual([ + 'mission', + 'investigate', + 'plan', + 'backup', + 'loop', + 'save', + 'complete', + ]); + // Nothing a Read or an Edit result carried in full ever leaves the server. + expect(res.body).not.toContain('FILE CONTENT'); + expect(res.body).not.toContain('WHOLE ORIGINAL FILE'); + }); + + it('draws an older turn by id and answers page null for an unknown one', async () => { + writeFileSync(transcript, jsonl([prompt('first job'), said('Done.'), end(2000), ...demoTurn()])); + const { app } = await setup(); + const list = (await app.inject({ method: 'GET', url: URL })).json().data.turns; + const older = await app.inject({ method: 'GET', url: `${URL}?turn=${encodeURIComponent(list[0].id)}` }); + expect(older.json().data.page).toMatchObject({ prompt: 'first job', complete: true }); + const gone = await app.inject({ method: 'GET', url: `${URL}?turn=nope` }); + expect(gone.json().data.page).toBeNull(); + }); + + it('is supported with no turns when there is no transcript yet', async () => { + const { app } = await setup({ hookPath: null }); + const res = await app.inject({ method: 'GET', url: URL }); + expect(res.json()).toEqual({ success: true, data: { supported: true, turns: [], page: null } }); + }); + + it('answers unsupported for a CLI that writes no Claude transcript', async () => { + const { app } = await setup({ mode: 'codex' }); + const data = (await app.inject({ method: 'GET', url: URL })).json().data; + expect(data.supported).toBe(false); + expect(data.reason).toMatch(/different CLI/); + }); + + it('answers unsupported for remote and Docker sessions, whose transcript is not on this host', async () => { + for (const where of [{ remote: { hostId: 'h1' } }, { docker: { containerName: 'c1' } }]) { + clearStoryReaderCache(); + const { app } = await setup(where); + const data = (await app.inject({ method: 'GET', url: URL })).json().data; + expect(data).toMatchObject({ supported: false }); + } + }); + + it('never reads a hook path that is not a transcript', async () => { + writeFileSync(join(dir, 'secret.txt'), jsonl(demoTurn())); + const { app } = await setup({ hookPath: join(dir, 'secret.txt') }); + const res = await app.inject({ method: 'GET', url: URL }); + expect(res.json().data).toEqual({ supported: true, turns: [], page: null }); + }); + + it('fails with OPERATION_FAILED when the transcript cannot be read', async () => { + const { app } = await setup({ hookPath: join(dir, 'missing.jsonl') }); + const res = await app.inject({ method: 'GET', url: URL }); + expect(res.statusCode).toBe(500); + expect(res.json()).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' }); + }); + + it('404s an unknown session', async () => { + const { app } = await setup(); + const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/story' }); + expect(res.statusCode).toBe(404); + }); + + it('a live turn grows as the transcript grows', async () => { + writeFileSync(transcript, jsonl([prompt('go'), ...read('r1', '/w/a.ts')])); + const { app } = await setup(); + const before = (await app.inject({ method: 'GET', url: URL })).json().data.page; + expect(before.panels.map((p: { kind: string }) => p.kind)).toEqual(['mission', 'investigate']); + expect(before.panels[1].live).toBe(true); + + appendFileSync(transcript, jsonl([...bash('b1', 'npm test', 'Run tests', ' Tests 4 passed (4)'), end(9000)])); + const after = (await app.inject({ method: 'GET', url: URL })).json().data.page; + expect(after.panels.map((p: { kind: string }) => p.kind)).toEqual(['mission', 'investigate', 'check', 'complete']); + expect(after.complete).toBe(true); + }); +}); + +describe('readStoryEntries', () => { + it('leaves a line that is still being written for the next read', async () => { + const full = JSON.stringify(prompt('half written')); + writeFileSync(transcript, jsonl([prompt('one')]) + full.slice(0, 20)); + const first = await readStoryEntries(transcript); + expect(first.entries.map((e) => e.k)).toEqual(['prompt']); + appendFileSync(transcript, full.slice(20) + '\n'); + const second = await readStoryEntries(transcript); + expect(second.entries.filter((e) => e.k === 'prompt')).toHaveLength(2); + }); + + it('starts over when the file was replaced by a shorter one', async () => { + writeFileSync(transcript, jsonl([prompt('one'), prompt('two'), prompt('three')])); + await readStoryEntries(transcript); + writeFileSync(transcript, jsonl([prompt('new')])); + const again = await readStoryEntries(transcript); + expect(again.entries.map((e) => (e.k === 'prompt' ? e.text : e.k))).toEqual(['new']); + }); + + it('shares one read between concurrent callers', async () => { + writeFileSync(transcript, jsonl(demoTurn())); + const [a, b] = await Promise.all([readStoryEntries(transcript), readStoryEntries(transcript)]); + expect(a.entries).toBe(b.entries); + }); +}); diff --git a/test/story-fixtures.ts b/test/story-fixtures.ts new file mode 100644 index 00000000..49f7a356 --- /dev/null +++ b/test/story-fixtures.ts @@ -0,0 +1,142 @@ +/** + * @fileoverview Builders for Story Mode test transcripts. Every line is INVENTED content shaped like a + * real Claude Code transcript entry (field names as measured on local transcripts, 2026-10-18), so no + * real prompt, path or output ever lands in the repo. + */ + +let clock = 0; +const at = () => new Date(Date.UTC(2026, 9, 18, 9, 41, 0) + (clock += 1000)).toISOString(); + +export function resetClock(): void { + clock = 0; +} + +export const prompt = (text: string, extra: Record = {}) => ({ + type: 'user', + uuid: `u-${clock + 1}`, + timestamp: at(), + isSidechain: false, + message: { role: 'user', content: text }, + turnOrigin: 'human', + promptSource: 'typed', + ...extra, +}); + +export const said = (text: string) => ({ + type: 'assistant', + timestamp: at(), + message: { role: 'assistant', content: [{ type: 'text', text }] }, +}); + +export const tool = (id: string, name: string, input: Record) => ({ + type: 'assistant', + timestamp: at(), + message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, +}); + +export const result = ( + id: string, + content: string, + opts: { isError?: boolean; toolUseResult?: Record } = {} +) => ({ + type: 'user', + timestamp: at(), + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: id, content, is_error: opts.isError ?? false }], + }, + toolUseResult: opts.toolUseResult, +}); + +export const end = (durationMs: number) => ({ + type: 'system', + subtype: 'turn_duration', + durationMs, + isMeta: true, + timestamp: at(), +}); + +/** An Edit call and its result, with a structuredPatch like Claude Code writes. */ +export function edit(id: string, file: string, lines: string[], oldStart = 10) { + const added = lines.filter((l) => l.startsWith('+')).length; + const removed = lines.filter((l) => l.startsWith('-')).length; + const context = lines.length - added - removed; + return [ + tool(id, 'Edit', { file_path: file, old_string: 'old', new_string: 'new' }), + result(id, 'The file has been updated successfully.', { + toolUseResult: { + filePath: file, + oldString: 'old', + newString: 'new', + originalFile: 'WHOLE ORIGINAL FILE THAT MUST NEVER REACH THE PAGE', + structuredPatch: [ + { oldStart, oldLines: removed + context, newStart: oldStart, newLines: added + context, lines }, + ], + userModified: false, + replaceAll: false, + }, + }), + ]; +} + +export const bash = (id: string, command: string, description: string, output: string, isError = false) => [ + tool(id, 'Bash', { command, description }), + result(id, output, { isError, toolUseResult: { stdout: output, stderr: '', interrupted: false } }), +]; + +export const read = (id: string, file: string) => [ + tool(id, 'Read', { file_path: file }), + result(id, 'FILE CONTENT THAT MUST NEVER REACH THE PAGE', { + toolUseResult: { + type: 'text', + file: { filePath: file, content: 'FILE CONTENT', numLines: 3, startLine: 1, totalLines: 3 }, + }, + }), +]; + +/** A complete demo turn: read, plan, helper, fix, failing test, fix, passing test, commit. */ +export function demoTurn(): unknown[] { + return [ + prompt('The checkout button does nothing on phones. Can you fix it?'), + said('Let me see how checkout works. I will start with the button.'), + ...read('r1', '/work/demo-shop/src/checkout/CheckoutButton.tsx'), + ...read('r2', '/work/demo-shop/src/styles/banner.css'), + ...bash( + 'b1', + 'grep -rn "onClick" src/checkout', + 'Find click handlers', + 'src/checkout/CheckoutButton.tsx:7: onClick' + ), + tool('t1', 'TodoWrite', { + todos: [ + { content: 'Fix the banner', status: 'in_progress' }, + { content: 'Test at phone size', status: 'pending' }, + ], + }), + result('t1', 'Todos have been modified successfully.'), + tool('a1', 'Agent', { + description: 'Write a phone-size test', + subagent_type: 'general-purpose', + prompt: 'Write a test.', + }), + result('a1', 'Test written.', { + toolUseResult: { + status: 'completed', + agentId: 'ag1', + content: [{ type: 'text', text: 'Test written. It fails today.' }], + totalToolUseCount: 3, + totalDurationMs: 30000, + }, + }), + ...edit('e1', '/work/demo-shop/src/styles/banner.css', [' z-index: 50;', '+ pointer-events: none;', ' }']), + ...bash('b2', 'npm test', 'Run the test suite', ' Tests 1 failed | 41 passed (42)\nExit code 1', true), + ...edit('e2', '/work/demo-shop/src/styles/banner.css', [ + '-.promo-banner__inner { pointer-events: auto; }', + '+.promo-banner__cta { pointer-events: auto; }', + ]), + ...bash('b3', 'npm test', 'Run the test suite', ' Tests 42 passed (42)'), + ...bash('b4', 'git commit -m "fix: banner"', 'Commit the fix', '[main 4be19d2] fix: banner'), + said('Fixed. The banner sat on top of the button. The same fix covers the cart page.'), + end(432_000), + ]; +} diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts new file mode 100644 index 00000000..9f98aa7c --- /dev/null +++ b/test/story-mode.test.ts @@ -0,0 +1,329 @@ +/** + * @fileoverview Story Mode core (src/story-mode.ts): transcript lines → entries → turns → comic page. + * Pure functions on invented transcripts (test/story-fixtures.ts). Port: N/A. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + STORY_LIMITS, + buildStoryPage, + buildStoryTurns, + classifyBashCommand, + createStoryReducer, + exactDuration, + firstSentence, + parseTestCounts, + roughDuration, + summarizeTurns, + type StoryEntry, +} from '../src/story-mode.js'; +import { bash, demoTurn, edit, end, prompt, read, resetClock, result, said, tool } from './story-fixtures.js'; + +function entriesOf(lines: unknown[]): StoryEntry[] { + const reducer = createStoryReducer(); + return lines.flatMap((l) => reducer.reduce(l)); +} +const pageOf = (lines: unknown[]) => { + const turns = buildStoryTurns(entriesOf(lines)); + return buildStoryPage(turns[turns.length - 1]); +}; + +beforeEach(() => resetClock()); + +describe('classifyBashCommand', () => { + it.each([ + ['npm test', 'test'], + ['npm test > out.log 2>&1', 'test'], + ['cd app && npx vitest run src/a.test.ts', 'test'], + ['python3 -m pytest -q', 'test'], + ['npm run typecheck', 'build'], + ['npx eslint src', 'build'], + ['git commit -m "fix"', 'save'], + ['git push origin HEAD', 'save'], + ['gh pr create --title x', 'save'], + ["sed -i 's/a/b/' file.ts", 'edit'], + ['echo hi > notes.txt', 'edit'], + ['cat > config.json </dev/null', 'look'], + ['curl -s localhost:3000/api/status', 'run'], + ['node scripts/build.mjs', 'run'], + ])('%s → %s', (command, intent) => { + expect(classifyBashCommand(command)).toBe(intent); + }); +}); + +describe('parseTestCounts', () => { + it.each([ + [' Tests 1 failed | 41 passed (42)', { passed: 41, failed: 1 }], + ['Tests: 2 failed, 40 passed, 42 total', { passed: 40, failed: 2 }], + ['==== 1 failed, 41 passed in 6.31s ====', { passed: 41, failed: 1 }], + [' 41 passing (2s)\n 1 failing\n', { passed: 41, failed: 1 }], + ['test result: ok. 42 passed; 0 failed; 0 ignored', { passed: 42, failed: 0 }], + ])('reads %j', (output, expected) => { + expect(parseTestCounts(output)).toEqual(expected); + }); + + it('returns null without a summary line', () => { + expect(parseTestCounts('compiled successfully')).toBeNull(); + }); +}); + +describe('text helpers', () => { + it('firstSentence strips markdown and stops at the first sentence', () => { + expect(firstSentence('**Found it.** The `banner` covers [the button](http://x).')).toBe('Found it.'); + expect(firstSentence('No full stop here')).toBe('No full stop here'); + }); + + it('formats durations for both captions', () => { + expect(roughDuration(432_000)).toBe('7 minutes'); + expect(exactDuration(432_000)).toBe('7 min 12 s'); + expect(roughDuration(4_000)).toBe('4 seconds'); + expect(roughDuration(3_900_000)).toBe('1 h 5 min'); + }); +}); + +describe('createStoryReducer', () => { + it('never keeps file contents, originals or images', () => { + const entries = entriesOf([prompt('go'), ...read('r1', '/w/a.ts'), ...edit('e1', '/w/a.ts', ['+x'])]); + const text = JSON.stringify(entries); + expect(text).not.toContain('FILE CONTENT'); + expect(text).not.toContain('WHOLE ORIGINAL FILE'); + }); + + it('keeps an edit as structuredPatch hunks with exact counts', () => { + const entries = entriesOf([prompt('go'), ...edit('e1', '/w/a.ts', [' a', '-b', '+c', '+d'], 7)]); + const r = entries.find((e) => e.k === 'result'); + expect(r).toMatchObject({ + k: 'result', + edit: { + file: '/w/a.ts', + added: 2, + removed: 1, + created: false, + hunks: [{ oldStart: 7, lines: [' a', '-b', '+c', '+d'] }], + }, + }); + }); + + it('turns a new file written with Write into added lines', () => { + const entries = entriesOf([ + prompt('go'), + tool('w1', 'Write', { file_path: '/w/new.ts', content: 'a\nb\n' }), + result('w1', 'File created', { + toolUseResult: { type: 'create', filePath: '/w/new.ts', content: 'a\nb\n', structuredPatch: [] }, + }), + ]); + expect(entries.find((e) => e.k === 'result')).toMatchObject({ + edit: { created: true, added: 2, removed: 0, hunks: [{ lines: ['+a', '+b'] }] }, + }); + }); + + it('keeps only the tail of a long output', () => { + const long = Array.from({ length: 500 }, (_, i) => `line ${i}`).join('\n'); + const entries = entriesOf([prompt('go'), ...bash('b1', 'node x.js', 'Run it', long)]); + const r = entries.find((e) => e.k === 'result') as Extract; + expect(r.outputTruncated).toBe(true); + expect(r.output?.split('\n')).toHaveLength(STORY_LIMITS.outputLines); + expect(r.output?.endsWith('line 499')).toBe(true); + }); + + it('skips meta, compact summaries, companions, sidechains and local command wrappers', () => { + const entries = entriesOf([ + prompt('meta', { isMeta: true }), + prompt('summary', { isCompactSummary: true }), + prompt('companion', { turnCompanion: true }), + prompt('side', { isSidechain: true }), + prompt('ok'), + prompt('ls'), + ]); + expect(entries).toEqual([]); + }); + + it('reads slash commands, task notifications, interrupts and strips system reminders', () => { + const entries = entriesOf([ + prompt('/review42'), + prompt('Build finished', { + turnOrigin: 'task_notification', + }), + prompt('[Request interrupted by user]'), + prompt('Fix itsecret plumbing'), + ]); + expect(entries.map((e) => (e.k === 'prompt' ? [e.k, e.text, e.origin] : [e.k]))).toEqual([ + ['prompt', '/review 42', 'human'], + ['prompt', 'Build finished', 'task'], + ['interrupt'], + ['prompt', 'Fix it', 'human'], + ]); + }); + + it('ignores lines that are not transcript objects', () => { + const reducer = createStoryReducer(); + expect(reducer.reduce(null)).toEqual([]); + expect(reducer.reduce('text')).toEqual([]); + expect(reducer.reduce({ type: 'ai-title', title: 'x' })).toEqual([]); + }); +}); + +describe('buildStoryTurns', () => { + it('splits turns at prompts and ends them at turn_duration', () => { + const turns = buildStoryTurns( + entriesOf([prompt('one'), said('ok'), end(1000), prompt('two'), ...bash('b', 'ls', 'List', 'a')]) + ); + expect(turns.map((t) => [t.prompt, t.ended])).toEqual([ + ['one', true], + ['two', false], + ]); + expect(turns[0].durationMs).toBe(1000); + }); + + it('a prompt queued while Claude works joins the running turn', () => { + const turns = buildStoryTurns( + entriesOf([prompt('one'), ...bash('b', 'ls', 'List', 'a'), prompt('also this', { promptSource: 'queued' })]) + ); + expect(turns).toHaveLength(1); + expect(turns[0].events.filter((e) => e.intent === 'prompt')).toHaveLength(2); + }); + + it('marks a turn whose prompt is outside the window as partial', () => { + const turns = buildStoryTurns(entriesOf([...bash('b', 'ls', 'List', 'a'), end(5000)])); + expect(turns[0]).toMatchObject({ partial: true, prompt: '', ended: true }); + }); + + it('flags failed calls and pending calls', () => { + const turns = buildStoryTurns( + entriesOf([ + prompt('go'), + ...bash('b1', 'npm test', 'Test', 'Exit code 1', true), + tool('b2', 'Bash', { command: 'sleep 9' }), + ]) + ); + const [failed, running] = turns[0].events.filter((e) => e.tool === 'Bash'); + expect(failed).toMatchObject({ ok: false, intent: 'test' }); + expect(running).toMatchObject({ pending: true }); + expect(running.ok).toBeUndefined(); + }); + + it('an interrupt ends the turn', () => { + const turns = buildStoryTurns( + entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')]) + ); + expect(turns[0]).toMatchObject({ ended: true, interrupted: true }); + expect(summarizeTurns(turns)[0]).toMatchObject({ complete: false, interrupted: true }); + }); +}); + +describe('buildStoryPage', () => { + it('draws the demo turn as mission, investigate, plan, backup, loop, save, complete', () => { + const page = pageOf(demoTurn()); + expect(page.panels.map((p) => p.kind)).toEqual([ + 'mission', + 'investigate', + 'plan', + 'backup', + 'loop', + 'save', + 'complete', + ]); + expect(page).toMatchObject({ complete: true, toolCalls: 10, filesChanged: 1, added: 2, removed: 1, helpers: 1 }); + }); + + it('writes plain story captions and tool-level director captions', () => { + const page = pageOf(demoTurn()); + const byKind = Object.fromEntries(page.panels.map((p) => [p.kind, p])); + expect(byKind.mission.caption.story).toBe('You give Claude a job.'); + expect(byKind.investigate.caption.story).toBe('Claude reads 2 files to find its way around.'); + expect(byKind.investigate.caption.director).toBe('Read ×2 · Bash'); + expect(byKind.plan.caption.story).toBe('Claude writes a plan: 2 steps.'); + expect(byKind.backup.caption.story).toBe('Claude calls in 1 helper.'); + expect(byKind.backup.caption.director).toContain('3 calls inside'); + expect(byKind.loop).toMatchObject({ rounds: 2, ok: true, added: 2, removed: 1 }); + expect(byKind.loop.caption.story).toBe('Fix and test, 2 rounds. It passes in the end.'); + expect(byKind.save.caption.story).toBe('Claude saves the work.'); + expect(byKind.complete.caption.story).toBe('Done in 7 minutes.'); + expect(byKind.complete.caption.director).toContain('7 min 12 s'); + }); + + it('gives balloons to the prompt and to what Claude says before a panel and at the end', () => { + const page = pageOf(demoTurn()); + expect(page.panels[0].balloon).toEqual({ + who: 'you', + text: 'The checkout button does nothing on phones. Can you fix it?', + }); + expect(page.panels[1].balloon).toEqual({ who: 'claude', text: 'Let me see how checkout works.' }); + expect(page.panels[page.panels.length - 1].balloon).toEqual({ who: 'claude', text: 'Fixed.' }); + }); + + it('lists files and keeps edits reachable from the panel', () => { + const page = pageOf(demoTurn()); + const investigate = page.panels.find((p) => p.kind === 'investigate'); + expect(investigate?.files).toEqual([ + '/work/demo-shop/src/checkout/CheckoutButton.tsx', + '/work/demo-shop/src/styles/banner.css', + ]); + const loop = page.panels.find((p) => p.kind === 'loop'); + const editEvents = loop?.events.map((i) => page.events[i]).filter((e) => e.edit); + expect(editEvents?.map((e) => e.edit?.hunks[0].lines)).toEqual([ + [' z-index: 50;', '+ pointer-events: none;', ' }'], + ['-.promo-banner__inner { pointer-events: auto; }', '+.promo-banner__cta { pointer-events: auto; }'], + ]); + }); + + it('a failing check says so and reads the test counts', () => { + const page = pageOf([ + prompt('go'), + ...bash('b1', 'npm test', 'Run tests', ' Tests 3 failed | 39 passed (42)', true), + ]); + const check = page.panels.find((p) => p.kind === 'check'); + expect(check).toMatchObject({ ok: false, tests: { passed: 39, failed: 3 }, live: true }); + expect(check?.caption.story).toBe('3 tests fail.'); + }); + + it('an unfinished turn has no complete panel and marks its newest panel live', () => { + const page = pageOf([prompt('go'), ...read('r1', '/w/a.ts'), said('Now the fix.')]); + expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'investigate']); + expect(page.panels[1]).toMatchObject({ live: true }); + expect(page.complete).toBe(false); + }); + + it('folds a long turn into montage panels so the page fits', () => { + const lines: unknown[] = [prompt('big job')]; + for (let i = 0; i < 30; i++) { + lines.push(...read(`r${i}`, `/w/f${i}.ts`)); + lines.push(...bash(`b${i}`, `node step${i}.js`, `Step ${i}`, 'ok')); + } + lines.push(end(60_000)); + const page = pageOf(lines); + expect(page.panels.length).toBeLessThanOrEqual(STORY_LIMITS.panelsPerPage); + expect(page.panels[0].kind).toBe('mission'); + expect(page.panels[page.panels.length - 1].kind).toBe('complete'); + expect(page.panels.some((p) => p.kind === 'montage')).toBe(true); + // Every tool call is still on the page, in some panel. + expect(new Set(page.panels.flatMap((p) => p.events)).size).toBe(61); + }); + + it('drops the oldest outputs first when a page is over its text budget', () => { + const big = 'x'.repeat(7000); + const lines: unknown[] = [prompt('go')]; + for (let i = 0; i < 80; i++) lines.push(...bash(`b${i}`, `node s${i}.js`, `Step ${i}`, big)); + const page = pageOf(lines); + expect(page.truncated).toBe(true); + const bashEvents = page.events.filter((e) => e.tool === 'Bash'); + expect(bashEvents[0].output).toBeUndefined(); + expect(bashEvents[0].outputTruncated).toBe(true); + expect(bashEvents[bashEvents.length - 1].output).toBe(big); + }); + + it('an origin other than you changes the mission caption', () => { + const page = pageOf([ + prompt('Build done', { + turnOrigin: 'task_notification', + }), + said('Nice.'), + end(1000), + ]); + expect(page.panels[0].caption.story).toBe('A background job reports back.'); + expect(page.origin).toBe('task'); + }); +});