From 6a1bac6ebe4af64656f764283babccf04bce0931 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 12:55:43 +0200 Subject: [PATCH] fix(story-mode): appends without invented line numbers, printed files you can open, whose words start a turn The rest of review round 5 (a phone with touch): - Text appended with `cat >> f <> f` was drawn as lines 1 to 8 of the file; where an append lands is not in the command, so the diff now says "Added at the end of the file (8 lines)" with no numbers. - A command that prints a file and does something else (`cat textstats.py && pytest`) kept no record of the file; it is now on the event, resolved against the command's own folder, and the output drawer offers "Open textstats.py". (A file rewritten by a python here-document still cannot be diffed from the command alone; Claude Code's own "modified" note, when it comes, is used.) - A background job's report that starts a turn was a balloon credited to "you" in the API (the page already drew it as a note); its speaker is now "note". - The output drawer tagged a command still running in the background "ok"; it says "in the background". - The Header buttons hint says that on a phone, Story Mode opens from Session Options. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 25 +++++++++++++++------- src/types/story.ts | 5 ++++- src/web/public/index.html | 2 +- src/web/public/story-mode.css | 5 +++++ src/web/public/story-mode.js | 29 ++++++++++++++++++++----- test/story-mode-ui.test.ts | 28 ++++++++++++++++++++++++ test/story-mode.test.ts | 40 +++++++++++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 15 deletions(-) diff --git a/src/story-mode.ts b/src/story-mode.ts index 2f9d4c1b..7b6c8a63 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -446,11 +446,12 @@ function echoWrites(command: string, cwd?: string): StoryEdit[] { created: false, hunks: [ { - oldStart: m[5] === '>>' ? 1 : 0, + oldStart: 0, oldLines: 0, - newStart: 1, + newStart: m[5] === '>>' ? 0 : 1, newLines: lines.length, lines: lines.slice(0, 40).map((l) => `+${clip(l, STORY_LIMITS.lineChars)}`), + ...(m[5] === '>>' ? { atEnd: true } : {}), }, ], truncated: lines.length > 40, @@ -610,7 +611,11 @@ export function heredocWrites(command: string, cwd?: string): StoryEdit[] { added: body.length, removed: 0, created: false, - hunks: [{ oldStart: append ? 1 : 0, oldLines: 0, newStart: 1, newLines: body.length, lines: kept }], + hunks: [ + append + ? { oldStart: 0, oldLines: 0, newStart: 0, newLines: body.length, lines: kept, atEnd: true } + : { oldStart: 0, oldLines: 0, newStart: 1, newLines: body.length, lines: kept }, + ], truncated: kept.length < body.length, }); } @@ -814,10 +819,12 @@ function reduceInput(name: string, input: unknown, cwd?: string): ReducedInput { 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; - } + // Files it prints, whatever else it does (`cat a.py && pytest`): the drawer offers them. A + // relative name resolves against the line's cwd (printedFiles gives none after a `cd`). + const reads = printedFiles(full).map((f) => + f.startsWith('/') || f.startsWith('~') || !cwd ? f : `${cwd.replace(/\/+$/, '')}/${f.replace(/^\.\//, '')}` + ); + if (reads.length) input.reads = reads; { // Counted whatever wins: a test run that ends in a commit still saves the work. const heads = commandSegments(withoutHeredocBodies(full.slice(0, 4000))).map(segmentHead); @@ -1705,7 +1712,7 @@ const MINOR = new Set(['investigate', 'research', 'plan', 'run', interface DraftPanel { kind: StoryPanelKind; events: number[]; - balloon?: { who: 'you' | 'claude'; text: string }; + balloon?: { who: 'you' | 'claude' | 'note'; text: string }; rounds?: number; } @@ -1959,6 +1966,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde : origin === 'peer' ? 'Another session chimes in while Claude works.' : lines[origin]; + // Words that started the turn but are not yours are a note, not your balloon. + if (panel.balloon && !command && origin !== 'human') panel.balloon = { ...panel.balloon, who: 'note' }; director = command ? `Command · ${clip(evs[0].label, 60)}` : `Prompt · ${plural(words, 'word')}${origin === 'human' ? '' : ` · ${origin}`}`; diff --git a/src/types/story.ts b/src/types/story.ts index 6f0093b1..c4bb5edc 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -61,6 +61,8 @@ export interface StoryHunk { newStart: number; newLines: number; lines: string[]; + /** Lines appended at the end of the file (`>>`): where they start is not known, so no line numbers. */ + atEnd?: boolean; } /** What one Edit/Write call changed. Counts are exact even when `hunks` was cut to size. */ @@ -152,7 +154,8 @@ export interface StoryPanel { /** Indices into the page's `events`. */ events: number[]; caption: { story: string; director: string }; - balloon?: { who: 'you' | 'claude'; text: string }; + /** `note`: words that start a turn but are not yours (a background job's report, another session). */ + balloon?: { who: 'you' | 'claude' | 'note'; text: string }; files?: string[]; notes?: string[]; helpers?: Array<{ name: string; type: string; task?: string; event: number }>; diff --git a/src/web/public/index.html b/src/web/public/index.html index ca51edd6..eff0f3d9 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2068,7 +2068,7 @@

Header buttons

device
-

Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.

+

Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices. On a phone, Story Mode opens from Session Options instead.

diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css index 666e58fa..6b927b7f 100644 --- a/src/web/public/story-mode.css +++ b/src/web/public/story-mode.css @@ -820,6 +820,11 @@ body.story-open .toast-container { background: var(--story-panel); border: 2px solid var(--story-ink); } +.story-out-files { + display: flex; + flex-wrap: wrap; + gap: 6px; +} .story-df-head, .story-out-head { position: sticky; diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index dcb6c226..077ba7da 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -3243,7 +3243,13 @@ Object.assign(CodemanApp.prototype, { 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} @@`) + el( + 'div', + 'story-ln story-ln--hunk', + h.atEnd + ? `Added at the end of the file (${h.newLines} ${h.newLines === 1 ? 'line' : 'lines'})` + : `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@` + ) ); let o = h.oldStart; let n = h.newStart; @@ -3251,8 +3257,8 @@ Object.assign(CodemanApp.prototype, { 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-no', mark === '+' || h.atEnd ? '' : String(o)), + el('span', 'story-ln-no', mark === '-' || h.atEnd ? '' : String(n)), el('span', 'story-ln-mk', mark === '+' ? '+' : mark === '-' ? '−' : ''), el('span', 'story-ln-tx', line.slice(1) || ' ') ); @@ -3273,10 +3279,11 @@ Object.assign(CodemanApp.prototype, { const shown = this._storyShortPath(ev.label, { dot: ev.tool === 'Bash' }); const cmd = el('span', 'story-out-cmd', `${ev.tool === 'Bash' ? '$ ' : `${ev.tool} `}${shown}`); if (ev.label !== shown) cmd.title = ev.label; + const waiting = ev.pending || ev.running; const status = el( 'span', - `story-tag story-tag--${ev.ok === false ? 'bad' : ev.pending ? 'pending' : 'ok'}`, - ev.ok === false ? 'error' : ev.pending ? 'running' : 'ok' + `story-tag story-tag--${ev.ok === false ? 'bad' : waiting ? 'pending' : 'ok'}`, + ev.ok === false ? 'error' : ev.running ? 'in the background' : waiting ? 'running' : 'ok' ); head.append(cmd, status); section.append(head); @@ -3296,6 +3303,18 @@ Object.assign(CodemanApp.prototype, { b.dataset.storyOpen = `diff:event:${index}`; section.append(b); } + // Files it printed (`cat a.py`): open them as they are now. + if (ev.files?.length && ev.intent !== 'look') { + const row = el('div', 'story-out-files'); + for (const f of ev.files.slice(0, 4)) { + const b = el('button', 'story-btn-s', `Open ${f.split('/').pop()}`); + b.type = 'button'; + b.title = f; + b.addEventListener('click', (e) => this._storyOpenFile(this._storyResolvePath(f), e.currentTarget)); + row.append(b); + } + section.append(row); + } } 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:')) { diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index c1a2a7b9..71a5933e 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -535,6 +535,34 @@ describe('story-mode.js overlay', () => { expect($$('.story-ln--add').map((l) => l.textContent)).toEqual(['1+const a = 1;']); }); + it('an append draws without line numbers, and a check offers the file it printed', async () => { + const lines = [ + prompt('add and test'), + ...bash( + 'b1', + "cat >> notes.md <<'EOF'\nmore\nEOF\ncat stats.py && pytest -q", + 'Append, show and test', + 'def f(): pass\n5 passed in 0.1s' + ), + end(1000), + ]; + (lines[1] as { cwd?: string }).cwd = '/work/demo-shop'; + boot(storyOf(lines)); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + $('.story-panel[data-kind="check"] .story-hit')!.click(); + const open = $$('.story-drawer-body .story-out-files button'); + expect(open.map((b) => b.textContent)).toEqual(['Open stats.py']); + open[0].click(); + expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/stats.py', SID); + $$('.story-drawer-body button') + .find((b) => b.textContent === 'See the file it wrote')! + .click(); + expect($('.story-ln--hunk')!.textContent).toBe('Added at the end of the file (1 line)'); + expect($$('.story-ln--add').map((l) => l.textContent)).toEqual(['+more']); + }); + it('while a question waits, the pencils say so; the chosen answer is marked, not struck', async () => { const ask = (answered: boolean) => storyOf([ diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 6d83ac2e..e7457e90 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -975,6 +975,46 @@ describe('buildStoryPage', () => { expect(heredocWrites('tee -a notes.md < d.file)).toEqual(['/w/notes.md']); }); + it('an append has no line numbers it cannot know; a whole-file write starts at line 1', () => { + const [append] = heredocWrites("cat >> notes.md <<'EOF'\none\ntwo\nEOF", '/w'); + expect(append.hunks[0]).toMatchObject({ atEnd: true, newStart: 0, newLines: 2 }); + const [echoed] = heredocWrites('echo "more" >> notes.md', '/w'); + expect(echoed.hunks[0]).toMatchObject({ atEnd: true, newStart: 0 }); + const [whole] = heredocWrites("cat > notes.md <<'EOF'\none\nEOF", '/w'); + expect(whole.hunks[0]).toMatchObject({ newStart: 1, newLines: 1 }); + expect(whole.hunks[0].atEnd).toBeUndefined(); + }); + + it('files a command prints are on its event, resolved against its cwd, whatever it does', () => { + const lines = [ + prompt('rewrite and test'), + ...bash('b1', 'cat textstats.py && python3 -m pytest -q', 'Show and test', 'def f(): pass\n5 passed in 0.1s'), + end(1000), + ]; + (lines[1] as { cwd?: string }).cwd = '/w'; + const page = pageOf(lines); + expect(page.events[1]).toMatchObject({ intent: 'test', files: ['/w/textstats.py'] }); + }); + + it('words that start a turn but are not yours are a note, not your balloon', () => { + const turns = buildStoryTurns( + entriesOf([ + prompt('go'), + end(1000), + prompt( + '\nbg1\ncompleted\nBackground command \"Run tests\" completed (exit code 0)\n', + { turnOrigin: 'task_notification' } + ), + said('All good.'), + end(1000), + ]) + ); + expect(buildStoryPage(turns[0]).panels[0].balloon?.who).toBe('you'); + const report = buildStoryPage(turns[1]).panels[0]; + expect(report.caption.story).toBe('A background job reports back.'); + expect(report.balloon?.who).toBe('note'); + }); + it('a loop keeps every run: a lint failure inside a round that passed in the end still shows', () => { const page = pageOf([ prompt('build'),