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 = { // GitHub Copilot CLI, measured on 1.0.95 inside tmux: ` ❯ HH:MM`, then ` ● ` // 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(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); }