mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39: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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user