feat(story-mode): a run panel shows what its command printed

From review round 8: you asked to see a dry run, Claude's balloon said
"Here's the dry run you asked for", and the page showed nothing of it.
A run panel's terminal card now prints, dimmer under the commands, the
first lines its last command printed (the last ones when it failed:
that is where an error is).

The card is drawn on the canvas, so it goes into a saved image someone
may share: a line that looks like a secret (token, password, api key,
credential, inside identifiers too) is left out, addresses and long
token-like strings are masked, and Claude Code's own markers ("(Bash
completed with no output)", tool-error tags) are dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 15:32:36 +02:00
1 parent b19317175e
commit e892278abc
17 files changed
+296 -7

No files matched your search

+25 -1
View File
@@ -2164,6 +2164,19 @@ function montageSummary(evs: StoryEvent[]): string {
return parts.length ? parts.join(', ') : 'a few more steps';
}
/**
* One output line as the terminal card may show it. The card is drawn on the canvas, so it goes into a
* saved image someone may share: a line that looks like a secret is left out, addresses and long
* token-like strings are masked, and Claude Code's own markers go.
*/
function printableLine(line: string): string | null {
const text = line.replace(/<\/?[a-z_]+>/g, '').trim();
if (!text || /^\(Bash completed with no output\)$/.test(text)) return null;
// Anywhere in the line, inside identifiers too (`API_TOKEN=`, `db_password:`): losing a harmless line is cheap.
if (/token|secret|passw(or)?d|api[_-]?key|authorization|bearer|private[_ -]?key|credential/i.test(text)) return null;
return clip(text.replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '…@…').replace(/[A-Za-z0-9+/=_-]{32,}/g, '…'), 90);
}
/** You asked for a failing run first (test first), or Claude said the failure was expected. */
const PLANNED_IN_PROMPT =
/\b(test[- ]first|tdd|fail(s|ing)? (first|on purpose)|failing tests? first|on purpose|red first|before (the )?(code|module|function|implementation) exists)\b/i;
@@ -2439,8 +2452,18 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
}
break;
}
case 'run':
case 'run': {
panel.notes = notesOf(evs);
// What it printed, so a dry run or a listing you asked to see is on the page, not only in the drawer.
const lastOut = [...evs].reverse().find((e) => e.output?.trim() && !e.running);
if (lastOut?.output) {
const lines = lastOut.output
.split('\n')
.map(printableLine)
.filter((l): l is string => !!l);
const picked = lastOut.ok === false ? lines.slice(-2) : lines.slice(0, 3);
if (picked.length) panel.printed = picked;
}
story = evs.every((e) => e.running)
? `Claude starts ${evs.length === 1 ? 'a command' : `${evs.length} commands`} in the background.`
: errors && errors === evs.length
@@ -2456,6 +2479,7 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
: 'Claude works in the terminal.';
director = `${tools} · ${shortCommand(evs[0])}${errors ? ` · ${plural(errors, 'error')}` : ''}`;
break;
}
case 'save': {
panel.notes = notesOf(evs);
const last = evs[evs.length - 1];
+2
View File
@@ -167,6 +167,8 @@ export interface StoryPanel {
balloon?: { who: 'you' | 'claude' | 'note'; text: string };
files?: string[];
notes?: string[];
/** What a run panel's last command printed, for its terminal card: the first lines, the last ones when it failed. */
printed?: string[];
helpers?: Array<{ name: string; type: string; task?: string; event: number }>;
added?: number;
removed?: number;
+17 -5
View File
@@ -688,7 +688,8 @@ const StoryArt = (() => {
}
return { w, h };
}
function terminal(ctx, x, y, w, h, lines, f) {
/** A terminal card: the commands (`› …`), then, dimmer, the first lines one of them printed. */
function terminal(ctx, x, y, w, h, lines, f, printed = []) {
rr(ctx, x, y, w, h, 7);
inked(ctx, C.line, 2.5);
ctx.fillStyle = C.balloonInk;
@@ -708,10 +709,21 @@ const StoryArt = (() => {
ctx.fillStyle = i === Math.min(rows, lines.length) - 1 ? C.yellow : C.balloon;
ctx.fillText(text.slice(0, shown), x + 9, y + 30 + i * 14);
});
if (f % 30 < 15) {
const n = Math.min(rows, lines.length);
const n = Math.min(rows, lines.length);
const out = printed.slice(0, Math.max(0, rows - n));
out.forEach((line, i) => {
const text = fitText(ctx, line, w - 18);
const at = 8 + (n + i) * 12;
const shown = Math.floor(interpolate(f, [at, at + 10], [0, text.length]));
ctx.save();
ctx.globalAlpha = 0.72;
ctx.fillStyle = C.balloon;
ctx.fillRect(x + 9, y + 22 + n * 14, 6, 10);
ctx.fillText(text.slice(0, shown), x + 9, y + 30 + (n + i) * 14);
ctx.restore();
});
if (f % 30 < 15) {
ctx.fillStyle = C.balloon;
ctx.fillRect(x + 9, y + 22 + (n + out.length) * 14, 6, 10);
}
}
function globe(ctx, cx, cy, r, f) {
@@ -1248,7 +1260,7 @@ const StoryArt = (() => {
draw(ctx, W, H, f, p, hit) {
const tw = Math.min(W * 0.62, 260);
const notes = p.panel.notes?.length ? p.panel.notes : ['…'];
terminal(ctx, 14, 92, tw, 140, notes, f);
terminal(ctx, 14, 92, tw, 140, notes.slice(0, p.panel.printed?.length ? 3 : 8), f, p.panel.printed || []);
hit('output', 14, 92, tw, 140, 'See the commands and their output');
agent(ctx, W - 48, 250, {
frame: f,