From cdb7b5d55fb88ffcbc4753b71be3462d085a51d5 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 12:39:04 +0200 Subject: [PATCH] fix(story-mode): background commands, and Save image on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review round 5 (a phone with touch): - Save image on a phone exported blank art for every panel not yet scrolled into view (the frame loop draws only what is on screen). It now draws every panel completely first, and the file name carries the turn number, so saves of different turns no longer overwrite each other. - A test command sent to the background ("Command running in background with ID …") drew as "The tests pass." with POW before any test ran. It is now running: "Claude starts the tests in the background.", tagged "in the background" on the wire, and the final panel says a command is still running. Its task notification settles it from the exit code, and reading the job's output file that reports tests is a check ("The tests pass.", 7 passed) instead of "Claude looks around". Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 61 +++++++++++++++++++++++++++++------- src/types/story.ts | 2 ++ src/web/public/story-mode.js | 18 ++++++++--- test/story-mode.test.ts | 48 ++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 17 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index a8293806..ed47aab3 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -139,6 +139,8 @@ export type StoryEntry = plan?: string; /** Files Claude Code says a Bash call changed ("This command modified 1 file you've previously read: a.py"). */ modified?: string[]; + /** A Bash call sent to the background: its result only says it started. */ + background?: boolean; } | { k: 'end'; at: string; durationMs?: number } | { k: 'compact'; at: string; trigger?: string; preTokens?: number; postTokens?: number; durationMs?: number } @@ -999,7 +1001,9 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean const tail = clipTail(text, STORY_LIMITS.outputChars, STORY_LIMITS.outputLines); result.output = tail.text; if (tail.truncated) result.outputTruncated = true; - if (name === 'Bash') { + if (name === 'Bash' && /^\s*Command running in background with ID:/.test(text)) { + result.background = true; + } else if (name === 'Bash') { const tests = parseTestCounts(tail.text); if (tests) result.tests = tests; const intent = call?.input.intent; @@ -1340,6 +1344,19 @@ function applyResult(ev: StoryEvent, r: Extract): v if (todos) ev.todos = todos; } if (r.problems) ev.ok = false; + if (r.background) { + // Started, not done: its task notification says how it ended. + ev.running = true; + ev.ok = undefined; + } + // Reading a background job's output file that reports tests is a look at test results. + if ( + r.tests && + ev.tool === 'Bash' && + (ev.intent === 'look' || ev.intent === 'run') && + /\.(output|log)\b|\/tasks\//.test(ev.label) + ) + ev.intent = 'test'; if (r.modified?.length) { // Changed, but no diff to show: the file is listed, its lines unknown. const known = new Set((ev.edits ?? []).map((d) => d.file)); @@ -1414,6 +1431,14 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { if (report.result.length >= STORY_LIMITS.helperChars) prompt.outputTruncated = true; } const ev = report.toolUseId ? helpers.get(report.toolUseId) : undefined; + if (ev?.running && !ev.helper) { + // A background command: done now, and its summary carries the exit code. + ev.running = undefined; + const code = /\(exit code (\d+)\)/.exec(prompt.label)?.[1]; + ev.ok = + code !== undefined ? code === '0' : !report.status || /^(completed|done|finished|success)/i.test(report.status); + return; + } if (!ev?.helper) return; const helper: StoryHelper = { ...ev.helper, running: undefined }; if (report.result) helper.result = report.result; @@ -1512,11 +1537,14 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { const ev = toolEvent(e, 'claude'); ensure(e.at).events.push(ev); byId.set(e.id, ev); - if (ev.helper) { + if (ev.helper || ev.tool === 'Bash') { + // A helper, or a command that may turn out to run in the background: both report back later. helpers.set(e.id, ev); - if (helpers.size > 500) helpers.delete(helpers.keys().next().value as string); - helpersByName.set(ev.helper.name, ev); - if (helpersByName.size > 500) helpersByName.delete(helpersByName.keys().next().value as string); + if (helpers.size > 2000) helpers.delete(helpers.keys().next().value as string); + if (ev.helper) { + helpersByName.set(ev.helper.name, ev); + if (helpersByName.size > 500) helpersByName.delete(helpersByName.keys().next().value as string); + } } break; } @@ -2057,7 +2085,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde panel.notes = notesOf(checks); 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.'; + if (lastCheck?.running) + story = isTest ? 'Claude starts the tests in the background.' : 'Claude starts the checks in the background.'; + else if (ok === undefined) story = isTest ? 'Claude runs the tests.' : 'Claude runs the checks.'; else if (!ok && tests && !tests.passed && !tests.failed && tests.errors) story = "The tests don't even load."; else if (!ok && tests && tests.passed && !tests.failed && !tests.errors) story = 'The tests pass, but another check fails.'; @@ -2098,8 +2128,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde } case 'run': panel.notes = notesOf(evs); - story = - errors && errors === evs.length + story = evs.every((e) => e.running) + ? `Claude starts ${evs.length === 1 ? 'a command' : `${evs.length} commands`} in the background.` + : errors && errors === evs.length ? `Claude tries ${evs.length === 1 ? 'a command' : `${evs.length} commands`}, and ${ evs.some((e) => /\bblocked\b|PreToolUse|denied/i.test(e.output ?? '')) ? 'it is blocked' @@ -2186,6 +2217,7 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde function completePanel(turn: StoryTurn, page: Omit, trailingSay?: string): StoryPanel { const running = page.helpersRunning; + const commands = page.events.filter((e) => e.running && !e.helper).length; const done = turn.durationMs !== undefined ? `Done in ${roughDuration(turn.durationMs)}.` : 'Done.'; const story = turn.interrupted ? 'You stop Claude here.' @@ -2193,10 +2225,15 @@ function completePanel(turn: StoryTurn, page: Omit, trailin ? 'Nothing Claude tried worked, so the job is not done.' : turn.endedBy === 'next prompt' ? 'The trail stops here: the next prompt starts a new turn.' - : running - ? `Claude's part is done${turn.durationMs !== undefined ? ` in ${roughDuration(turn.durationMs)}` : ''}. ${ - running === 1 ? 'A helper is' : `${running} helpers are` - } still working.` + : running || commands + ? `Claude's part is done${turn.durationMs !== undefined ? ` in ${roughDuration(turn.durationMs)}` : ''}. ${[ + running ? `${running === 1 ? 'A helper is' : `${running} helpers are`} still working.` : '', + commands + ? `${commands === 1 ? 'A command is' : `${commands} commands are`} still running in the background.` + : '', + ] + .filter(Boolean) + .join(' ')}` : done; const director = [ turn.endedBy === 'next prompt' ? 'superseded by the next prompt' : (turn.endedBy ?? 'turn_duration'), diff --git a/src/types/story.ts b/src/types/story.ts index cedbd60f..fdd76514 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -124,6 +124,8 @@ export interface StoryEvent { line?: number; /** Claude's own short description of a Bash call ("Run the test suite"). */ description?: string; + /** A command sent to the background ("Command running in background") that has not reported back. */ + running?: boolean; /** A save that ships the work: `gh pr create` (`pr`) or `git push` (`push`). */ ships?: 'pr' | 'push'; /** How many commits a Bash call makes (`git commit` segments). */ diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index a2765f2d..93610c6e 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2370,7 +2370,7 @@ Object.assign(CodemanApp.prototype, { else rt.pending = true; }, - _storyDrawPanel(rt) { + _storyDrawPanel(rt, full = false) { const st = this._story; if (!rt.panel || !rt.w || !rt.h) return; const scene = StoryArt.SCENES[rt.kind]; @@ -2400,7 +2400,7 @@ Object.assign(CodemanApp.prototype, { if (job.next < job.total) { const b = job.canvas.getContext('2d'); // Without a frame loop (tests, a browser that never paints) the whole background at once. - const steps = st?.raf ? 1 : job.total - job.next; + const steps = st?.raf && !full ? 1 : job.total - job.next; for (let i = 0; i < steps && b; i++, job.next++) { const top = (StoryArt.VH / job.total) * job.next; const bottom = top + StoryArt.VH / job.total; @@ -2533,6 +2533,12 @@ Object.assign(CodemanApp.prototype, { const page = st?.data?.page; if (!page || !st.panels.length) return; await document.fonts?.ready; + // Every panel finished, on screen or not: on a phone the frame loop draws only what was scrolled to. + for (const rt of st.panels) { + if (!rt.w) this._storySizePanel(rt); + this._storyDrawPanel(rt, true); + rt.pending = false; + } const sheet = st.pageEl.getBoundingClientRect(); const head = 64; const scale = Math.max(1, Math.min(2, 12000 / (sheet.height + head))); @@ -2588,7 +2594,8 @@ Object.assign(CodemanApp.prototype, { const a = document.createElement('a'); const name = (st.sessionLabel.textContent || 'session').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40); a.href = URL.createObjectURL(blob); - a.download = `story-${name}-${(page.at || '').slice(0, 10) || 'turn'}.png`; + const turnNo = (st.data?.turns || []).findIndex((t) => t.id === page.id) + 1; + a.download = `story-${name}-${turnNo ? `turn-${turnNo}-` : ''}${(page.at || '').slice(0, 10) || 'turn'}.png`; document.body.appendChild(a); a.click(); a.remove(); @@ -2780,8 +2787,9 @@ Object.assign(CodemanApp.prototype, { label.textContent = this._storyShortPath(ev.label); if (label.textContent !== ev.label) label.title = 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; + const waiting = ev.pending || ev.running; + tag.className = `story-tag story-tag--${ev.ok === false ? 'bad' : waiting ? 'pending' : ev.intent}`; + tag.textContent = ev.ok === false ? 'error' : ev.running ? 'in the background' : waiting ? 'running' : ev.intent; body.append(who, ' ', tool, ' ', label, ' ', tag); li.append(t, body); return li; diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 7f8ba4d1..00735647 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -711,6 +711,54 @@ describe('buildStoryTurns', () => { expect(turn.events.map((e) => e.intent)).toEqual(['prompt', 'compact']); }); + it('a command sent to the background is running, not passed, until its notification; its output read later is a check', () => { + const turns = buildStoryTurns( + entriesOf([ + prompt('run the slow tests in the background'), + ...bash( + 'b1', + 'sleep 25 && pytest -q', + 'Run slow tests', + 'Command running in background with ID: bg1. Output is being written to: /tmp/x/tasks/bg1.output' + ), + said('The tests are running in the background.'), + end(1000), + prompt( + '\nbg1\nb1\ncompleted\nBackground command \"Run slow tests\" completed (exit code 0)\n', + { turnOrigin: 'task_notification' } + ), + ...bash('b2', 'cat /tmp/x/tasks/bg1.output', 'Read the test output', '7 passed in 25.1s'), + end(1000), + ]) + ); + // Before the report the first turn said it was still running; the report settled it. + const first = buildStoryPage(turns[0]); + expect(first.events[1]).toMatchObject({ intent: 'test', ok: true }); + expect(first.events[1].running).toBeUndefined(); + const second = buildStoryPage(turns[1]); + expect(second.panels.find((p) => p.kind === 'check')).toMatchObject({ + caption: { story: 'The tests pass.' }, + tests: { passed: 7, failed: 0 }, + }); + + const [alone] = buildStoryTurns( + entriesOf([ + prompt('background'), + ...bash('b1', 'sleep 25 && pytest -q', 'Run slow tests', 'Command running in background with ID: bg1.'), + end(1000), + ]) + ); + const page = buildStoryPage(alone); + expect(page.events[1]).toMatchObject({ running: true }); + expect(page.events[1].ok).toBeUndefined(); + expect(page.panels.find((p) => p.kind === 'check')!.caption.story).toBe( + 'Claude starts the tests in the background.' + ); + expect(page.panels.at(-1)!.caption.story).toBe( + "Claude's part is done in 1 second. A command is still running in the background." + ); + }); + it('an interrupt ends the turn', () => { const turns = buildStoryTurns( entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')])