Files
Codeman/src/web/response-viewer-transcript.ts
T
Codeman maintainer d170ad2295 fix(#592): a rule inside a Copilot answer stays part of it in agent read
The pane segmenter ended the response block at every full-width rule, so
an answer that draws one (a markdown `---`, a passage quoted between two
rules) read as only its last section: `Summary / ──── / Details` came
back as "Details", and the existing test pinned that by expecting the
quoted passage without its "Here it is:" lead-in.

Inside an open response a rule is now held and written into the text as
`---` once more of the same answer follows, so a rule that only separates
the answer from the footer never trails it. The composer's own two rules
(the last rule/❯/rule triple) are still boundaries. The old test now
expects both parts, and a new one pins a Summary/rule/Details answer as
one block; both fail without the change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 08:07:11 +02:00

555 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export type ResponseViewerTranscriptKind = 'prompt' | 'response' | 'status' | 'tool';
export interface ResponseViewerTranscriptBlock {
kind: ResponseViewerTranscriptKind;
label: 'Prompt' | 'Response' | 'Status' | 'Tool';
/** What the frontend renders by: 'user' gets the "You" badge, everything else the agent badge. */
role: 'user' | 'assistant';
text: string;
}
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
// would drag node-pty and the whole session layer into this pure module, so the
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'copilot']);
/**
* A pane that is not Codex-shaped (`›` prompt, `•` bullets) describes its own dialect as data, so
* a new CLI adds a row here instead of another branch in the Codex-oriented loop below.
*/
interface PaneDialect {
/** Group 1 is the prompt text. A bare composer glyph with nothing after it must NOT match. */
prompt: RegExp;
/** A right-aligned timestamp the TUI appends to a submitted prompt. */
promptTrailer?: RegExp;
/** The glyph that opens an answer. */
responseBullet: RegExp;
/** Chrome that can sit between turns: banner notes, the cwd/credits row, the footer, spinners. */
status: RegExp[];
/** Rows that carry nothing (the empty composer), dropped and treated as a block boundary. */
ignore: RegExp[];
/**
* The composer row, the one between two rules at the bottom of the screen. Only the LAST rule,
* composer row, rule triple counts: an answer can hold a rule-delimited paragraph or a quoted
* prompt of its own, and that is answer text.
*/
composer: RegExp;
/** Furniture drawn at the end of a row (a scrollbar gutter), removed before anything else reads it. */
lineTrailer?: RegExp;
/**
* Text that is on screen only before the first prompt has scrolled the screen: the banner, a
* trust dialog. Its presence is how "nothing has been asked yet" is told apart from "the prompt
* row has scrolled off the top".
*/
startup: RegExp[];
}
const PANE_DIALECTS: Record<string, PaneDialect> = {
// GitHub Copilot CLI, measured on 1.0.95 inside tmux: ` ❯ <prompt> HH:MM`, then ` ● <answer>`
// with 3-space continuation lines, a cwd + `Session: N AIC used` row, the composer between two
// rules, and a `← open sidebar · … · / commands · ? help` footer. Tool-call rows are not
// modelled: they open with the same bullet and read as part of the answer.
copilot: {
// Copilot draws a submitted prompt at column 0 or 1; the continuation rows of an answer are
// indented three spaces, so a shell prompt inside a code block (` ❯ npm test`) is not a prompt.
prompt: /^ ?❯\s+(\S.*)$/,
promptTrailer: /\s{2,}\d{1,2}:\d{2}\s*$/,
responseBullet: /^\s*●\s+/,
status: [
/^\s*(?:!|●)\s+(?:Tip:|MCP Servers?\b|Model\b)/,
/^\s*!\s+/,
/\bSession: .*(?:AIC used|credits unavailable)/,
/^\s*←\s+open sidebar/,
/\/ commands · \? help/,
/^\s*[◉◎]\s+Working\b/,
],
ignore: [/^ ?❯\s*$/],
composer: /^ ?❯/,
// The right-hand scrollbar gutter, `┃` on a row (or alone on a blank one).
lineTrailer: /\s*[┃▐]\s*$/,
startup: [/Copilot v\d+\.\d+\.\d+ uses AI/, /Confirm folder trust/, /Do you trust the files in this folder/],
},
};
function isPromptLine(line: string): boolean {
return /^\s*›\s*/.test(line);
}
function isDividerDashChar(ch: string): boolean {
return ch === '─' || ch === '-';
}
function isWhitespaceChar(ch: string): boolean {
return /\s/.test(ch);
}
// Linear-time equivalent of the old /^[─-]+\s*(.+?)\s*[─-]{3,}$/. The lazy
// middle of that pattern backtracked catastrophically on a long dash run that
// does NOT end in 3+ dashes (measured >2min at 8,000 chars) — and pane text is
// agent-controlled with buffers up to 32MB, so this ran on hostile input. Same
// accept set and same captured content, computed with counters; equivalence is
// pinned char-for-char against the old regex by the brute-force corpus test in
// test/response-viewer-transcript.test.ts.
function normalizeDividerStatusLine(line: string): string | null {
const s = line.trim();
const n = s.length;
// Minimum match: 1 leading dash + 1 content char + 3 trailing dashes.
if (n < 5) return null;
let lead = 0;
while (lead < n && isDividerDashChar(s.charAt(lead))) lead += 1;
if (lead === 0) return null;
let trail = 0;
while (trail < n && isDividerDashChar(s.charAt(n - 1 - trail))) trail += 1;
if (trail < 3) return null;
// The regex was greedy on the leading run but gave dashes back until at least
// one content char plus the 3-dash tail fit (an all-dash line matched with a
// single leftover dash as its "content"), so the content window starts at the
// end of the leading run, clamped to leave 4 chars.
const contentStart = Math.min(lead, n - 4);
let ws = 0;
while (contentStart + ws < n - 4 && isWhitespaceChar(s.charAt(contentStart + ws))) ws += 1;
const from = contentStart + ws;
// The lazy middle stopped at the first position from which "optional
// whitespace, then dashes to end-of-line" matches: the start of the
// whitespace padding in front of the trailing dash run (never before the
// first content char).
const tailStart = n - trail;
let padded = tailStart;
while (padded > 0 && isWhitespaceChar(s.charAt(padded - 1))) padded -= 1;
const end = Math.max(from + 1, padded);
return s.slice(from, end).trim() || null;
}
function isDividerOnlyLine(line: string): boolean {
return /^[\s─-]{8,}$/.test(line.trim());
}
// COD-227: unambiguous Codex tool-call markers. These only ever appear as internal
// activity, never as ordinary assistant prose, so they are always a Tool block.
function isToolActivityMarker(line: string): boolean {
return /^\s*[•*-]\s+(Calling|Called)\b/.test(line.trim());
}
// COD-227: action verbs that ALSO occur in ordinary assistant prose (e.g.
// "• Created COD-226: …"). These are a Tool header only when corroborated by a
// box-drawing result tree on the next non-blank line (see the caller); the verb
// alone is not sufficient.
function isToolVerbBullet(line: string): boolean {
return /^\s*[•*-]\s+(Explored|Viewed|Read|Edited|Updated|Created|Deleted|Ran|Searched|Opened|Listed|Found|Applied|Patched|Used|Wrote|Executed|Modified|Analyzed|Compared|Fetched|Installed)\b/.test(
line.trim()
);
}
// A genuine Codex tool block renders its result as a box-drawing tree (└ │ ├).
function isBoxDrawingLine(line: string): boolean {
return /^[│├└]/.test(line.trim());
}
// Look past blank lines from `fromIndex + 1` for the next non-blank line and report
// whether it is a box-drawing tool-result line — the signal that a verb bullet is a
// real tool block rather than assistant prose that happens to start with a verb.
function nextNonBlankIsBoxDrawing(lines: string[], fromIndex: number): boolean {
for (let j = fromIndex + 1; j < lines.length; j += 1) {
const trimmed = (lines[j] || '').trim();
if (!trimmed) continue;
return isBoxDrawingLine(trimmed);
}
return false;
}
function isToolContinuationLine(line: string, currentKind: ResponseViewerTranscriptKind | null): boolean {
if (currentKind !== 'tool') return false;
const trimmed = line.trimEnd();
if (!trimmed) return true;
return /^\s*[│├└]/.test(trimmed) || /^\s{2,}\S/.test(line);
}
function isStatusLine(line: string, mode: string): boolean {
if (!EXTERNAL_CLI_MODES.has(mode)) return false;
const trimmed = line.trim();
if (!trimmed) return false;
if (normalizeDividerStatusLine(trimmed)) return true;
if (/^(model|directory):\s+/i.test(trimmed)) return true;
if (/\bContext\b.*\bleft\b/i.test(trimmed)) return true;
if (/\b\/model to change\b/i.test(trimmed)) return true;
if (/\bReady\b/i.test(trimmed) && /·/.test(trimmed)) return true;
if (/^(gpt|o\d|claude|gemini)\b/i.test(trimmed) && /·/.test(trimmed)) return true;
if (/^Tip:/i.test(trimmed)) return true;
if (/^\s*[•*-]\s+(Working|Thinking|Loading|Starting\b|Waiting\b)/i.test(trimmed)) return true;
return false;
}
function isStandaloneMarkdownLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) return false;
if (/^#{1,6}\s/.test(trimmed)) return true;
if (/^>\s/.test(trimmed)) return true;
if (/^(```|~~~)/.test(trimmed)) return true;
if (/^[-*+]\s/.test(trimmed)) return true;
if (/^\d+[.)]\s/.test(trimmed)) return true;
if (/^\|/.test(trimmed)) return true;
if (/^\s{4,}\S/.test(line)) return true;
return false;
}
function shouldJoinWrappedLine(previous: string, next: string): boolean {
const prev = previous.trimEnd();
const curr = next.trim();
if (!prev || !curr) return false;
if (/[.!?]$/.test(prev)) return false;
if (/[:;]$/.test(prev)) return false;
if (isStandaloneMarkdownLine(curr)) return false;
if (/^[a-z(]/.test(curr)) return true;
if (prev.length >= 72 && /^[A-Za-z0-9"'(]/.test(curr)) return true;
return false;
}
function normalizeWrappedText(lines: string[]): string {
const out: string[] = [];
let paragraph = '';
const flushParagraph = () => {
if (!paragraph) return;
out.push(paragraph);
paragraph = '';
};
for (const rawLine of lines) {
const line = rawLine.trimEnd();
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
if (out[out.length - 1] !== '') out.push('');
continue;
}
if (isStandaloneMarkdownLine(line)) {
flushParagraph();
out.push(trimmed);
continue;
}
if (!paragraph) {
paragraph = trimmed;
continue;
}
if (shouldJoinWrappedLine(paragraph, trimmed)) {
paragraph += ` ${trimmed}`;
continue;
}
flushParagraph();
paragraph = trimmed;
}
flushParagraph();
return out
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function cleanTerminalTranscript(buffer: string): string {
// Stripping ANSI/OSC/DCS escape sequences and stray C0/C1 control bytes
// legitimately requires control characters in these patterns.
/* eslint-disable no-control-regex */
return String(buffer || '')
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
.replace(/\x1b[>=<78cDEHM]/g, '')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[ \t]+$/gm, '')
.trim();
/* eslint-enable no-control-regex */
}
function trimLeadingStartup(lines: string[]): string[] {
let index = 0;
while (index < lines.length) {
const line = lines[index] || '';
const trimmed = line.trim();
if (!trimmed) {
index += 1;
continue;
}
if (/^[╭╰│─].*[╮╯│]?$/.test(trimmed)) {
index += 1;
continue;
}
if (/^>_\s*OpenAI Codex/i.test(trimmed)) {
index += 1;
continue;
}
if (/^(model|directory):\s+/i.test(trimmed)) {
index += 1;
continue;
}
break;
}
return lines.slice(index);
}
function pushBlock(
blocks: ResponseViewerTranscriptBlock[],
kind: ResponseViewerTranscriptKind | null,
lines: string[]
): void {
if (!kind || lines.length === 0) return;
const normalizedLines =
kind === 'status'
? lines.map((line) => normalizeDividerStatusLine(line) || line.trim()).filter((line) => line.length > 0)
: lines;
const text =
kind === 'tool' || kind === 'status'
? normalizedLines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
: normalizeWrappedText(normalizedLines);
if (!text) return;
const label = (kind.charAt(0).toUpperCase() + kind.slice(1)) as ResponseViewerTranscriptBlock['label'];
// The frontend's loadFullContext() renders via msg.role — a block without it
// lost the "You" badge on prompts and rendered every block as the agent.
blocks.push({ kind, label, role: kind === 'prompt' ? 'user' : 'assistant', text });
}
export function isExternalCliTranscriptMode(mode: string | null | undefined): boolean {
return EXTERNAL_CLI_MODES.has(String(mode || ''));
}
/**
* Whether this mode's pane is a repainting full-screen TUI whose output stream is absolute cursor
* moves rather than lines. The segmenter needs the SCREEN for those (tmux capture-pane), because
* stripping the escapes from the stream concatenates every row into one run of text.
*/
export function transcriptNeedsScreenCapture(mode: string | null | undefined): boolean {
return Object.prototype.hasOwnProperty.call(PANE_DIALECTS, String(mode || ''));
}
/**
* Segment a pane that has its own dialect. Everything before the first prompt is the banner (or a
* trust dialog) and is dropped, so a session that has not been asked anything answers "nothing
* yet" rather than serving its logo as a reply.
*
* The screen is all there is to read, so a long answer scrolls the prompt row off the top. When no
* prompt row is on screen AND no startup text is either, the session has already been asked
* something: the rows above the composer are the tail of the last answer, not "nothing yet".
*/
function parseDialectTranscript(cleaned: string, dialect: PaneDialect): ResponseViewerTranscriptBlock[] {
const blocks: ResponseViewerTranscriptBlock[] = [];
let kind: ResponseViewerTranscriptKind | null = null;
let current: string[] = [];
const rows = cleaned.split('\n').map((row) => (dialect.lineTrailer ? row.replace(dialect.lineTrailer, '') : row));
// The composer sits between two rules and holds whatever is typed but not sent. That is not a
// prompt row: otherwise typing under a tall answer would make the answer's tail read as banner.
let composerIndex = -1;
for (let i = rows.length - 2; i > 0; i -= 1) {
if (
dialect.composer.test(rows[i] ?? '') &&
isDividerOnlyLine(rows[i - 1] ?? '') &&
isDividerOnlyLine(rows[i + 1] ?? '')
) {
composerIndex = i;
break;
}
}
const isComposerRow = (i: number) => i === composerIndex;
const isComposerRule = (i: number) => composerIndex !== -1 && (i === composerIndex - 1 || i === composerIndex + 1);
const hasPrompt = rows.some((row, i) => dialect.prompt.test(row) && !isComposerRow(i));
const startupVisible = rows.some((row) => dialect.startup.some((re) => re.test(row)));
let sawPrompt = !hasPrompt && !startupVisible;
// A rule drawn INSIDE an answer (a markdown `---`, a quoted passage between two rules) is part
// of the answer, so it must not end the block, or `agent read` returns only the text after the
// last rule. It is held until more of the same answer follows, so a rule that only separates
// the answer from the footer never trails it. The composer's own two rules stay boundaries.
let pendingRule = false;
const flush = () => {
if (sawPrompt) pushBlock(blocks, kind, current);
kind = null;
current = [];
pendingRule = false;
};
for (const [index, line] of rows.entries()) {
if (kind === 'response' && isDividerOnlyLine(line) && !isComposerRule(index)) {
pendingRule = true;
continue;
}
if (isDividerOnlyLine(line) || isComposerRow(index) || dialect.ignore.some((re) => re.test(line))) {
flush();
continue;
}
const prompt = dialect.prompt.exec(line);
if (prompt) {
flush();
sawPrompt = true;
kind = 'prompt';
current = [dialect.promptTrailer ? prompt[1].replace(dialect.promptTrailer, '').trim() : prompt[1].trim()];
continue;
}
if (!sawPrompt) continue;
if (dialect.status.some((re) => re.test(line))) {
if (kind !== 'status') flush();
kind = 'status';
current.push(line.trim());
continue;
}
// A wrapped warning or tip: indented under its status row.
if (kind === 'status' && /^\s{3,}\S/.test(line)) {
current.push(line.trim());
continue;
}
if (!line.trim()) {
if (kind) current.push('');
continue;
}
const bullet = dialect.responseBullet.exec(line);
if (bullet) {
flush();
kind = 'response';
current = [line.slice(bullet[0].length)];
continue;
}
if (!kind) kind = 'response';
if (pendingRule) {
current.push('', '---', '');
pendingRule = false;
}
current.push(line);
}
flush();
return blocks.filter((block) => block.text.trim().length > 0);
}
export function parseExternalCliTranscript(
buffer: string,
mode: string | null | undefined
): ResponseViewerTranscriptBlock[] {
const resolvedMode = String(mode || '');
if (!isExternalCliTranscriptMode(resolvedMode)) return [];
const cleaned = cleanTerminalTranscript(buffer);
if (!cleaned) return [];
const dialect = PANE_DIALECTS[resolvedMode];
if (dialect) return parseDialectTranscript(cleaned, dialect);
const lines = trimLeadingStartup(cleaned.split('\n'));
const blocks: ResponseViewerTranscriptBlock[] = [];
let currentKind: ResponseViewerTranscriptKind | null = null;
let currentLines: string[] = [];
const flush = () => {
pushBlock(blocks, currentKind, currentLines);
currentKind = null;
currentLines = [];
};
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i] ?? '';
// COD-226: within a prompt, the 2-space Codex gutter is authoritative. Blank
// lines and gutter-indented (2+ leading spaces) continuation lines stay in the
// Prompt block ahead of every structural detector below, so multiline prompts —
// including bullets, indented dividers, and literal › lines — are not
// misclassified as responses. Only a non-blank, non-gutter line ends the prompt
// and falls through (a column-0 › then opens a NEW prompt).
if (currentKind === 'prompt') {
if (!line.trim()) {
currentLines.push('');
continue;
}
if (/^ {2,}\S/.test(line)) {
currentLines.push(line);
continue;
}
}
if (isDividerOnlyLine(line)) {
flush();
continue;
}
if (isPromptLine(line)) {
flush();
currentKind = 'prompt';
currentLines = [line.replace(/^\s*›\s*/, '').trim()];
continue;
}
// COD-227: • Calling / • Called are always tool markers; the other action verbs
// are a tool header only when a box-drawing result tree follows on the next
// non-blank line — otherwise the verb bullet is ordinary assistant prose.
if (isToolActivityMarker(line) || (isToolVerbBullet(line) && nextNonBlankIsBoxDrawing(lines, i))) {
if (currentKind !== 'tool') flush();
currentKind = 'tool';
currentLines.push(line.trimEnd());
continue;
}
if (isToolContinuationLine(line, currentKind)) {
currentLines.push(line.trimEnd());
continue;
}
if (isStatusLine(line, resolvedMode)) {
if (currentKind !== 'status') flush();
currentKind = 'status';
currentLines.push(line.trim());
continue;
}
if (!line.trim()) {
currentLines.push('');
continue;
}
if (currentKind !== 'response') flush();
currentKind = 'response';
currentLines.push(line);
}
flush();
return blocks.filter((block) => block.text.trim().length > 0);
}
export function getLastTranscriptResponse(blocks: ResponseViewerTranscriptBlock[]): string {
for (let i = blocks.length - 1; i >= 0; i -= 1) {
if (blocks[i]?.kind === 'response') return blocks[i].text;
}
return '';
}
/**
* The messages of the most recent turn that has an answer: every assistant
* message whose `turn` matches the highest turn any assistant message carries.
*
* This is what the viewer's brief ("Last Response") view renders for Claude.
* The brief `text` is one row — the last assistant row — and a Claude turn is
* a median of 3 rows (p90 11), so that row alone was usually the tail of the
* answer ("Done.") with the substance in the rows before it. Reading the whole
* turn gives the same cards the full view shows for it, and no more.
*
* Deliberately NOT "everything after the last user message": a prompt queued
* while the agent works opens a new, still-unanswered turn, and the honest
* brief view is then the previous, answered one — exactly the row `text`
* already points at. Messages without a numeric `turn` (Codex, the pane
* parser, an older reader) yield an empty list so callers fall back to `text`.
*/
export function selectLastAnsweredTurn<T extends { role: string; turn?: number }>(messages: T[]): T[] {
let latest = -1;
for (const message of messages) {
if (message.role === 'assistant' && typeof message.turn === 'number' && message.turn > latest) {
latest = message.turn;
}
}
if (latest < 0) return [];
return messages.filter((message) => message.role === 'assistant' && message.turn === latest);
}