mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(story-mode): files written in Bash, the advisor, and honest checks, from review round 2
- Files a Bash call writes through here-documents (`cat > f <<'EOF'`, `tee`) are whole-file edits on the event: in the diff drawer, on the code page, in "N files changed". Before, a worker that wrote seven files that way drew a page with zero files changed. Labels leave the bodies out (the wire row was a wall of file text), and a relative target resolves against the transcript line's cwd unless the command changed directory first. - A caption names the part of a one-liner that decided it (`node --test`, the commit), not its first line. - The advisor (a server tool, answer encrypted in the transcript) is drawn: "Claude asks the advisor for a second opinion." It is not counted as a helper. Server web search maps to the web panel. - Checks that all pass from the start read "Build and test, N rounds. Every check passes.", not as fixing. - pytest's collection errors are counted apart: "The tests don't even load." - A result remark replaces a step announcement on its check. - A turn still open when the next prompt starts has ended; an interrupted turn is timed by its span. - The mission drawer keeps prompts up to 4000 characters. - Drawer: diff headers, titles and commands read relative to the session folder (the folder itself reads "."); a bare name under an `ls dir:` header links into that folder; the chosen answer is marked, not struck through; boards count errors. - While a question waits, the pencils say "Waiting for your answer…"; balloons start below the LIVE tag; the loop's balloon points at its verdict, above the board. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
831599a6cf
commit
ca035dd41b
7 files changed
+439
-64
No files matched your search
@@ -1067,7 +1067,7 @@ Responses carry a weak `ETag` with `Cache-Control: no-cache`; a request with a m
|
||||
|
||||
A `page` carries the turn's summary fields plus `events` (one per prompt, sentence Claude wrote and tool call, with `intent`, `ok` when the call returned, `edit` hunks in Claude Code's own `structuredPatch` shape, the tail of `output`, `helper`, `tests` counts, and `files` for the files a Bash call printed with `cat`/`head`/`tail`, as the command named them), `panels` (each `{ kind, events, caption: { story, director }, balloon?, files?, ... }`, at most 12 per page, indices into `events`), `filesChanged`, `added`, `removed`, `helpers`, `helpersRunning`, `partial` (the turn began before the part that was read) and `truncated` (older outputs were dropped to keep the page small). A transcript that cannot be read is a `500` with `OPERATION_FAILED`.
|
||||
|
||||
A helper started in the background (`helper.background`) carries `helper.running: true` until its `<task-notification>` arrives; that notification starts a turn of its own (origin `task`), whose prompt event carries the helper's answer as `output`, and the answer, tool count and duration are joined back onto the helper in the turn that started it. Claude Code times such a turn from the prompt that started the chain; Story Mode caps a turn's `durationMs` at the turn's own span. A helper started as a teammate (`status: teammate_spawned`) is a background helper too, and its `<teammate-message>` reports join it by name. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn, a prompt event carrying `origin` (`human`, `task`, `peer`, `system`), and a report that lands mid-turn is an event with intent `notice` rather than a new mission panel. A turn ends at `turn_duration`, or at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`, timed then by its own span.
|
||||
A helper started in the background (`helper.background`) carries `helper.running: true` until its `<task-notification>` arrives; that notification starts a turn of its own (origin `task`), whose prompt event carries the helper's answer as `output`, and the answer, tool count and duration are joined back onto the helper in the turn that started it. Claude Code times such a turn from the prompt that started the chain; Story Mode caps a turn's `durationMs` at the turn's own span. A helper started as a teammate (`status: teammate_spawned`) is a background helper too, and its `<teammate-message>` reports join it by name. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn, a prompt event carrying `origin` (`human`, `task`, `peer`, `system`), and a report that lands mid-turn is an event with intent `notice` rather than a new mission panel. A Bash call that writes files through here-documents (`cat > f <<'EOF'`) carries them as `edits` (whole-file edits, counted in `filesChanged`), and its label leaves the bodies out; `headline` is the part of a one-liner that decided its intent. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript. `tests` may carry `errors` (pytest's collection errors). A turn ends at `turn_duration`, or at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`, timed then by its own span.
|
||||
|
||||
## Git worktrees of a case
|
||||
|
||||
|
||||
+178
-29
@@ -49,7 +49,7 @@ import type {
|
||||
|
||||
/** Size caps. A page stays small enough to fetch on every live refresh. */
|
||||
export const STORY_LIMITS = Object.freeze({
|
||||
promptChars: 600,
|
||||
promptChars: 4000,
|
||||
textChars: 600,
|
||||
labelChars: 400,
|
||||
inputChars: 2000,
|
||||
@@ -76,6 +76,10 @@ interface ReducedInput {
|
||||
reads?: string[];
|
||||
/** A save that ships: `gh pr create` opens a pull request, `git push` sends the work up. */
|
||||
ships?: 'pr' | 'push';
|
||||
/** The segment of a Bash one-liner that decided its intent. */
|
||||
headline?: string;
|
||||
/** Files a Bash call writes through here-documents. */
|
||||
writes?: StoryEdit[];
|
||||
description?: string;
|
||||
file?: string;
|
||||
offset?: number;
|
||||
@@ -410,7 +414,13 @@ const INTENT_RANK: StoryIntent[] = ['test', 'save', 'build', 'edit', 'run', 'loo
|
||||
* env assignments and wrappers like `timeout`), and the strongest intent of any part wins.
|
||||
*/
|
||||
export function classifyBashCommand(command: string): StoryIntent {
|
||||
return judgeBashCommand(command).intent;
|
||||
}
|
||||
|
||||
/** The intent of a Bash command and the segment that decided it. */
|
||||
function judgeBashCommand(command: string): { intent: StoryIntent; segment: string } {
|
||||
let best = INTENT_RANK.length - 1;
|
||||
let deciding = '';
|
||||
for (const segment of commandSegments(withoutHeredocBodies(command.slice(0, 4000)))) {
|
||||
const head = segmentHead(segment);
|
||||
if (!head) continue;
|
||||
@@ -427,9 +437,69 @@ export function classifyBashCommand(command: string): StoryIntent {
|
||||
: LOOK_HEAD.test(head)
|
||||
? 'look'
|
||||
: 'run';
|
||||
best = Math.min(best, INTENT_RANK.indexOf(intent));
|
||||
const rank = INTENT_RANK.indexOf(intent);
|
||||
if (rank < best || !deciding) {
|
||||
best = Math.min(best, rank);
|
||||
deciding = segment.trim();
|
||||
}
|
||||
}
|
||||
return INTENT_RANK[best];
|
||||
return { intent: INTENT_RANK[best], segment: deciding };
|
||||
}
|
||||
|
||||
/**
|
||||
* Files a command writes through here-documents: `cat > f <<'EOF'`, `cat <<EOF > f`, `tee f <<EOF`
|
||||
* (and `>>` / `tee -a`, appends). Each becomes a whole-file edit of the body's lines. A relative
|
||||
* target resolves against the transcript line's `cwd` unless the command changed directory first;
|
||||
* scratch targets (`/tmp/x`, `"$S/x"`) are not project files. A body piped into a program
|
||||
* (`python3 - <<EOF`) writes nothing.
|
||||
*/
|
||||
export function heredocWrites(command: string, cwd?: string): StoryEdit[] {
|
||||
const out: StoryEdit[] = [];
|
||||
const lines = command.slice(0, 200_000).split('\n');
|
||||
let moved = false;
|
||||
for (let i = 0; i < lines.length && out.length < 6; i++) {
|
||||
const line = lines[i];
|
||||
if (/(^|[;&|(]\s*)(cd|pushd)\s/.test(line.split('<<')[0])) moved = true;
|
||||
const m = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(line.replace(/<<</g, ' '));
|
||||
if (!m) continue;
|
||||
const body: string[] = [];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].trim() !== m[2]) body.push(lines[j++]);
|
||||
i = j;
|
||||
const before = line.slice(0, m.index);
|
||||
const after = line.slice(m.index + m[0].length);
|
||||
const redirect = /(?:^|[^>&0-9])(>>?)\s*(["']?)([^\s"'|;&<>()]+)\2/;
|
||||
let target: string | undefined;
|
||||
let append = false;
|
||||
if (/\bcat\s*(>|$|\s)/.test(before)) {
|
||||
const r = redirect.exec(before) ?? redirect.exec(after);
|
||||
if (r) {
|
||||
target = r[3];
|
||||
append = r[1] === '>>';
|
||||
}
|
||||
} else {
|
||||
const tee = /\btee\s+(-a\s+)?(["']?)([^\s"'|;&<>()]+)\2/.exec(before);
|
||||
if (tee) {
|
||||
target = tee[3];
|
||||
append = !!tee[1];
|
||||
}
|
||||
}
|
||||
if (!target || SCRATCH_TARGET.test(target)) continue;
|
||||
const file =
|
||||
target.startsWith('/') || !cwd || moved ? target : `${cwd.replace(/\/+$/, '')}/${target.replace(/^\.\//, '')}`;
|
||||
const kept = body
|
||||
.slice(0, STORY_LIMITS.hunkLines)
|
||||
.map((l) => (l.length >= STORY_LIMITS.lineChars ? `+${l.slice(0, STORY_LIMITS.lineChars - 2)}…` : `+${l}`));
|
||||
out.push({
|
||||
file,
|
||||
added: body.length,
|
||||
removed: 0,
|
||||
created: false,
|
||||
hunks: [{ oldStart: append ? 1 : 0, oldLines: 0, newStart: 1, newLines: body.length, lines: kept }],
|
||||
truncated: kept.length < body.length,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -444,8 +514,10 @@ export function parseTestCounts(output: string): StoryTests | null {
|
||||
const nodeFail = lines.map((l) => /^\s*[#ℹ]\s*fail\s+(\d+)\s*$/.exec(l)).find(Boolean);
|
||||
return { passed: Number(nodePass[1]), failed: nodeFail ? Number(nodeFail[1]) : 0 };
|
||||
}
|
||||
// A run with errors (pytest's collection errors) failed as surely as one with failed tests.
|
||||
// Errors (pytest's collection errors) are counted apart: the run failed although no test did.
|
||||
const errorsOn = (line: string) => Number(/\b(\d+) errors?\b/i.exec(line)?.[1] ?? 0);
|
||||
const counts = (passed: number, failed: number, errors: number): StoryTests =>
|
||||
errors ? { passed, failed, errors } : { passed, failed };
|
||||
for (const line of lines) {
|
||||
const passed = /\b(\d+) (passed|passing)\b/i.exec(line);
|
||||
if (!passed) continue;
|
||||
@@ -455,14 +527,14 @@ export function parseTestCounts(output: string): StoryTests | null {
|
||||
const failing = lines.map((l) => /^\s*(\d+) failing\b/i.exec(l)).find(Boolean);
|
||||
if (failing) failed = Number(failing[1]);
|
||||
}
|
||||
return { passed: Number(passed[1]), failed: failed + errorsOn(line) };
|
||||
return counts(Number(passed[1]), failed, errorsOn(line));
|
||||
}
|
||||
// Nothing passed: a summary line that only counts failures (`=== 3 failed in 0.12s ===`,
|
||||
// `Tests 3 failed (3)`, `1 error in 0.05s`).
|
||||
for (const line of lines) {
|
||||
if (!/^\s*(=+ |Tests?:?\s|test result:)|\bin [\d.]+m?s\b/.test(line)) continue;
|
||||
const failed = Number(/\b(\d+) (failed|failing)\b/i.exec(line)?.[1] ?? 0) + errorsOn(line);
|
||||
if (failed) return { passed: 0, failed };
|
||||
const failed = Number(/\b(\d+) (failed|failing)\b/i.exec(line)?.[1] ?? 0);
|
||||
if (failed || errorsOn(line)) return counts(0, failed, errorsOn(line));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -562,7 +634,7 @@ function originOf(turnOrigin: unknown, kind: 'prompt' | 'task' | 'peer'): StoryT
|
||||
return 'human';
|
||||
}
|
||||
|
||||
function reduceInput(name: string, input: unknown): ReducedInput {
|
||||
function reduceInput(name: string, input: unknown, cwd?: string): ReducedInput {
|
||||
const i = isRecord(input) ? input : {};
|
||||
const s = (v: unknown, max: number) => {
|
||||
const value = str(v);
|
||||
@@ -572,12 +644,17 @@ function reduceInput(name: string, input: unknown): ReducedInput {
|
||||
case 'Bash': {
|
||||
// Judged on the whole command: a commit or a test run often sits past the displayed part.
|
||||
const full = str(i.command) ?? '';
|
||||
const intent = classifyBashCommand(full);
|
||||
const { intent, segment } = judgeBashCommand(full);
|
||||
const input: ReducedInput = {
|
||||
command: s(i.command, STORY_LIMITS.labelChars),
|
||||
// Shown without here-document bodies: the files they write are shown as diffs instead.
|
||||
command: clip(withoutHeredocBodies(full), STORY_LIMITS.labelChars),
|
||||
description: s(i.description, 160),
|
||||
intent,
|
||||
};
|
||||
if (segment && segment !== full.trim() && (intent === 'test' || intent === 'build' || intent === 'save'))
|
||||
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;
|
||||
@@ -700,6 +777,11 @@ function editFromResult(name: string, result: Record<string, unknown>, input: Re
|
||||
}
|
||||
|
||||
/** Tools whose result text is noise on a comic page (file contents, "edited successfully"). */
|
||||
/** Server tools under the names the client tools of the same kind use. */
|
||||
const SERVER_TOOLS: Record<string, string> = { advisor: 'Advisor', web_search: 'WebSearch', web_fetch: 'WebFetch' };
|
||||
/** The advisor's answer reaches the transcript encrypted; this says so instead. */
|
||||
const ADVISOR_ANSWER = 'The advice went to Claude only: the transcript keeps it encrypted.';
|
||||
|
||||
const QUIET_RESULTS = new Set(['Read', 'NotebookRead', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'TodoWrite']);
|
||||
const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
|
||||
|
||||
@@ -854,9 +936,28 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
|
||||
if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
|
||||
out.push({ k: 'text', at, text: clip(block.text, STORY_LIMITS.textChars) });
|
||||
} else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
|
||||
const input = reduceInput(block.name, block.input);
|
||||
const input = reduceInput(block.name, block.input, str(entry.cwd));
|
||||
remember(block.id, block.name, input);
|
||||
out.push({ k: 'tool', at, id: block.id, name: block.name, input });
|
||||
} else if (block.type === 'server_tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
|
||||
// Tools the API runs itself (the advisor, web search): call and result share the message.
|
||||
const name = SERVER_TOOLS[block.name] ?? block.name;
|
||||
const input = reduceInput(name, block.input);
|
||||
remember(block.id, name, input);
|
||||
out.push({ k: 'tool', at, id: block.id, name, input });
|
||||
} else if (
|
||||
typeof block.type === 'string' &&
|
||||
block.type.endsWith('_tool_result') &&
|
||||
typeof block.tool_use_id === 'string' &&
|
||||
calls.has(block.tool_use_id)
|
||||
) {
|
||||
const content = isRecord(block.content) ? block.content : {};
|
||||
const isError = typeof content.type === 'string' && /error/.test(content.type);
|
||||
const call = calls.get(block.tool_use_id);
|
||||
const result: Extract<StoryEntry, { k: 'result' }> = { k: 'result', at, id: block.tool_use_id, isError };
|
||||
if (call?.name === 'Advisor' && !isError) result.helper = { result: ADVISOR_ANSWER };
|
||||
out.push(result);
|
||||
calls.delete(block.tool_use_id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -926,6 +1027,7 @@ function intentOf(name: string, input: ReducedInput): StoryIntent {
|
||||
case 'Agent':
|
||||
case 'Task':
|
||||
case 'Workflow':
|
||||
case 'Advisor':
|
||||
return 'helper';
|
||||
case 'SendMessage':
|
||||
return 'message';
|
||||
@@ -955,6 +1057,8 @@ function labelOf(name: string, i: ReducedInput): string {
|
||||
case 'Agent':
|
||||
case 'Task':
|
||||
return `${i.name || i.type || 'helper'}: ${i.description ?? ''}`.trim();
|
||||
case 'Advisor':
|
||||
return 'a second opinion';
|
||||
case 'TodoWrite':
|
||||
return plural(i.todos?.length ?? 0, 'item');
|
||||
case 'AskUserQuestion':
|
||||
@@ -982,6 +1086,8 @@ function toolEvent(e: Extract<StoryEntry, { k: 'tool' }>, actor: StoryActor): St
|
||||
if (i.file) ev.file = i.file;
|
||||
if (i.reads) ev.files = i.reads;
|
||||
if (i.ships) ev.ships = i.ships;
|
||||
if (i.headline) ev.headline = i.headline;
|
||||
if (i.writes) ev.edits = i.writes;
|
||||
if (i.offset) ev.line = i.offset;
|
||||
if (e.name === 'Bash' && i.description) ev.description = i.description;
|
||||
if (e.name === 'Agent' || e.name === 'Task') {
|
||||
@@ -992,6 +1098,13 @@ function toolEvent(e: Extract<StoryEntry, { k: 'tool' }>, actor: StoryActor): St
|
||||
prompt: i.prompt || i.description || '',
|
||||
};
|
||||
}
|
||||
if (e.name === 'Advisor')
|
||||
ev.helper = {
|
||||
name: 'advisor',
|
||||
type: 'advisor',
|
||||
task: 'A second opinion',
|
||||
prompt: 'Claude shows a stronger model the whole conversation so far and asks what it thinks.',
|
||||
};
|
||||
if (i.todos) ev.todos = i.todos;
|
||||
if (e.name === 'AskUserQuestion') ev.question = { text: i.question ?? '', options: i.options ?? [] };
|
||||
if (e.name === 'ExitPlanMode')
|
||||
@@ -1011,7 +1124,7 @@ function applyResult(ev: StoryEvent, r: Extract<StoryEntry, { k: 'result' }>): v
|
||||
if (r.tests) {
|
||||
ev.tests = r.tests;
|
||||
// A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0).
|
||||
if (r.tests.failed > 0) ev.ok = false;
|
||||
if (r.tests.failed > 0 || (r.tests.errors ?? 0) > 0) ev.ok = false;
|
||||
}
|
||||
if (r.helper && ev.helper) {
|
||||
const helper: StoryHelper = { ...ev.helper };
|
||||
@@ -1058,6 +1171,13 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
|
||||
ev.helper = helper;
|
||||
}
|
||||
};
|
||||
/** Ends a turn at `at` with no duration of its own: timed by its span, when the span is known. */
|
||||
const closeTurn = (t: StoryTurn, at: string) => {
|
||||
t.ended = true;
|
||||
t.endedAt = at;
|
||||
const span = Date.parse(at) - Date.parse(t.at);
|
||||
if (!t.partial && Number.isFinite(span) && span >= 0) t.durationMs ??= span;
|
||||
};
|
||||
const reportBack = (report: StoryTaskReport, prompt: StoryEvent) => {
|
||||
if (report.result) {
|
||||
prompt.output = report.result;
|
||||
@@ -1102,14 +1222,17 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
|
||||
};
|
||||
if (e.task) reportBack(e.task, ev);
|
||||
if (e.teammates) teammatesBack(e.teammates, ev);
|
||||
if (cur && !cur.ended && e.queued) {
|
||||
const open = cur as StoryTurn | null;
|
||||
if (open && !open.ended && e.queued) {
|
||||
// A report that lands while Claude works is a line on the wire, not a new mission.
|
||||
if (e.origin === 'task') {
|
||||
ev.intent = 'notice';
|
||||
ev.tool = 'Report';
|
||||
}
|
||||
cur.events.push(ev);
|
||||
open.events.push(ev);
|
||||
} else {
|
||||
// Claude runs one turn at a time: a turn still open when the next one starts has ended.
|
||||
if (open && !open.ended) closeTurn(open, e.at);
|
||||
cur = {
|
||||
id: e.id,
|
||||
at: e.at,
|
||||
@@ -1187,8 +1310,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
|
||||
intent: 'interrupt',
|
||||
});
|
||||
t.interrupted = true;
|
||||
t.ended = true;
|
||||
t.endedAt = e.at;
|
||||
closeTurn(t, e.at);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1257,12 +1379,18 @@ interface DraftPanel {
|
||||
}
|
||||
|
||||
/** Keeps the newest outputs, hunks and helper texts within the page budget. */
|
||||
/** Every edit an event made: an Edit/Write call's one, or the files a Bash call wrote. */
|
||||
const editsOf = (e: StoryEvent): StoryEdit[] => (e.edit ? [e.edit] : (e.edits ?? []));
|
||||
|
||||
function applyTextBudget(events: StoryEvent[]): boolean {
|
||||
let used = 0;
|
||||
let cut = false;
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
const hunkChars = e.edit ? e.edit.hunks.reduce((n, h) => n + h.lines.reduce((m, l) => m + l.length, 0), 0) : 0;
|
||||
const hunkChars = editsOf(e).reduce(
|
||||
(n, d) => n + d.hunks.reduce((m, h) => m + h.lines.reduce((k, l) => k + l.length, 0), 0),
|
||||
0
|
||||
);
|
||||
const size = (e.output?.length ?? 0) + hunkChars + (e.helper?.result?.length ?? 0) + (e.helper?.prompt.length ?? 0);
|
||||
if (used + size <= STORY_LIMITS.pageTextBudget) {
|
||||
used += size;
|
||||
@@ -1273,6 +1401,7 @@ function applyTextBudget(events: StoryEvent[]): boolean {
|
||||
e.outputTruncated = true;
|
||||
}
|
||||
if (e.edit && e.edit.hunks.length) e.edit = { ...e.edit, hunks: [], truncated: true };
|
||||
if (e.edits?.some((d) => d.hunks.length)) e.edits = e.edits.map((d) => ({ ...d, hunks: [], truncated: true }));
|
||||
if (e.helper)
|
||||
e.helper = {
|
||||
...e.helper,
|
||||
@@ -1308,7 +1437,7 @@ function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay?
|
||||
const reportsBack =
|
||||
pendingSay &&
|
||||
last?.kind === 'check' &&
|
||||
!last.balloon &&
|
||||
(!last.balloon || !TALKS_RESULTS.test(last.balloon.text)) &&
|
||||
TALKS_RESULTS.test(pendingSay) &&
|
||||
!ANNOUNCES.test(pendingSay);
|
||||
if (kind === 'mission') panel.balloon = { who: 'you', text: clip(ev.label, 220) };
|
||||
@@ -1392,8 +1521,9 @@ function countBy(events: StoryEvent[], key: (e: StoryEvent) => string): string {
|
||||
|
||||
/** Lines of a script that say nothing about what it does: `set -e`, a bare `cd dir`, `S=/tmp/x`, comments. */
|
||||
const NOISE_LINE = /^\s*(#.*|set\s+[-+][a-zA-Z]+(\s+\w+)?|cd\s+\S+|[A-Za-z_][A-Za-z0-9_]*=\S*)?\s*$/;
|
||||
/** A command's first line that says what it does, for a caption. */
|
||||
/** What a command does, for a caption: the part that decided it, else its first telling line. */
|
||||
const shortCommand = (e: StoryEvent) => {
|
||||
if (e.headline) return clip(e.headline.split('\n')[0] ?? '', 60);
|
||||
const lines = e.label.split('\n');
|
||||
return clip(lines.find((l) => !NOISE_LINE.test(l)) ?? lines[0] ?? '', 60);
|
||||
};
|
||||
@@ -1415,7 +1545,7 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
const evs = draft.events.map((i) => events[i]);
|
||||
const panel: StoryPanel = { kind: draft.kind, events: draft.events, caption: { story: '', director: '' } };
|
||||
if (draft.balloon) panel.balloon = draft.balloon;
|
||||
const edits = evs.filter((e) => e.edit).map((e) => e.edit as StoryEdit);
|
||||
const edits = evs.flatMap(editsOf);
|
||||
const checks = evs.filter((e) => e.intent === 'test' || e.intent === 'build');
|
||||
const lastCheck = checks[checks.length - 1];
|
||||
// A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0).
|
||||
@@ -1503,10 +1633,13 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
.map(shortCommand)
|
||||
.slice(0, 4);
|
||||
const behind = evs.filter((e) => e.helper?.background).length;
|
||||
const advisor = evs.filter((e) => e.tool === 'Advisor').length;
|
||||
story = helpers.length
|
||||
? behind === helpers.length
|
||||
? `Claude sends ${plural(helpers.length, 'helper')} off to work in the background.`
|
||||
: `Claude calls in ${plural(helpers.length, 'helper')}.`
|
||||
? advisor === helpers.length
|
||||
? 'Claude asks the advisor for a second opinion.'
|
||||
: behind === helpers.length
|
||||
? `Claude sends ${plural(helpers.length, 'helper')} off to work in the background.`
|
||||
: `Claude calls in ${plural(helpers.length, 'helper')}.`
|
||||
: 'Claude sends a message to another session.';
|
||||
const inside = evs.reduce((n, e) => n + (e.helper?.toolUses ?? 0), 0);
|
||||
director = `${tools}${helpers.length ? ` · ${unique(helpers.map((h) => h.type)).join(' + ')}` : ''}${inside ? ` · ${plural(inside, 'call')} inside` : ''}`;
|
||||
@@ -1515,7 +1648,10 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
case 'fix':
|
||||
case 'loop': {
|
||||
// From the calls, not only the results: an edit still running already names its file.
|
||||
const files = unique(evs.filter((e) => e.intent === 'edit' && e.file).map((e) => e.file as string));
|
||||
const files = unique([
|
||||
...evs.filter((e) => e.intent === 'edit' && e.file).map((e) => e.file as string),
|
||||
...edits.map((d) => d.file),
|
||||
]);
|
||||
panel.files = files.slice(0, 12);
|
||||
panel.added = edits.reduce((n, e) => n + e.added, 0);
|
||||
panel.removed = edits.reduce((n, e) => n + e.removed, 0);
|
||||
@@ -1541,7 +1677,12 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
if (ok !== undefined) panel.ok = ok;
|
||||
const tests = lastCheck?.tests;
|
||||
if (tests) panel.tests = tests;
|
||||
story = `Fix and test, ${plural(panel.rounds, 'round')}.${panel.ok === true ? ' It passes in the end.' : panel.ok === false ? ' Still failing.' : ''}`;
|
||||
// Every check green from the start: Claude was building piece by piece, not fixing.
|
||||
const neverFailed = checks.every((e) => passed(e) !== false);
|
||||
story =
|
||||
neverFailed && panel.ok === true
|
||||
? `Build and test, ${plural(panel.rounds, 'round')}. Every check passes.`
|
||||
: `Fix and test, ${plural(panel.rounds, 'round')}.${panel.ok === true ? ' It passes in the end.' : panel.ok === false ? ' Still failing.' : ''}`;
|
||||
director = `${plural(checks.length, 'check')} · ${plural(edits.length, 'edit')} · ${fmtCounts(panel.added, panel.removed)} · last: ${
|
||||
lastCheck ? outcome(lastCheck) : 'running'
|
||||
}`;
|
||||
@@ -1557,6 +1698,7 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
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.';
|
||||
else if (!ok && tests && !tests.passed && !tests.failed && tests.errors) story = "The tests don't even load.";
|
||||
else if (ok)
|
||||
story = failedFirst ? 'It fails at first, then passes.' : isTest ? 'The tests pass.' : 'The checks pass.';
|
||||
else
|
||||
@@ -1569,7 +1711,11 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
.slice(-3)
|
||||
.map((e) => `${shortCommand(e)} → ${outcome(e)}`)
|
||||
.join(' · ');
|
||||
if (tests) director += ` · ${tests.passed} passed${tests.failed ? `, ${tests.failed} failed` : ''}`;
|
||||
if (tests)
|
||||
director += ` · ${tests.passed} passed${tests.failed ? `, ${tests.failed} failed` : ''}${
|
||||
tests.errors ? `, ${plural(tests.errors, 'error')}` : ''
|
||||
}`;
|
||||
if (edits.length) director += ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}`;
|
||||
break;
|
||||
}
|
||||
case 'run':
|
||||
@@ -1594,7 +1740,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
|
||||
: ships === 'push'
|
||||
? 'Claude saves the work and sends it up.'
|
||||
: 'Claude saves the work.';
|
||||
director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}`;
|
||||
director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${
|
||||
edits.length ? ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}` : ''
|
||||
}`;
|
||||
break;
|
||||
}
|
||||
case 'ask': {
|
||||
@@ -1674,7 +1822,7 @@ export function buildStoryPage(turn: StoryTurn): StoryPage {
|
||||
const drafts = capPanels(collapseLoops(grouped), room);
|
||||
const panels = drafts.map((d, i) => decorate(d, events, turn, i));
|
||||
|
||||
const edits = events.filter((e) => e.edit).map((e) => e.edit as StoryEdit);
|
||||
const edits = events.flatMap(editsOf);
|
||||
const base: Omit<StoryPage, 'panels'> = {
|
||||
...summarizeTurn({ ...turn, events }),
|
||||
partial: turn.partial,
|
||||
@@ -1683,7 +1831,8 @@ export function buildStoryPage(turn: StoryTurn): StoryPage {
|
||||
filesChanged: unique(edits.map((e) => e.file)).length,
|
||||
added: edits.reduce((n, e) => n + e.added, 0),
|
||||
removed: edits.reduce((n, e) => n + e.removed, 0),
|
||||
helpers: events.filter((e) => e.helper).length,
|
||||
// The advisor is a second opinion, not a helper that does work.
|
||||
helpers: events.filter((e) => e.helper && e.tool !== 'Advisor').length,
|
||||
helpersRunning: events.filter((e) => e.helper?.running).length,
|
||||
};
|
||||
if (turn.endedAt) base.endedAt = turn.endedAt;
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface StoryHelper {
|
||||
export interface StoryTests {
|
||||
passed: number;
|
||||
failed: number;
|
||||
/** Errors outside the tests (pytest's collection errors): the run failed although no test did. */
|
||||
errors?: number;
|
||||
}
|
||||
|
||||
export interface StoryTodo {
|
||||
@@ -129,6 +131,10 @@ export interface StoryEvent {
|
||||
output?: string;
|
||||
outputTruncated?: boolean;
|
||||
edit?: StoryEdit;
|
||||
/** Files a Bash call wrote through a here-document (`cat > f <<'EOF'`), each as a whole-file edit. */
|
||||
edits?: StoryEdit[];
|
||||
/** The part of a Bash one-liner that decided its intent (the test run, the commit), for captions. */
|
||||
headline?: string;
|
||||
helper?: StoryHelper;
|
||||
tests?: StoryTests;
|
||||
todos?: StoryTodo[];
|
||||
|
||||
@@ -905,6 +905,14 @@ body.story-open {
|
||||
color: var(--story-go);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
/* The answer you picked: chosen, not crossed off. */
|
||||
.story-todos .is-chosen {
|
||||
color: var(--story-go);
|
||||
font-weight: 650;
|
||||
}
|
||||
.story-todos .is-chosen::before {
|
||||
content: '✓ ';
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.story-fresh *,
|
||||
|
||||
@@ -312,8 +312,14 @@ const StoryArt = (() => {
|
||||
}
|
||||
}
|
||||
/** The agent: a terminal-headed character. (x, y) is the point between its feet. */
|
||||
/** Every edit an event made: an Edit/Write call's one, or the files a Bash here-document wrote. */
|
||||
const editsOf = (e) => (e?.edit ? [e.edit] : e?.edits || []);
|
||||
/** Where Claude's head was last drawn (stage units): the speech balloon is placed over it. */
|
||||
let speaker = null;
|
||||
/** A scene with no Claude in it names the spot its balloon should point at. */
|
||||
function aim(x, top) {
|
||||
speaker = { x, top };
|
||||
}
|
||||
function takeSpeaker() {
|
||||
const s = speaker;
|
||||
speaker = null;
|
||||
@@ -991,8 +997,8 @@ const StoryArt = (() => {
|
||||
halftone(ctx, W, H, C.cyan, 0, H, { rmax: 3.6 });
|
||||
},
|
||||
draw(ctx, W, H, f, p, hit) {
|
||||
const edits = p.panel.events.map((i) => p.page.events[i]).filter((e) => e && e.edit);
|
||||
const first = edits[0]?.edit;
|
||||
const edits = p.panel.events.flatMap((i) => editsOf(p.page.events[i]));
|
||||
const first = edits[0];
|
||||
const lines = first?.hunks?.[0]?.lines || [];
|
||||
const pw = Math.min(W * 0.56, 196);
|
||||
const ph = 128;
|
||||
@@ -1037,12 +1043,13 @@ const StoryArt = (() => {
|
||||
},
|
||||
draw(ctx, W, H, f, p, hit) {
|
||||
const t = p.panel.tests;
|
||||
const total = t ? t.passed + t.failed : 0;
|
||||
const bad = t ? t.failed + (t.errors || 0) : 0;
|
||||
const total = t ? t.passed + bad : 0;
|
||||
const running = p.panel.ok === undefined;
|
||||
let b;
|
||||
if (total) {
|
||||
const dots = Math.min(42, Math.max(7, total));
|
||||
const fail = t.failed ? Math.max(1, Math.round((t.failed / total) * dots)) : 0;
|
||||
const fail = bad ? Math.max(1, Math.round((bad / total) * dots)) : 0;
|
||||
const fill = running ? (f * 0.6) % (dots + 6) : Math.min(dots, (f - 4) * 1.6);
|
||||
b = board(ctx, 16, 106, `${total} checks`, dots, fail, fill);
|
||||
} else {
|
||||
@@ -1086,8 +1093,10 @@ const StoryArt = (() => {
|
||||
halftone(ctx, W, H, C.magenta, W, 0, { cell: 8, rmax: 3.6 });
|
||||
},
|
||||
draw(ctx, W, H, f, p, hit) {
|
||||
const edits = p.panel.events.map((i) => p.page.events[i]).filter((e) => e && e.edit);
|
||||
const last = edits[edits.length - 1]?.edit;
|
||||
const edits = p.panel.events.flatMap((i) => editsOf(p.page.events[i]));
|
||||
const last = edits[edits.length - 1];
|
||||
// No Claude here: the balloon points at the verdict, above the board and the rounds.
|
||||
aim(W - 60, 128);
|
||||
const pw = Math.min(W * 0.36, 150);
|
||||
codePage(ctx, 12, 108, pw, 112, f, baseName(last?.file || 'file'), last?.hunks?.[0]?.lines || [], 0);
|
||||
hit('diff', 12, 108, pw, 112, "See Claude's changes");
|
||||
@@ -1095,11 +1104,12 @@ const StoryArt = (() => {
|
||||
if (bx + 127 < W - 10) {
|
||||
// The last run's real counts, or one verdict when it reported none.
|
||||
const t = p.panel.tests;
|
||||
const total = t ? t.passed + t.failed : 0;
|
||||
const bad = t ? t.failed + (t.errors || 0) : 0;
|
||||
const total = t ? t.passed + bad : 0;
|
||||
let b;
|
||||
if (total) {
|
||||
const dots = Math.min(28, Math.max(7, total));
|
||||
const fail = t.failed ? Math.max(1, Math.round((t.failed / total) * dots)) : 0;
|
||||
const fail = bad ? Math.max(1, Math.round((bad / total) * dots)) : 0;
|
||||
b = board(ctx, bx, 116, `${total} checks`, dots, fail, Math.min(dots, (f - 4) * 1.6));
|
||||
} else b = verdict(ctx, bx, 116, 'checks', p.panel.ok, f);
|
||||
hit('output', bx, 116, b.w, b.h, 'See the test output');
|
||||
@@ -1375,7 +1385,7 @@ const StoryArt = (() => {
|
||||
},
|
||||
};
|
||||
|
||||
return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker };
|
||||
return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf };
|
||||
})();
|
||||
|
||||
/** Panel kinds as the wire's group headings name them. */
|
||||
@@ -1988,10 +1998,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
const word = document.createElement('span');
|
||||
word.textContent = 'Pencils';
|
||||
const small = document.createElement('small');
|
||||
small.textContent = 'Claude is working…';
|
||||
pencils.append(word, small);
|
||||
}
|
||||
if (working) {
|
||||
// A question still open means Claude is waiting on you, not working.
|
||||
const waiting = page.events.some((e) => e.question && e.pending);
|
||||
pencils.querySelector('small').textContent = waiting ? 'Waiting for your answer…' : 'Claude is working…';
|
||||
pencils.classList.toggle('is-waiting', waiting);
|
||||
pencils.style.setProperty('--story-span6', String(spans6[want.length]));
|
||||
pencils.style.setProperty('--story-span2', String(spans2[want.length]));
|
||||
st.pageEl.append(pencils);
|
||||
@@ -2211,7 +2224,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const hx = placed ? (head.x + ox) * s : 0;
|
||||
const cap = rt.capStory.parentElement;
|
||||
const capW = cap?.offsetWidth || 0;
|
||||
const key = `${rt.w}x${rt.h}|${placed ? Math.round(hx / 6) : '-'}|${capW}|${b.className}|${b.textContent}`;
|
||||
const live = !rt.liveTag.hidden;
|
||||
const key = `${rt.w}x${rt.h}|${placed ? Math.round(hx / 6) : '-'}|${capW}|${live}|${b.className}|${b.textContent}`;
|
||||
if (key === rt.balloonKey) return;
|
||||
rt.balloonKey = key;
|
||||
if (placed) {
|
||||
@@ -2223,6 +2237,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const besideCap = rt.w - right - capRight - 10;
|
||||
if (besideCap >= 150) maxW = Math.min(maxW, besideCap);
|
||||
else top = capBottom + 8;
|
||||
// The LIVE tag sits in the top-right corner: start below it when the balloon reaches that far.
|
||||
if (live && right < rt.w - rt.liveTag.offsetLeft)
|
||||
top = Math.max(top, rt.liveTag.offsetTop + rt.liveTag.offsetHeight + 6);
|
||||
b.style.left = 'auto';
|
||||
b.style.right = `${Math.round(right)}px`;
|
||||
b.style.top = `${Math.round(top)}px`;
|
||||
@@ -2526,13 +2543,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
_storyShortPath(text) {
|
||||
const dir = this.sessions?.get(this._story?.sessionId)?.workingDir;
|
||||
if (!dir || dir === '/' || !text) return text;
|
||||
const prefix = dir.endsWith('/') ? dir : `${dir}/`;
|
||||
return text.split(prefix).join('');
|
||||
const base = dir.replace(/\/+$/, '');
|
||||
return text
|
||||
.split(`${base}/`)
|
||||
.join('')
|
||||
.replace(new RegExp(`${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=$|[\\s'"\`:;,)])`, 'g'), '.');
|
||||
},
|
||||
|
||||
/** What a click on an event opens, as a `data-story-open` target, or null. */
|
||||
_storyEventTarget(ev, index) {
|
||||
if (ev.edit) return `diff:event:${index}`;
|
||||
if (ev.edit || ev.edits?.length) return `diff:event:${index}`;
|
||||
if (ev.helper) return `helper:event:${index}`;
|
||||
if (ev.output !== undefined || ev.outputTruncated) return `output:event:${index}`;
|
||||
if (ev.file) return `file:event:${index}`;
|
||||
@@ -2662,7 +2682,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Changes made from the terminal (`sed -i`, a redirect) have no diff to show: show what ran.
|
||||
const view =
|
||||
source.view === 'diff' && items.length && !items.some((x) => x.ev.edit || x.ev.pending) ? 'output' : source.view;
|
||||
source.view === 'diff' && items.length && !items.some((x) => StoryArt.editsOf(x.ev).length || x.ev.pending)
|
||||
? 'output'
|
||||
: source.view;
|
||||
if (view === 'prompt') {
|
||||
const prompts = items.filter((x) => x.ev.intent === 'prompt');
|
||||
const from = prompts[0]?.ev.origin ?? 'human';
|
||||
@@ -2682,20 +2704,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (ev.outputTruncated) out.push(el('p', 'story-empty', 'The report is cut here.'));
|
||||
}
|
||||
}
|
||||
if (prompts.some((x) => x.ev.label.length >= 599))
|
||||
if (prompts.some((x) => x.ev.label.length >= 3999))
|
||||
out.push(el('p', 'story-empty', 'A long prompt is cut here; the terminal has all of it.'));
|
||||
} else if (view === 'diff') {
|
||||
const edits = items.filter((x) => x.ev.edit);
|
||||
const edits = items.flatMap((x) =>
|
||||
StoryArt.editsOf(x.ev).map((d, k) => ({ ev: x.ev, edit: d, key: `${x.ev.id}#${k}` }))
|
||||
);
|
||||
kind = live ? 'Live diff' : 'Diff';
|
||||
const added = edits.reduce((n, x) => n + x.ev.edit.added, 0);
|
||||
const removed = edits.reduce((n, x) => n + x.ev.edit.removed, 0);
|
||||
const files = new Set(edits.map((x) => x.ev.edit.file)).size;
|
||||
title = files === 1 ? edits[0].ev.edit.file : `${files} files`;
|
||||
const added = edits.reduce((n, x) => n + x.edit.added, 0);
|
||||
const removed = edits.reduce((n, x) => n + x.edit.removed, 0);
|
||||
const files = new Set(edits.map((x) => x.edit.file)).size;
|
||||
title = files === 1 ? this._storyShortPath(edits[0].edit.file) : `${files} files`;
|
||||
const counts = [added ? `+${added}` : '', removed ? `−${removed}` : ''].filter(Boolean).join(' ') || '±0';
|
||||
sub = `${edits.length} edit${edits.length === 1 ? '' : 's'} · ${counts}${live ? ' · new edits slide in as Claude makes them' : ''}`;
|
||||
if (!edits.length) out.push(el('p', 'story-empty', 'No changes in this part of the turn yet.'));
|
||||
for (const { ev } of edits) {
|
||||
const section = this._storyDiffSection(ev, el);
|
||||
for (const { ev, edit } of edits) {
|
||||
const section = this._storyDiffSection(edit, el);
|
||||
if (isNew(ev.id)) {
|
||||
section.classList.add('story-arrive');
|
||||
firstNew ??= section;
|
||||
@@ -2775,7 +2799,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
title = ev?.question?.text || 'Question';
|
||||
if (ev?.question?.options?.length) {
|
||||
const ul = el('ul', 'story-todos');
|
||||
for (const o of ev.question.options) ul.append(el('li', o === ev.question.answer ? 'is-done' : '', o));
|
||||
for (const o of ev.question.options) ul.append(el('li', o === ev.question.answer ? 'is-chosen' : '', o));
|
||||
out.push(ul);
|
||||
}
|
||||
out.push(
|
||||
@@ -2873,11 +2897,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
box.replaceChildren(el('p', 'story-out-desc', summary), list);
|
||||
},
|
||||
|
||||
_storyDiffSection(ev, el) {
|
||||
const edit = ev.edit;
|
||||
_storyDiffSection(edit, el) {
|
||||
const section = el('section', 'story-df');
|
||||
const head = el('div', 'story-df-head');
|
||||
const name = el('span', 'story-df-path', edit.file);
|
||||
const name = el('span', 'story-df-path', this._storyShortPath(edit.file));
|
||||
if (name.textContent !== edit.file) name.title = edit.file;
|
||||
const cnt = el('span', 'story-cnt');
|
||||
cnt.append(
|
||||
el('span', 'story-cnt-a', edit.added ? `+${edit.added}` : ''),
|
||||
@@ -2921,7 +2945,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
_storyOutputSection(ev, index, el) {
|
||||
const section = el('section', 'story-out');
|
||||
const head = el('div', 'story-out-head');
|
||||
const cmd = el('span', 'story-out-cmd', `${ev.tool === 'Bash' ? '$ ' : `${ev.tool} `}${ev.label}`);
|
||||
const cmd = el(
|
||||
'span',
|
||||
'story-out-cmd',
|
||||
`${ev.tool === 'Bash' ? '$ ' : `${ev.tool} `}${this._storyShortPath(ev.label)}`
|
||||
);
|
||||
if (ev.label !== this._storyShortPath(ev.label)) cmd.title = ev.label;
|
||||
const status = el(
|
||||
'span',
|
||||
`story-tag story-tag--${ev.ok === false ? 'bad' : ev.pending ? 'pending' : 'ok'}`,
|
||||
@@ -2937,7 +2966,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
} 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:')) {
|
||||
const b = el('button', 'story-btn-s', ev.edit ? 'See the change' : ev.file ? 'Open file' : 'Open');
|
||||
const b = el(
|
||||
'button',
|
||||
'story-btn-s',
|
||||
ev.edit || ev.edits?.length ? 'See the change' : ev.file ? 'Open file' : 'Open'
|
||||
);
|
||||
b.type = 'button';
|
||||
b.dataset.storyOpen = target;
|
||||
section.append(b);
|
||||
@@ -2974,8 +3007,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
b.className = 'story-link';
|
||||
b.textContent = this._storyShortPath(link.path);
|
||||
if (b.textContent !== link.path) b.title = link.path;
|
||||
const resolved =
|
||||
typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(link.path, workingDir) : link.path;
|
||||
// A bare name listed under an `ls` header (`some/dir:`) lives in that directory.
|
||||
let target = link.path;
|
||||
if (!target.includes('/')) {
|
||||
const block =
|
||||
value
|
||||
.slice(0, link.index)
|
||||
.split(/\n\s*\n/)
|
||||
.pop() || '';
|
||||
const header = /(?:^|\n)([^\s:][^\n:]*):[ \t]*\n(?![\s\S]*\n[^\s:][^\n:]*:[ \t]*\n)/.exec(block);
|
||||
if (header) target = `${header[1].replace(/\/+$/, '')}/${target}`;
|
||||
}
|
||||
const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(target, workingDir) : target;
|
||||
b.addEventListener('click', () => this.openFilePreview?.(resolved, this._story.sessionId));
|
||||
pre.append(b);
|
||||
last = link.index + link.path.length;
|
||||
|
||||
@@ -515,6 +515,70 @@ describe('story-mode.js overlay', () => {
|
||||
expect($('.story-panel[data-kind="investigate"] .story-hit')!.title).toBe('Open rename_by_date.sh');
|
||||
});
|
||||
|
||||
it('files written through a here-document open as a diff from the wire, with short paths', async () => {
|
||||
const lines = [
|
||||
prompt('write'),
|
||||
...bash('b1', "cat > app.js <<'EOF'\nconst a = 1;\nEOF\nnode --test", 'Write and test', '# pass 1\n# fail 0'),
|
||||
end(1000),
|
||||
];
|
||||
(lines[1] as { cwd?: string }).cwd = '/work/demo-shop';
|
||||
boot(storyOf(lines));
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
app._storySetDirector(true);
|
||||
const link = $$('.story-wire .story-link').find((l) => l.dataset.storyOpen?.startsWith('diff:'))!;
|
||||
link.click();
|
||||
expect($('.story-drawer-kind')!.textContent).toBe('Diff');
|
||||
expect($('.story-drawer-title')!.textContent).toBe('app.js');
|
||||
expect($('.story-df-path')!.textContent).toBe('app.js');
|
||||
expect($('.story-df-path')!.title).toBe('/work/demo-shop/app.js');
|
||||
expect($$('.story-ln--add').map((l) => l.textContent)).toEqual(['1+const a = 1;']);
|
||||
});
|
||||
|
||||
it('while a question waits, the pencils say so; the chosen answer is marked, not struck', async () => {
|
||||
const ask = (answered: boolean) =>
|
||||
storyOf([
|
||||
prompt('pick'),
|
||||
tool('q1', 'AskUserQuestion', {
|
||||
questions: [{ question: 'Which name?', options: [{ label: 'slugify' }, { label: 'kebab' }] }],
|
||||
}),
|
||||
...(answered ? [result('q1', 'kebab', { toolUseResult: { answers: { 'Which name?': 'kebab' } } })] : []),
|
||||
]);
|
||||
boot(ask(false));
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
expect($('.story-pencils small')!.textContent).toBe('Waiting for your answer…');
|
||||
responses = [{ status: 200, body: { success: true, data: ask(true) } }];
|
||||
await app._storyRefresh();
|
||||
await settle();
|
||||
sizeAndDraw();
|
||||
$('.story-panel[data-kind="ask"] .story-hit')!.click();
|
||||
const chosen = $('.story-drawer-body .is-chosen')!;
|
||||
expect(chosen.textContent).toBe('kebab');
|
||||
expect($('.story-drawer-body .is-done')).toBeNull();
|
||||
expect($('.story-pencils small')!.textContent).toBe('Claude is working…');
|
||||
});
|
||||
|
||||
it('a bare name under an ls header links into that folder; the session folder reads as "."', async () => {
|
||||
boot(
|
||||
storyOf([
|
||||
prompt('look'),
|
||||
...bash('b1', 'ls /work/demo-shop && ls -R /work/demo-shop/clone', 'List the clone', 'clone/src:\nkebab.py\n\nnotes.md'),
|
||||
end(1000),
|
||||
])
|
||||
);
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
sizeAndDraw();
|
||||
$('.story-panel[data-kind="investigate"] .story-hit')!.click();
|
||||
expect($('.story-out-cmd')!.textContent).toBe('$ ls . && ls -R clone');
|
||||
const links = $$('.story-drawer-body .story-link');
|
||||
links.find((l) => l.textContent === 'kebab.py')!.click();
|
||||
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/clone/src/kebab.py', SID);
|
||||
links.find((l) => l.textContent === 'notes.md')!.click();
|
||||
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/notes.md', SID);
|
||||
});
|
||||
|
||||
it('turning the setting off hides the button and closes an open story', async () => {
|
||||
boot(storyOf(demoTurn()));
|
||||
app.openStoryMode(SID);
|
||||
|
||||
+108
-3
@@ -11,6 +11,7 @@ import {
|
||||
createStoryReducer,
|
||||
exactDuration,
|
||||
firstSentence,
|
||||
heredocWrites,
|
||||
parseTestCounts,
|
||||
planFromText,
|
||||
printedFiles,
|
||||
@@ -110,8 +111,8 @@ describe('parseTestCounts beyond "N passed"', () => {
|
||||
['# tests 7\n# suites 0\n# pass 7\n# fail 0', { passed: 7, failed: 0 }],
|
||||
['ℹ tests 3\nℹ pass 2\nℹ fail 1', { passed: 2, failed: 1 }],
|
||||
['FAILED test_a.py::test_x\n===== 3 failed in 0.12s =====', { passed: 0, failed: 3 }],
|
||||
['==== 1 error in 0.05s ====', { passed: 0, failed: 1 }],
|
||||
['=== 1 failed, 18 passed, 2 errors in 0.40s ===', { passed: 18, failed: 3 }],
|
||||
['==== 1 error in 0.05s ====', { passed: 0, failed: 0, errors: 1 }],
|
||||
['=== 1 failed, 18 passed, 2 errors in 0.40s ===', { passed: 18, failed: 1, errors: 2 }],
|
||||
[' Tests 3 failed (3)', { passed: 0, failed: 3 }],
|
||||
])('%j', (output, counts) => {
|
||||
expect(parseTestCounts(output)).toEqual(counts);
|
||||
@@ -614,13 +615,117 @@ describe('buildStoryPage', () => {
|
||||
...bash('b2', 'git push -u origin main && git -C remote.git log', 'Push', 'fatal: bad object', true),
|
||||
end(1000),
|
||||
]);
|
||||
expect(page.events[1]).toMatchObject({ ok: false, tests: { passed: 0, failed: 1 } });
|
||||
expect(page.events[1]).toMatchObject({ ok: false, tests: { passed: 0, failed: 0, errors: 1 } });
|
||||
const check = page.panels.find((p) => p.kind === 'check')!;
|
||||
expect(check.caption.story).toBe("The tests don't even load.");
|
||||
expect(check.caption.director).toContain('0 passed, 1 error');
|
||||
const save = page.panels.find((p) => p.kind === 'save')!;
|
||||
expect(save.ok).toBe(false);
|
||||
expect(save.caption.story).toBe('Claude tries to send the work up, and the command fails.');
|
||||
expect(save.caption.director).toContain('1 error');
|
||||
});
|
||||
|
||||
it('files written through a here-document are edits: on the event, in the counts, out of the label', () => {
|
||||
const command = [
|
||||
"cat > index.js <<'EOF'",
|
||||
'export const add = (a, b) => a + b;',
|
||||
'test(1 > 2)',
|
||||
'EOF',
|
||||
'cat <<EOF > /tmp/scratch.txt',
|
||||
'scratch',
|
||||
'EOF',
|
||||
'node --test && git add -A && git commit -qm "feat: add"',
|
||||
].join('\n');
|
||||
const lines = [prompt('write it'), ...bash('b1', command, 'Write, test, commit', '# pass 3\n# fail 0'), end(1000)];
|
||||
(lines[1] as { cwd?: string }).cwd = '/work/app';
|
||||
const page = pageOf(lines);
|
||||
const ev = page.events[1];
|
||||
expect(ev.intent).toBe('test');
|
||||
expect(ev.edits?.map((d) => [d.file, d.added])).toEqual([['/work/app/index.js', 2]]);
|
||||
expect(ev.edits?.[0].hunks[0].lines).toEqual(['+export const add = (a, b) => a + b;', '+test(1 > 2)']);
|
||||
expect(ev.label).not.toContain('export const add');
|
||||
expect(ev.headline).toBe('node --test');
|
||||
expect(page.filesChanged).toBe(1);
|
||||
const check = page.panels.find((p) => p.kind === 'check')!;
|
||||
expect(check.caption.director).toMatch(/^node --test → ok · 3 passed · writes 1 file$/);
|
||||
});
|
||||
|
||||
it('heredocWrites skips scratch, stdin bodies and relative paths after a cd', () => {
|
||||
expect(heredocWrites("python3 - <<'EOF'\nprint(1)\nEOF", '/w')).toEqual([]);
|
||||
expect(heredocWrites('cat > "$S/x.txt" <<EOF\nx\nEOF', '/w')).toEqual([]);
|
||||
expect(heredocWrites('cd sub && cat > a.txt <<EOF\nx\nEOF', '/w').map((d) => d.file)).toEqual(['a.txt']);
|
||||
expect(heredocWrites('tee -a notes.md <<EOF\nx\nEOF', '/w').map((d) => d.file)).toEqual(['/w/notes.md']);
|
||||
});
|
||||
|
||||
it('checks that all pass from the start read as building, not fixing', () => {
|
||||
const page = pageOf([
|
||||
prompt('build it'),
|
||||
...edit('e1', '/w/a.py', ['+a = 1']),
|
||||
...bash('b1', 'pytest -q', 'Run tests', '3 passed in 0.1s'),
|
||||
...edit('e2', '/w/b.py', ['+b = 1']),
|
||||
...bash('b2', 'pytest -q', 'Run tests', '6 passed in 0.1s'),
|
||||
end(1000),
|
||||
]);
|
||||
expect(page.panels.find((p) => p.kind === 'loop')!.caption.story).toBe(
|
||||
'Build and test, 2 rounds. Every check passes.'
|
||||
);
|
||||
});
|
||||
|
||||
it('the advisor (a server tool) is a helper giving a second opinion', () => {
|
||||
const page = pageOf([
|
||||
prompt('think hard'),
|
||||
{
|
||||
type: 'assistant',
|
||||
timestamp: '2026-10-18T09:41:20.000Z',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'server_tool_use', id: 'srv1', name: 'advisor', input: {} },
|
||||
{
|
||||
type: 'advisor_tool_result',
|
||||
tool_use_id: 'srv1',
|
||||
content: { type: 'advisor_redacted_result', encrypted_content: 'xx' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
end(1000),
|
||||
]);
|
||||
const backup = page.panels.find((p) => p.kind === 'backup')!;
|
||||
expect(backup.caption.story).toBe('Claude asks the advisor for a second opinion.');
|
||||
expect(page.events[1]).toMatchObject({ tool: 'Advisor', ok: true, helper: { name: 'advisor' } });
|
||||
expect(page.events[1].helper!.result).toMatch(/encrypted/);
|
||||
expect(page.helpers).toBe(0);
|
||||
});
|
||||
|
||||
it('a turn left open when the next prompt starts has ended; an interrupted turn is timed', () => {
|
||||
const turns = buildStoryTurns(
|
||||
entriesOf([
|
||||
prompt('one'),
|
||||
...bash('b', 'ls', 'List', 'a'),
|
||||
prompt('two'),
|
||||
...bash('c', 'ls', 'List', 'a'),
|
||||
{ type: 'user', timestamp: '2026-10-18T09:50:00.000Z', message: { content: '[Request interrupted by user]' } },
|
||||
])
|
||||
);
|
||||
expect(turns[0]).toMatchObject({ ended: true, interrupted: false });
|
||||
expect(turns[0].durationMs).toBeGreaterThan(0);
|
||||
expect(turns[1]).toMatchObject({ ended: true, interrupted: true });
|
||||
expect(turns[1].durationMs).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('a result remark replaces a step announcement on its check', () => {
|
||||
const page = pageOf([
|
||||
prompt('steps'),
|
||||
said('Step 1: split the module.'),
|
||||
...bash('b1', 'node --test', 'Run tests', '# pass 8\n# fail 0'),
|
||||
said('All 8 tests pass.'),
|
||||
...bash('b2', 'git commit -qm x', 'Commit', 'ok'),
|
||||
end(1000),
|
||||
]);
|
||||
expect(page.panels.find((p) => p.kind === 'check')!.balloon?.text).toBe('All 8 tests pass.');
|
||||
expect(page.panels.find((p) => p.kind === 'save')!.balloon).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a loop ends at its last check; writes after it are a panel of their own', () => {
|
||||
const page = pageOf([
|
||||
prompt('tdd'),
|
||||
|
||||
Reference in new issue
Block a user