diff --git a/docs/api-reference.md b/docs/api-reference.md index 289e91d7..d9080f83 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1067,7 +1067,7 @@ Responses carry a weak `ETag` with `Cache-Control: no-cache`; a request with a m 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, and `files` for the files a Bash call printed with `cat`/`head`/`tail`, as the command named them), `panels` (each `{ kind, events, caption: { story, director }, balloon?, files?, ... }`, at most 12 per page, indices into `events`), `filesChanged`, `added`, `removed`, `helpers`, `helpersRunning`, `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`. -A helper started in the background (`helper.background`) carries `helper.running: true` until its `` arrives; that notification starts a turn of its own (origin `task`), whose prompt event carries the helper's answer as `output`, and the answer, tool count and duration are joined back onto the helper in the turn that started it. Claude Code times such a turn from the prompt that started the chain; Story Mode caps a turn's `durationMs` at the turn's own span. A helper started as a teammate (`status: teammate_spawned`) is a background helper too, and its `` reports join it by name. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn, a prompt event carrying `origin` (`human`, `task`, `peer`, `system`), and a report that lands mid-turn is an event with intent `notice` rather than a new mission panel. A turn ends at `turn_duration`, or at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`, timed then by its own span. +A helper started in the background (`helper.background`) carries `helper.running: true` until its `` arrives; that notification starts a turn of its own (origin `task`), whose prompt event carries the helper's answer as `output`, and the answer, tool count and duration are joined back onto the helper in the turn that started it. Claude Code times such a turn from the prompt that started the chain; Story Mode caps a turn's `durationMs` at the turn's own span. A helper started as a teammate (`status: teammate_spawned`) is a background helper too, and its `` reports join it by name. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn, a prompt event carrying `origin` (`human`, `task`, `peer`, `system`), and a report that lands mid-turn is an event with intent `notice` rather than a new mission panel. A Bash call that writes files through here-documents (`cat > f <<'EOF'`) carries them as `edits` (whole-file edits, counted in `filesChanged`), and its label leaves the bodies out; `headline` is the part of a one-liner that decided its intent. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript. `tests` may carry `errors` (pytest's collection errors). A turn ends at `turn_duration`, or at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`, timed then by its own span. ## Git worktrees of a case diff --git a/src/story-mode.ts b/src/story-mode.ts index 5a896301..aa8ca159 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -49,7 +49,7 @@ import type { /** Size caps. A page stays small enough to fetch on every live refresh. */ export const STORY_LIMITS = Object.freeze({ - promptChars: 600, + promptChars: 4000, textChars: 600, labelChars: 400, inputChars: 2000, @@ -76,6 +76,10 @@ interface ReducedInput { reads?: string[]; /** A save that ships: `gh pr create` opens a pull request, `git push` sends the work up. */ ships?: 'pr' | 'push'; + /** The segment of a Bash one-liner that decided its intent. */ + headline?: string; + /** Files a Bash call writes through here-documents. */ + writes?: StoryEdit[]; description?: string; file?: string; offset?: number; @@ -410,7 +414,13 @@ const INTENT_RANK: StoryIntent[] = ['test', 'save', 'build', 'edit', 'run', 'loo * env assignments and wrappers like `timeout`), and the strongest intent of any part wins. */ export function classifyBashCommand(command: string): StoryIntent { + return judgeBashCommand(command).intent; +} + +/** The intent of a Bash command and the segment that decided it. */ +function judgeBashCommand(command: string): { intent: StoryIntent; segment: string } { let best = INTENT_RANK.length - 1; + let deciding = ''; for (const segment of commandSegments(withoutHeredocBodies(command.slice(0, 4000)))) { const head = segmentHead(segment); if (!head) continue; @@ -427,9 +437,69 @@ export function classifyBashCommand(command: string): StoryIntent { : LOOK_HEAD.test(head) ? 'look' : 'run'; - best = Math.min(best, INTENT_RANK.indexOf(intent)); + const rank = INTENT_RANK.indexOf(intent); + if (rank < best || !deciding) { + best = Math.min(best, rank); + deciding = segment.trim(); + } } - return INTENT_RANK[best]; + return { intent: INTENT_RANK[best], segment: deciding }; +} + +/** + * Files a command writes through here-documents: `cat > f <<'EOF'`, `cat < f`, `tee f <>` / `tee -a`, appends). Each becomes a whole-file edit of the body's lines. A relative + * target resolves against the transcript line's `cwd` unless the command changed directory first; + * scratch targets (`/tmp/x`, `"$S/x"`) are not project files. A body piped into a program + * (`python3 - <&0-9])(>>?)\s*(["']?)([^\s"'|;&<>()]+)\2/; + let target: string | undefined; + let append = false; + if (/\bcat\s*(>|$|\s)/.test(before)) { + const r = redirect.exec(before) ?? redirect.exec(after); + if (r) { + target = r[3]; + append = r[1] === '>>'; + } + } else { + const tee = /\btee\s+(-a\s+)?(["']?)([^\s"'|;&<>()]+)\2/.exec(before); + if (tee) { + target = tee[3]; + append = !!tee[1]; + } + } + if (!target || SCRATCH_TARGET.test(target)) continue; + const file = + target.startsWith('/') || !cwd || moved ? target : `${cwd.replace(/\/+$/, '')}/${target.replace(/^\.\//, '')}`; + const kept = body + .slice(0, STORY_LIMITS.hunkLines) + .map((l) => (l.length >= STORY_LIMITS.lineChars ? `+${l.slice(0, STORY_LIMITS.lineChars - 2)}…` : `+${l}`)); + out.push({ + file, + added: body.length, + removed: 0, + created: false, + hunks: [{ oldStart: append ? 1 : 0, oldLines: 0, newStart: 1, newLines: body.length, lines: kept }], + truncated: kept.length < body.length, + }); + } + return out; } /** @@ -444,8 +514,10 @@ export function parseTestCounts(output: string): StoryTests | null { const nodeFail = lines.map((l) => /^\s*[#ℹ]\s*fail\s+(\d+)\s*$/.exec(l)).find(Boolean); return { passed: Number(nodePass[1]), failed: nodeFail ? Number(nodeFail[1]) : 0 }; } - // A run with errors (pytest's collection errors) failed as surely as one with failed tests. + // Errors (pytest's collection errors) are counted apart: the run failed although no test did. const errorsOn = (line: string) => Number(/\b(\d+) errors?\b/i.exec(line)?.[1] ?? 0); + const counts = (passed: number, failed: number, errors: number): StoryTests => + errors ? { passed, failed, errors } : { passed, failed }; for (const line of lines) { const passed = /\b(\d+) (passed|passing)\b/i.exec(line); if (!passed) continue; @@ -455,14 +527,14 @@ export function parseTestCounts(output: string): StoryTests | null { 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: failed + errorsOn(line) }; + return counts(Number(passed[1]), failed, errorsOn(line)); } // Nothing passed: a summary line that only counts failures (`=== 3 failed in 0.12s ===`, // `Tests 3 failed (3)`, `1 error in 0.05s`). for (const line of lines) { if (!/^\s*(=+ |Tests?:?\s|test result:)|\bin [\d.]+m?s\b/.test(line)) continue; - const failed = Number(/\b(\d+) (failed|failing)\b/i.exec(line)?.[1] ?? 0) + errorsOn(line); - if (failed) return { passed: 0, failed }; + const failed = Number(/\b(\d+) (failed|failing)\b/i.exec(line)?.[1] ?? 0); + if (failed || errorsOn(line)) return counts(0, failed, errorsOn(line)); } return null; } @@ -562,7 +634,7 @@ function originOf(turnOrigin: unknown, kind: 'prompt' | 'task' | 'peer'): StoryT return 'human'; } -function reduceInput(name: string, input: unknown): ReducedInput { +function reduceInput(name: string, input: unknown, cwd?: string): ReducedInput { const i = isRecord(input) ? input : {}; const s = (v: unknown, max: number) => { const value = str(v); @@ -572,12 +644,17 @@ function reduceInput(name: string, input: unknown): ReducedInput { case 'Bash': { // Judged on the whole command: a commit or a test run often sits past the displayed part. const full = str(i.command) ?? ''; - const intent = classifyBashCommand(full); + const { intent, segment } = judgeBashCommand(full); const input: ReducedInput = { - command: s(i.command, STORY_LIMITS.labelChars), + // Shown without here-document bodies: the files they write are shown as diffs instead. + command: clip(withoutHeredocBodies(full), STORY_LIMITS.labelChars), description: s(i.description, 160), intent, }; + if (segment && segment !== full.trim() && (intent === 'test' || intent === 'build' || intent === 'save')) + input.headline = clip(segment, 200); + const writes = heredocWrites(full, cwd); + if (writes.length) input.writes = writes; if (intent === 'look') { const reads = printedFiles(full); if (reads.length) input.reads = reads; @@ -700,6 +777,11 @@ function editFromResult(name: string, result: Record, input: Re } /** Tools whose result text is noise on a comic page (file contents, "edited successfully"). */ +/** Server tools under the names the client tools of the same kind use. */ +const SERVER_TOOLS: Record = { advisor: 'Advisor', web_search: 'WebSearch', web_fetch: 'WebFetch' }; +/** The advisor's answer reaches the transcript encrypted; this says so instead. */ +const ADVISOR_ANSWER = 'The advice went to Claude only: the transcript keeps it encrypted.'; + const QUIET_RESULTS = new Set(['Read', 'NotebookRead', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'TodoWrite']); const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']); @@ -854,9 +936,28 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean 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); + const input = reduceInput(block.name, block.input, str(entry.cwd)); remember(block.id, block.name, input); out.push({ k: 'tool', at, id: block.id, name: block.name, input }); + } else if (block.type === 'server_tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { + // Tools the API runs itself (the advisor, web search): call and result share the message. + const name = SERVER_TOOLS[block.name] ?? block.name; + const input = reduceInput(name, block.input); + remember(block.id, name, input); + out.push({ k: 'tool', at, id: block.id, name, input }); + } else if ( + typeof block.type === 'string' && + block.type.endsWith('_tool_result') && + typeof block.tool_use_id === 'string' && + calls.has(block.tool_use_id) + ) { + const content = isRecord(block.content) ? block.content : {}; + const isError = typeof content.type === 'string' && /error/.test(content.type); + const call = calls.get(block.tool_use_id); + const result: Extract = { k: 'result', at, id: block.tool_use_id, isError }; + if (call?.name === 'Advisor' && !isError) result.helper = { result: ADVISOR_ANSWER }; + out.push(result); + calls.delete(block.tool_use_id); } } return out; @@ -926,6 +1027,7 @@ function intentOf(name: string, input: ReducedInput): StoryIntent { case 'Agent': case 'Task': case 'Workflow': + case 'Advisor': return 'helper'; case 'SendMessage': return 'message'; @@ -955,6 +1057,8 @@ function labelOf(name: string, i: ReducedInput): string { case 'Agent': case 'Task': return `${i.name || i.type || 'helper'}: ${i.description ?? ''}`.trim(); + case 'Advisor': + return 'a second opinion'; case 'TodoWrite': return plural(i.todos?.length ?? 0, 'item'); case 'AskUserQuestion': @@ -982,6 +1086,8 @@ function toolEvent(e: Extract, actor: StoryActor): St if (i.file) ev.file = i.file; if (i.reads) ev.files = i.reads; if (i.ships) ev.ships = i.ships; + if (i.headline) ev.headline = i.headline; + if (i.writes) ev.edits = i.writes; if (i.offset) ev.line = i.offset; if (e.name === 'Bash' && i.description) ev.description = i.description; if (e.name === 'Agent' || e.name === 'Task') { @@ -992,6 +1098,13 @@ function toolEvent(e: Extract, actor: StoryActor): St prompt: i.prompt || i.description || '', }; } + if (e.name === 'Advisor') + ev.helper = { + name: 'advisor', + type: 'advisor', + task: 'A second opinion', + prompt: 'Claude shows a stronger model the whole conversation so far and asks what it thinks.', + }; if (i.todos) ev.todos = i.todos; if (e.name === 'AskUserQuestion') ev.question = { text: i.question ?? '', options: i.options ?? [] }; if (e.name === 'ExitPlanMode') @@ -1011,7 +1124,7 @@ function applyResult(ev: StoryEvent, r: Extract): v if (r.tests) { ev.tests = r.tests; // A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0). - if (r.tests.failed > 0) ev.ok = false; + if (r.tests.failed > 0 || (r.tests.errors ?? 0) > 0) ev.ok = false; } if (r.helper && ev.helper) { const helper: StoryHelper = { ...ev.helper }; @@ -1058,6 +1171,13 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { ev.helper = helper; } }; + /** Ends a turn at `at` with no duration of its own: timed by its span, when the span is known. */ + const closeTurn = (t: StoryTurn, at: string) => { + t.ended = true; + t.endedAt = at; + const span = Date.parse(at) - Date.parse(t.at); + if (!t.partial && Number.isFinite(span) && span >= 0) t.durationMs ??= span; + }; const reportBack = (report: StoryTaskReport, prompt: StoryEvent) => { if (report.result) { prompt.output = report.result; @@ -1102,14 +1222,17 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { }; if (e.task) reportBack(e.task, ev); if (e.teammates) teammatesBack(e.teammates, ev); - if (cur && !cur.ended && e.queued) { + const open = cur as StoryTurn | null; + if (open && !open.ended && e.queued) { // A report that lands while Claude works is a line on the wire, not a new mission. if (e.origin === 'task') { ev.intent = 'notice'; ev.tool = 'Report'; } - cur.events.push(ev); + open.events.push(ev); } else { + // Claude runs one turn at a time: a turn still open when the next one starts has ended. + if (open && !open.ended) closeTurn(open, e.at); cur = { id: e.id, at: e.at, @@ -1187,8 +1310,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { intent: 'interrupt', }); t.interrupted = true; - t.ended = true; - t.endedAt = e.at; + closeTurn(t, e.at); break; } } @@ -1257,12 +1379,18 @@ interface DraftPanel { } /** Keeps the newest outputs, hunks and helper texts within the page budget. */ +/** Every edit an event made: an Edit/Write call's one, or the files a Bash call wrote. */ +const editsOf = (e: StoryEvent): StoryEdit[] => (e.edit ? [e.edit] : (e.edits ?? [])); + 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 hunkChars = editsOf(e).reduce( + (n, d) => n + d.hunks.reduce((m, h) => m + h.lines.reduce((k, l) => k + l.length, 0), 0), + 0 + ); const size = (e.output?.length ?? 0) + hunkChars + (e.helper?.result?.length ?? 0) + (e.helper?.prompt.length ?? 0); if (used + size <= STORY_LIMITS.pageTextBudget) { used += size; @@ -1273,6 +1401,7 @@ function applyTextBudget(events: StoryEvent[]): boolean { e.outputTruncated = true; } if (e.edit && e.edit.hunks.length) e.edit = { ...e.edit, hunks: [], truncated: true }; + if (e.edits?.some((d) => d.hunks.length)) e.edits = e.edits.map((d) => ({ ...d, hunks: [], truncated: true })); if (e.helper) e.helper = { ...e.helper, @@ -1308,7 +1437,7 @@ function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay? const reportsBack = pendingSay && last?.kind === 'check' && - !last.balloon && + (!last.balloon || !TALKS_RESULTS.test(last.balloon.text)) && TALKS_RESULTS.test(pendingSay) && !ANNOUNCES.test(pendingSay); if (kind === 'mission') panel.balloon = { who: 'you', text: clip(ev.label, 220) }; @@ -1392,8 +1521,9 @@ function countBy(events: StoryEvent[], key: (e: StoryEvent) => string): string { /** Lines of a script that say nothing about what it does: `set -e`, a bare `cd dir`, `S=/tmp/x`, comments. */ const NOISE_LINE = /^\s*(#.*|set\s+[-+][a-zA-Z]+(\s+\w+)?|cd\s+\S+|[A-Za-z_][A-Za-z0-9_]*=\S*)?\s*$/; -/** A command's first line that says what it does, for a caption. */ +/** What a command does, for a caption: the part that decided it, else its first telling line. */ const shortCommand = (e: StoryEvent) => { + if (e.headline) return clip(e.headline.split('\n')[0] ?? '', 60); const lines = e.label.split('\n'); return clip(lines.find((l) => !NOISE_LINE.test(l)) ?? lines[0] ?? '', 60); }; @@ -1415,7 +1545,7 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde 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 edits = evs.flatMap(editsOf); const checks = evs.filter((e) => e.intent === 'test' || e.intent === 'build'); const lastCheck = checks[checks.length - 1]; // A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0). @@ -1503,10 +1633,13 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde .map(shortCommand) .slice(0, 4); const behind = evs.filter((e) => e.helper?.background).length; + const advisor = evs.filter((e) => e.tool === 'Advisor').length; story = helpers.length - ? behind === helpers.length - ? `Claude sends ${plural(helpers.length, 'helper')} off to work in the background.` - : `Claude calls in ${plural(helpers.length, 'helper')}.` + ? advisor === helpers.length + ? 'Claude asks the advisor for a second opinion.' + : behind === helpers.length + ? `Claude sends ${plural(helpers.length, 'helper')} off to work in the background.` + : `Claude calls in ${plural(helpers.length, 'helper')}.` : 'Claude sends a message to another session.'; 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` : ''}`; @@ -1515,7 +1648,10 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde 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)); + const files = unique([ + ...evs.filter((e) => e.intent === 'edit' && e.file).map((e) => e.file as string), + ...edits.map((d) => d.file), + ]); panel.files = files.slice(0, 12); panel.added = edits.reduce((n, e) => n + e.added, 0); panel.removed = edits.reduce((n, e) => n + e.removed, 0); @@ -1541,7 +1677,12 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde if (ok !== undefined) panel.ok = ok; const tests = lastCheck?.tests; if (tests) panel.tests = tests; - story = `Fix and test, ${plural(panel.rounds, 'round')}.${panel.ok === true ? ' It passes in the end.' : panel.ok === false ? ' Still failing.' : ''}`; + // Every check green from the start: Claude was building piece by piece, not fixing. + const neverFailed = checks.every((e) => passed(e) !== false); + story = + neverFailed && panel.ok === true + ? `Build and test, ${plural(panel.rounds, 'round')}. Every check passes.` + : `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 ? outcome(lastCheck) : 'running' }`; @@ -1557,6 +1698,7 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde const isTest = checks.some((e) => e.intent === 'test'); const failedFirst = checks.slice(0, -1).some((e) => passed(e) === false); if (ok === undefined) story = isTest ? 'Claude runs the tests.' : 'Claude runs the checks.'; + else if (!ok && tests && !tests.passed && !tests.failed && tests.errors) story = "The tests don't even load."; else if (ok) story = failedFirst ? 'It fails at first, then passes.' : isTest ? 'The tests pass.' : 'The checks pass.'; else @@ -1569,7 +1711,11 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde .slice(-3) .map((e) => `${shortCommand(e)} → ${outcome(e)}`) .join(' · '); - if (tests) director += ` · ${tests.passed} passed${tests.failed ? `, ${tests.failed} failed` : ''}`; + if (tests) + director += ` · ${tests.passed} passed${tests.failed ? `, ${tests.failed} failed` : ''}${ + tests.errors ? `, ${plural(tests.errors, 'error')}` : '' + }`; + if (edits.length) director += ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}`; break; } case 'run': @@ -1594,7 +1740,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde : ships === 'push' ? 'Claude saves the work and sends it up.' : 'Claude saves the work.'; - director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}`; + director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${ + edits.length ? ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}` : '' + }`; break; } case 'ask': { @@ -1674,7 +1822,7 @@ export function buildStoryPage(turn: StoryTurn): StoryPage { 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 edits = events.flatMap(editsOf); const base: Omit = { ...summarizeTurn({ ...turn, events }), partial: turn.partial, @@ -1683,7 +1831,8 @@ export function buildStoryPage(turn: StoryTurn): StoryPage { 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, + // The advisor is a second opinion, not a helper that does work. + helpers: events.filter((e) => e.helper && e.tool !== 'Advisor').length, helpersRunning: events.filter((e) => e.helper?.running).length, }; if (turn.endedAt) base.endedAt = turn.endedAt; diff --git a/src/types/story.ts b/src/types/story.ts index 5b92969c..06951d81 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -94,6 +94,8 @@ export interface StoryHelper { export interface StoryTests { passed: number; failed: number; + /** Errors outside the tests (pytest's collection errors): the run failed although no test did. */ + errors?: number; } export interface StoryTodo { @@ -129,6 +131,10 @@ export interface StoryEvent { output?: string; outputTruncated?: boolean; edit?: StoryEdit; + /** Files a Bash call wrote through a here-document (`cat > f <<'EOF'`), each as a whole-file edit. */ + edits?: StoryEdit[]; + /** The part of a Bash one-liner that decided its intent (the test run, the commit), for captions. */ + headline?: string; helper?: StoryHelper; tests?: StoryTests; todos?: StoryTodo[]; diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css index 58a3b4ae..77bd629a 100644 --- a/src/web/public/story-mode.css +++ b/src/web/public/story-mode.css @@ -905,6 +905,14 @@ body.story-open { color: var(--story-go); text-decoration: line-through; } +/* The answer you picked: chosen, not crossed off. */ +.story-todos .is-chosen { + color: var(--story-go); + font-weight: 650; +} +.story-todos .is-chosen::before { + content: '✓ '; +} @media (prefers-reduced-motion: reduce) { .story-fresh *, diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index f132289d..0591bc1f 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -312,8 +312,14 @@ const StoryArt = (() => { } } /** The agent: a terminal-headed character. (x, y) is the point between its feet. */ + /** Every edit an event made: an Edit/Write call's one, or the files a Bash here-document wrote. */ + const editsOf = (e) => (e?.edit ? [e.edit] : e?.edits || []); /** Where Claude's head was last drawn (stage units): the speech balloon is placed over it. */ let speaker = null; + /** A scene with no Claude in it names the spot its balloon should point at. */ + function aim(x, top) { + speaker = { x, top }; + } function takeSpeaker() { const s = speaker; speaker = null; @@ -991,8 +997,8 @@ const StoryArt = (() => { 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 edits = p.panel.events.flatMap((i) => editsOf(p.page.events[i])); + const first = edits[0]; const lines = first?.hunks?.[0]?.lines || []; const pw = Math.min(W * 0.56, 196); const ph = 128; @@ -1037,12 +1043,13 @@ const StoryArt = (() => { }, draw(ctx, W, H, f, p, hit) { const t = p.panel.tests; - const total = t ? t.passed + t.failed : 0; + const bad = t ? t.failed + (t.errors || 0) : 0; + const total = t ? t.passed + bad : 0; const running = p.panel.ok === undefined; let b; if (total) { const dots = Math.min(42, Math.max(7, total)); - const fail = t.failed ? Math.max(1, Math.round((t.failed / total) * dots)) : 0; + const fail = bad ? Math.max(1, Math.round((bad / total) * dots)) : 0; const fill = running ? (f * 0.6) % (dots + 6) : Math.min(dots, (f - 4) * 1.6); b = board(ctx, 16, 106, `${total} checks`, dots, fail, fill); } else { @@ -1086,8 +1093,10 @@ const StoryArt = (() => { 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 edits = p.panel.events.flatMap((i) => editsOf(p.page.events[i])); + const last = edits[edits.length - 1]; + // No Claude here: the balloon points at the verdict, above the board and the rounds. + aim(W - 60, 128); const pw = Math.min(W * 0.36, 150); codePage(ctx, 12, 108, pw, 112, f, baseName(last?.file || 'file'), last?.hunks?.[0]?.lines || [], 0); hit('diff', 12, 108, pw, 112, "See Claude's changes"); @@ -1095,11 +1104,12 @@ const StoryArt = (() => { if (bx + 127 < W - 10) { // The last run's real counts, or one verdict when it reported none. const t = p.panel.tests; - const total = t ? t.passed + t.failed : 0; + const bad = t ? t.failed + (t.errors || 0) : 0; + const total = t ? t.passed + bad : 0; let b; if (total) { const dots = Math.min(28, Math.max(7, total)); - const fail = t.failed ? Math.max(1, Math.round((t.failed / total) * dots)) : 0; + const fail = bad ? Math.max(1, Math.round((bad / total) * dots)) : 0; b = board(ctx, bx, 116, `${total} checks`, dots, fail, Math.min(dots, (f - 4) * 1.6)); } else b = verdict(ctx, bx, 116, 'checks', p.panel.ok, f); hit('output', bx, 116, b.w, b.h, 'See the test output'); @@ -1375,7 +1385,7 @@ const StoryArt = (() => { }, }; - return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker }; + return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf }; })(); /** Panel kinds as the wire's group headings name them. */ @@ -1988,10 +1998,13 @@ Object.assign(CodemanApp.prototype, { const word = document.createElement('span'); word.textContent = 'Pencils'; const small = document.createElement('small'); - small.textContent = 'Claude is working…'; pencils.append(word, small); } if (working) { + // A question still open means Claude is waiting on you, not working. + const waiting = page.events.some((e) => e.question && e.pending); + pencils.querySelector('small').textContent = waiting ? 'Waiting for your answer…' : 'Claude is working…'; + pencils.classList.toggle('is-waiting', waiting); pencils.style.setProperty('--story-span6', String(spans6[want.length])); pencils.style.setProperty('--story-span2', String(spans2[want.length])); st.pageEl.append(pencils); @@ -2211,7 +2224,8 @@ Object.assign(CodemanApp.prototype, { const hx = placed ? (head.x + ox) * s : 0; const cap = rt.capStory.parentElement; const capW = cap?.offsetWidth || 0; - const key = `${rt.w}x${rt.h}|${placed ? Math.round(hx / 6) : '-'}|${capW}|${b.className}|${b.textContent}`; + const live = !rt.liveTag.hidden; + const key = `${rt.w}x${rt.h}|${placed ? Math.round(hx / 6) : '-'}|${capW}|${live}|${b.className}|${b.textContent}`; if (key === rt.balloonKey) return; rt.balloonKey = key; if (placed) { @@ -2223,6 +2237,9 @@ Object.assign(CodemanApp.prototype, { const besideCap = rt.w - right - capRight - 10; if (besideCap >= 150) maxW = Math.min(maxW, besideCap); else top = capBottom + 8; + // The LIVE tag sits in the top-right corner: start below it when the balloon reaches that far. + if (live && right < rt.w - rt.liveTag.offsetLeft) + top = Math.max(top, rt.liveTag.offsetTop + rt.liveTag.offsetHeight + 6); b.style.left = 'auto'; b.style.right = `${Math.round(right)}px`; b.style.top = `${Math.round(top)}px`; @@ -2526,13 +2543,16 @@ Object.assign(CodemanApp.prototype, { _storyShortPath(text) { const dir = this.sessions?.get(this._story?.sessionId)?.workingDir; if (!dir || dir === '/' || !text) return text; - const prefix = dir.endsWith('/') ? dir : `${dir}/`; - return text.split(prefix).join(''); + const base = dir.replace(/\/+$/, ''); + return text + .split(`${base}/`) + .join('') + .replace(new RegExp(`${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s'"\`:;,)])`, 'g'), '.'); }, /** 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.edit || ev.edits?.length) 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}`; @@ -2662,7 +2682,9 @@ Object.assign(CodemanApp.prototype, { // Changes made from the terminal (`sed -i`, a redirect) have no diff to show: show what ran. const view = - source.view === 'diff' && items.length && !items.some((x) => x.ev.edit || x.ev.pending) ? 'output' : source.view; + source.view === 'diff' && items.length && !items.some((x) => StoryArt.editsOf(x.ev).length || x.ev.pending) + ? 'output' + : source.view; if (view === 'prompt') { const prompts = items.filter((x) => x.ev.intent === 'prompt'); const from = prompts[0]?.ev.origin ?? 'human'; @@ -2682,20 +2704,22 @@ Object.assign(CodemanApp.prototype, { if (ev.outputTruncated) out.push(el('p', 'story-empty', 'The report is cut here.')); } } - if (prompts.some((x) => x.ev.label.length >= 599)) + if (prompts.some((x) => x.ev.label.length >= 3999)) out.push(el('p', 'story-empty', 'A long prompt is cut here; the terminal has all of it.')); } else if (view === 'diff') { - const edits = items.filter((x) => x.ev.edit); + const edits = items.flatMap((x) => + StoryArt.editsOf(x.ev).map((d, k) => ({ ev: x.ev, edit: d, key: `${x.ev.id}#${k}` })) + ); 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`; + const added = edits.reduce((n, x) => n + x.edit.added, 0); + const removed = edits.reduce((n, x) => n + x.edit.removed, 0); + const files = new Set(edits.map((x) => x.edit.file)).size; + title = files === 1 ? this._storyShortPath(edits[0].edit.file) : `${files} files`; const counts = [added ? `+${added}` : '', removed ? `−${removed}` : ''].filter(Boolean).join(' ') || '±0'; sub = `${edits.length} edit${edits.length === 1 ? '' : 's'} · ${counts}${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); + for (const { ev, edit } of edits) { + const section = this._storyDiffSection(edit, el); if (isNew(ev.id)) { section.classList.add('story-arrive'); firstNew ??= section; @@ -2775,7 +2799,7 @@ Object.assign(CodemanApp.prototype, { 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)); + for (const o of ev.question.options) ul.append(el('li', o === ev.question.answer ? 'is-chosen' : '', o)); out.push(ul); } out.push( @@ -2873,11 +2897,11 @@ Object.assign(CodemanApp.prototype, { box.replaceChildren(el('p', 'story-out-desc', summary), list); }, - _storyDiffSection(ev, el) { - const edit = ev.edit; + _storyDiffSection(edit, el) { const section = el('section', 'story-df'); const head = el('div', 'story-df-head'); - const name = el('span', 'story-df-path', edit.file); + const name = el('span', 'story-df-path', this._storyShortPath(edit.file)); + if (name.textContent !== edit.file) name.title = edit.file; const cnt = el('span', 'story-cnt'); cnt.append( el('span', 'story-cnt-a', edit.added ? `+${edit.added}` : ''), @@ -2921,7 +2945,12 @@ Object.assign(CodemanApp.prototype, { _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 cmd = el( + 'span', + 'story-out-cmd', + `${ev.tool === 'Bash' ? '$ ' : `${ev.tool} `}${this._storyShortPath(ev.label)}` + ); + if (ev.label !== this._storyShortPath(ev.label)) cmd.title = ev.label; const status = el( 'span', `story-tag story-tag--${ev.ok === false ? 'bad' : ev.pending ? 'pending' : 'ok'}`, @@ -2937,7 +2966,11 @@ Object.assign(CodemanApp.prototype, { } 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'); + const b = el( + 'button', + 'story-btn-s', + ev.edit || ev.edits?.length ? 'See the change' : ev.file ? 'Open file' : 'Open' + ); b.type = 'button'; b.dataset.storyOpen = target; section.append(b); @@ -2974,8 +3007,18 @@ Object.assign(CodemanApp.prototype, { b.className = 'story-link'; b.textContent = this._storyShortPath(link.path); if (b.textContent !== link.path) b.title = link.path; - const resolved = - typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(link.path, workingDir) : link.path; + // A bare name listed under an `ls` header (`some/dir:`) lives in that directory. + let target = link.path; + if (!target.includes('/')) { + const block = + value + .slice(0, link.index) + .split(/\n\s*\n/) + .pop() || ''; + const header = /(?:^|\n)([^\s:][^\n:]*):[ \t]*\n(?![\s\S]*\n[^\s:][^\n:]*:[ \t]*\n)/.exec(block); + if (header) target = `${header[1].replace(/\/+$/, '')}/${target}`; + } + const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(target, workingDir) : target; b.addEventListener('click', () => this.openFilePreview?.(resolved, this._story.sessionId)); pre.append(b); last = link.index + link.path.length; diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index fcc24fda..a1a6cdd4 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -515,6 +515,70 @@ describe('story-mode.js overlay', () => { expect($('.story-panel[data-kind="investigate"] .story-hit')!.title).toBe('Open rename_by_date.sh'); }); + it('files written through a here-document open as a diff from the wire, with short paths', async () => { + const lines = [ + prompt('write'), + ...bash('b1', "cat > app.js <<'EOF'\nconst a = 1;\nEOF\nnode --test", 'Write and test', '# pass 1\n# fail 0'), + end(1000), + ]; + (lines[1] as { cwd?: string }).cwd = '/work/demo-shop'; + boot(storyOf(lines)); + app.openStoryMode(SID); + await settle(); + app._storySetDirector(true); + const link = $$('.story-wire .story-link').find((l) => l.dataset.storyOpen?.startsWith('diff:'))!; + link.click(); + expect($('.story-drawer-kind')!.textContent).toBe('Diff'); + expect($('.story-drawer-title')!.textContent).toBe('app.js'); + expect($('.story-df-path')!.textContent).toBe('app.js'); + expect($('.story-df-path')!.title).toBe('/work/demo-shop/app.js'); + expect($$('.story-ln--add').map((l) => l.textContent)).toEqual(['1+const a = 1;']); + }); + + it('while a question waits, the pencils say so; the chosen answer is marked, not struck', async () => { + const ask = (answered: boolean) => + storyOf([ + prompt('pick'), + tool('q1', 'AskUserQuestion', { + questions: [{ question: 'Which name?', options: [{ label: 'slugify' }, { label: 'kebab' }] }], + }), + ...(answered ? [result('q1', 'kebab', { toolUseResult: { answers: { 'Which name?': 'kebab' } } })] : []), + ]); + boot(ask(false)); + app.openStoryMode(SID); + await settle(); + expect($('.story-pencils small')!.textContent).toBe('Waiting for your answer…'); + responses = [{ status: 200, body: { success: true, data: ask(true) } }]; + await app._storyRefresh(); + await settle(); + sizeAndDraw(); + $('.story-panel[data-kind="ask"] .story-hit')!.click(); + const chosen = $('.story-drawer-body .is-chosen')!; + expect(chosen.textContent).toBe('kebab'); + expect($('.story-drawer-body .is-done')).toBeNull(); + expect($('.story-pencils small')!.textContent).toBe('Claude is working…'); + }); + + it('a bare name under an ls header links into that folder; the session folder reads as "."', async () => { + boot( + storyOf([ + prompt('look'), + ...bash('b1', 'ls /work/demo-shop && ls -R /work/demo-shop/clone', 'List the clone', 'clone/src:\nkebab.py\n\nnotes.md'), + end(1000), + ]) + ); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + $('.story-panel[data-kind="investigate"] .story-hit')!.click(); + expect($('.story-out-cmd')!.textContent).toBe('$ ls . && ls -R clone'); + const links = $$('.story-drawer-body .story-link'); + links.find((l) => l.textContent === 'kebab.py')!.click(); + expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/clone/src/kebab.py', SID); + links.find((l) => l.textContent === 'notes.md')!.click(); + expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/notes.md', SID); + }); + it('turning the setting off hides the button and closes an open story', async () => { boot(storyOf(demoTurn())); app.openStoryMode(SID); diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 03117f3b..9c607650 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -11,6 +11,7 @@ import { createStoryReducer, exactDuration, firstSentence, + heredocWrites, parseTestCounts, planFromText, printedFiles, @@ -110,8 +111,8 @@ describe('parseTestCounts beyond "N passed"', () => { ['# tests 7\n# suites 0\n# pass 7\n# fail 0', { passed: 7, failed: 0 }], ['ℹ tests 3\nℹ pass 2\nℹ fail 1', { passed: 2, failed: 1 }], ['FAILED test_a.py::test_x\n===== 3 failed in 0.12s =====', { passed: 0, failed: 3 }], - ['==== 1 error in 0.05s ====', { passed: 0, failed: 1 }], - ['=== 1 failed, 18 passed, 2 errors in 0.40s ===', { passed: 18, failed: 3 }], + ['==== 1 error in 0.05s ====', { passed: 0, failed: 0, errors: 1 }], + ['=== 1 failed, 18 passed, 2 errors in 0.40s ===', { passed: 18, failed: 1, errors: 2 }], [' Tests 3 failed (3)', { passed: 0, failed: 3 }], ])('%j', (output, counts) => { expect(parseTestCounts(output)).toEqual(counts); @@ -614,13 +615,117 @@ describe('buildStoryPage', () => { ...bash('b2', 'git push -u origin main && git -C remote.git log', 'Push', 'fatal: bad object', true), end(1000), ]); - expect(page.events[1]).toMatchObject({ ok: false, tests: { passed: 0, failed: 1 } }); + expect(page.events[1]).toMatchObject({ ok: false, tests: { passed: 0, failed: 0, errors: 1 } }); + const check = page.panels.find((p) => p.kind === 'check')!; + expect(check.caption.story).toBe("The tests don't even load."); + expect(check.caption.director).toContain('0 passed, 1 error'); const save = page.panels.find((p) => p.kind === 'save')!; expect(save.ok).toBe(false); expect(save.caption.story).toBe('Claude tries to send the work up, and the command fails.'); expect(save.caption.director).toContain('1 error'); }); + it('files written through a here-document are edits: on the event, in the counts, out of the label', () => { + const command = [ + "cat > index.js <<'EOF'", + 'export const add = (a, b) => a + b;', + 'test(1 > 2)', + 'EOF', + 'cat < /tmp/scratch.txt', + 'scratch', + 'EOF', + 'node --test && git add -A && git commit -qm "feat: add"', + ].join('\n'); + const lines = [prompt('write it'), ...bash('b1', command, 'Write, test, commit', '# pass 3\n# fail 0'), end(1000)]; + (lines[1] as { cwd?: string }).cwd = '/work/app'; + const page = pageOf(lines); + const ev = page.events[1]; + expect(ev.intent).toBe('test'); + expect(ev.edits?.map((d) => [d.file, d.added])).toEqual([['/work/app/index.js', 2]]); + expect(ev.edits?.[0].hunks[0].lines).toEqual(['+export const add = (a, b) => a + b;', '+test(1 > 2)']); + expect(ev.label).not.toContain('export const add'); + expect(ev.headline).toBe('node --test'); + expect(page.filesChanged).toBe(1); + const check = page.panels.find((p) => p.kind === 'check')!; + expect(check.caption.director).toMatch(/^node --test → ok · 3 passed · writes 1 file$/); + }); + + it('heredocWrites skips scratch, stdin bodies and relative paths after a cd', () => { + expect(heredocWrites("python3 - <<'EOF'\nprint(1)\nEOF", '/w')).toEqual([]); + expect(heredocWrites('cat > "$S/x.txt" < a.txt < d.file)).toEqual(['a.txt']); + expect(heredocWrites('tee -a notes.md < d.file)).toEqual(['/w/notes.md']); + }); + + it('checks that all pass from the start read as building, not fixing', () => { + const page = pageOf([ + prompt('build it'), + ...edit('e1', '/w/a.py', ['+a = 1']), + ...bash('b1', 'pytest -q', 'Run tests', '3 passed in 0.1s'), + ...edit('e2', '/w/b.py', ['+b = 1']), + ...bash('b2', 'pytest -q', 'Run tests', '6 passed in 0.1s'), + end(1000), + ]); + expect(page.panels.find((p) => p.kind === 'loop')!.caption.story).toBe( + 'Build and test, 2 rounds. Every check passes.' + ); + }); + + it('the advisor (a server tool) is a helper giving a second opinion', () => { + const page = pageOf([ + prompt('think hard'), + { + type: 'assistant', + timestamp: '2026-10-18T09:41:20.000Z', + message: { + content: [ + { type: 'server_tool_use', id: 'srv1', name: 'advisor', input: {} }, + { + type: 'advisor_tool_result', + tool_use_id: 'srv1', + content: { type: 'advisor_redacted_result', encrypted_content: 'xx' }, + }, + ], + }, + }, + end(1000), + ]); + const backup = page.panels.find((p) => p.kind === 'backup')!; + expect(backup.caption.story).toBe('Claude asks the advisor for a second opinion.'); + expect(page.events[1]).toMatchObject({ tool: 'Advisor', ok: true, helper: { name: 'advisor' } }); + expect(page.events[1].helper!.result).toMatch(/encrypted/); + expect(page.helpers).toBe(0); + }); + + it('a turn left open when the next prompt starts has ended; an interrupted turn is timed', () => { + const turns = buildStoryTurns( + entriesOf([ + prompt('one'), + ...bash('b', 'ls', 'List', 'a'), + prompt('two'), + ...bash('c', 'ls', 'List', 'a'), + { type: 'user', timestamp: '2026-10-18T09:50:00.000Z', message: { content: '[Request interrupted by user]' } }, + ]) + ); + expect(turns[0]).toMatchObject({ ended: true, interrupted: false }); + expect(turns[0].durationMs).toBeGreaterThan(0); + expect(turns[1]).toMatchObject({ ended: true, interrupted: true }); + expect(turns[1].durationMs).toBeGreaterThan(0); + }); + + it('a result remark replaces a step announcement on its check', () => { + const page = pageOf([ + prompt('steps'), + said('Step 1: split the module.'), + ...bash('b1', 'node --test', 'Run tests', '# pass 8\n# fail 0'), + said('All 8 tests pass.'), + ...bash('b2', 'git commit -qm x', 'Commit', 'ok'), + end(1000), + ]); + expect(page.panels.find((p) => p.kind === 'check')!.balloon?.text).toBe('All 8 tests pass.'); + expect(page.panels.find((p) => p.kind === 'save')!.balloon).toBeUndefined(); + }); + it('a loop ends at its last check; writes after it are a panel of their own', () => { const page = pageOf([ prompt('tdd'),