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]')])