2026-08-20 09:24:37 -04:00
|
|
|
|
export type ResponseViewerTranscriptKind = 'prompt' | 'response' | 'status' | 'tool';
|
|
|
|
|
|
|
|
|
|
|
|
export interface ResponseViewerTranscriptBlock {
|
|
|
|
|
|
kind: ResponseViewerTranscriptKind;
|
|
|
|
|
|
label: 'Prompt' | 'Response' | 'Status' | 'Tool';
|
2026-08-21 02:21:19 +02:00
|
|
|
|
/** What the frontend renders by: 'user' gets the "You" badge, everything else the agent badge. */
|
|
|
|
|
|
role: 'user' | 'assistant';
|
2026-08-20 09:24:37 -04:00
|
|
|
|
text: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 02:21:19 +02:00
|
|
|
|
// 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.
|
2026-10-11 11:24:21 +08:00
|
|
|
|
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[];
|
2026-10-17 04:40:58 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
2026-10-11 11:24:21 +08:00
|
|
|
|
/** Furniture drawn at the end of a row (a scrollbar gutter), removed before anything else reads it. */
|
|
|
|
|
|
lineTrailer?: RegExp;
|
2026-10-11 14:02:21 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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[];
|
2026-10-11 11:24:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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: {
|
2026-10-17 04:40:58 +08:00
|
|
|
|
// 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.*)$/,
|
2026-10-11 11:24:21 +08:00
|
|
|
|
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/,
|
|
|
|
|
|
],
|
2026-10-17 04:40:58 +08:00
|
|
|
|
ignore: [/^ ?❯\s*$/],
|
|
|
|
|
|
composer: /^ ?❯/,
|
2026-10-11 11:24:21 +08:00
|
|
|
|
// The right-hand scrollbar gutter, `┃` on a row (or alone on a blank one).
|
|
|
|
|
|
lineTrailer: /\s*[┃▐]\s*$/,
|
2026-10-11 14:02:21 +08:00
|
|
|
|
startup: [/Copilot v\d+\.\d+\.\d+ uses AI/, /Confirm folder trust/, /Do you trust the files in this folder/],
|
2026-10-11 11:24:21 +08:00
|
|
|
|
},
|
|
|
|
|
|
};
|
2026-08-20 09:24:37 -04:00
|
|
|
|
|
|
|
|
|
|
function isPromptLine(line: string): boolean {
|
|
|
|
|
|
return /^\s*›\s*/.test(line);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 02:21:19 +02:00
|
|
|
|
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.
|
2026-08-20 09:24:37 -04:00
|
|
|
|
function normalizeDividerStatusLine(line: string): string | null {
|
2026-08-21 02:21:19 +02:00
|
|
|
|
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;
|
2026-08-20 09:24:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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'];
|
2026-08-21 02:21:19 +02:00
|
|
|
|
// 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 });
|
2026-08-20 09:24:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isExternalCliTranscriptMode(mode: string | null | undefined): boolean {
|
|
|
|
|
|
return EXTERNAL_CLI_MODES.has(String(mode || ''));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 11:24:21 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
2026-10-11 14:02:21 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 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".
|
2026-10-11 11:24:21 +08:00
|
|
|
|
*/
|
|
|
|
|
|
function parseDialectTranscript(cleaned: string, dialect: PaneDialect): ResponseViewerTranscriptBlock[] {
|
|
|
|
|
|
const blocks: ResponseViewerTranscriptBlock[] = [];
|
|
|
|
|
|
let kind: ResponseViewerTranscriptKind | null = null;
|
|
|
|
|
|
let current: string[] = [];
|
2026-10-11 14:02:21 +08:00
|
|
|
|
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.
|
2026-10-17 04:40:58 +08:00
|
|
|
|
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;
|
2026-10-18 08:07:11 +02:00
|
|
|
|
const isComposerRule = (i: number) => composerIndex !== -1 && (i === composerIndex - 1 || i === composerIndex + 1);
|
2026-10-11 14:02:21 +08:00
|
|
|
|
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;
|
2026-10-18 08:07:11 +02:00
|
|
|
|
// 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;
|
2026-10-11 11:24:21 +08:00
|
|
|
|
|
|
|
|
|
|
const flush = () => {
|
|
|
|
|
|
if (sawPrompt) pushBlock(blocks, kind, current);
|
|
|
|
|
|
kind = null;
|
|
|
|
|
|
current = [];
|
2026-10-18 08:07:11 +02:00
|
|
|
|
pendingRule = false;
|
2026-10-11 11:24:21 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-11 14:02:21 +08:00
|
|
|
|
for (const [index, line] of rows.entries()) {
|
2026-10-18 08:07:11 +02:00
|
|
|
|
if (kind === 'response' && isDividerOnlyLine(line) && !isComposerRule(index)) {
|
|
|
|
|
|
pendingRule = true;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-11 14:02:21 +08:00
|
|
|
|
if (isDividerOnlyLine(line) || isComposerRow(index) || dialect.ignore.some((re) => re.test(line))) {
|
2026-10-11 11:24:21 +08:00
|
|
|
|
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';
|
2026-10-18 08:07:11 +02:00
|
|
|
|
if (pendingRule) {
|
|
|
|
|
|
current.push('', '---', '');
|
|
|
|
|
|
pendingRule = false;
|
|
|
|
|
|
}
|
2026-10-11 11:24:21 +08:00
|
|
|
|
current.push(line);
|
|
|
|
|
|
}
|
|
|
|
|
|
flush();
|
|
|
|
|
|
return blocks.filter((block) => block.text.trim().length > 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-20 09:24:37 -04:00
|
|
|
|
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 [];
|
|
|
|
|
|
|
2026-10-11 11:24:21 +08:00
|
|
|
|
const dialect = PANE_DIALECTS[resolvedMode];
|
|
|
|
|
|
if (dialect) return parseDialectTranscript(cleaned, dialect);
|
|
|
|
|
|
|
2026-08-20 09:24:37 -04:00
|
|
|
|
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 '';
|
|
|
|
|
|
}
|
2026-09-10 12:45:16 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|