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 <<EOF` or `echo x >> 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) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 12:55:43 +02:00
1 parent 76d5c61eda
commit 6a1bac6ebe
7 files changed
+119 -15

No files matched your search

+17 -8
View File
@@ -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<StoryPanelKind>(['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}`}`;
+4 -1
View File
@@ -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 }>;
+1 -1
View File
@@ -2068,7 +2068,7 @@
<div class="set-group">
<div class="set-group-head"><h4>Header buttons</h4><span class="set-scope">device</span></div>
<p class="set-group-hint">Tap to show a control in the header. Multi-monitor is the one entry here that syncs across devices.</p>
<p class="set-group-hint">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.</p>
<div class="set-group-body">
<div class="set-chips" data-search="header buttons plan usage font stats lifecycle response story file viewer attachments monitor session away cron redraw split tiles">
<label class="set-chip" data-preview="header" data-preview-order="1" data-preview-text="A+"><input type="checkbox" id="appSettingsShowFontControls"><svg class="set-chip-ico" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20 10 5l6 15"/><path d="M6.5 15h7"/><path d="M18 12h4M20 10v4"/></svg><span>Font Size</span></label>
+5
View File
@@ -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;
+24 -5
View File
@@ -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:')) {