diff --git a/src/web/response-viewer-transcript.ts b/src/web/response-viewer-transcript.ts index 40d442a5..6c3f98b7 100644 --- a/src/web/response-viewer-transcript.ts +++ b/src/web/response-viewer-transcript.ts @@ -11,7 +11,49 @@ export interface ResponseViewerTranscriptBlock { // 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']); +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[]; + /** Furniture drawn at the end of a row (a scrollbar gutter), removed before anything else reads it. */ + lineTrailer?: 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: { + prompt: /^\s*❯\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*❯\s*$/], + // The right-hand scrollbar gutter, `┃` on a row (or alone on a blank one). + lineTrailer: /\s*[┃▐]\s*$/, + }, +}; function isPromptLine(line: string): boolean { return /^\s*›\s*/.test(line); @@ -265,6 +307,80 @@ export function isExternalCliTranscriptMode(mode: string | null | undefined): bo 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. + */ +function parseDialectTranscript(cleaned: string, dialect: PaneDialect): ResponseViewerTranscriptBlock[] { + const blocks: ResponseViewerTranscriptBlock[] = []; + let kind: ResponseViewerTranscriptKind | null = null; + let current: string[] = []; + let sawPrompt = false; + + const flush = () => { + if (sawPrompt) pushBlock(blocks, kind, current); + kind = null; + current = []; + }; + + for (const rawLine of cleaned.split('\n')) { + const line = dialect.lineTrailer ? rawLine.replace(dialect.lineTrailer, '') : rawLine; + if (isDividerOnlyLine(line)) { + flush(); + continue; + } + if (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'; + current.push(line); + } + flush(); + return blocks.filter((block) => block.text.trim().length > 0); +} + export function parseExternalCliTranscript( buffer: string, mode: string | null | undefined @@ -275,6 +391,9 @@ export function parseExternalCliTranscript( 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; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 66355b35..e7df05ff 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -190,6 +190,7 @@ import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js'; import { scanOmpSessionsHistory } from '../../omp-transcript.js'; import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js'; import { + transcriptNeedsScreenCapture, getLastTranscriptResponse, isExternalCliTranscriptMode, parseExternalCliTranscript, @@ -2818,7 +2819,14 @@ export function registerSessionRoutes( // where a real rollout file is the better source. if (isExternalCliTranscriptMode(session.mode)) { const externalQuery = req.query as { context?: string }; - const blocks = parseExternalCliTranscript(session.terminalBuffer, session.mode); + // A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the + // buffer cannot be segmented; read the pane's visible screen instead, and fall back to the + // buffer when there is no tmux pane to ask (direct PTY, tests). + const screen = + transcriptNeedsScreenCapture(session.mode) && session.muxName + ? (ctx.mux.capturePaneText?.(session.muxName) ?? null) + : null; + const blocks = parseExternalCliTranscript(screen ?? session.terminalBuffer, session.mode); return { text: getLastTranscriptResponse(blocks), timestamp: '', diff --git a/test/response-viewer-transcript.test.ts b/test/response-viewer-transcript.test.ts index 68616943..b51d7993 100644 --- a/test/response-viewer-transcript.test.ts +++ b/test/response-viewer-transcript.test.ts @@ -460,3 +460,73 @@ Here is the assistant answer at column zero. }); }); }); + +describe('GitHub Copilot pane dialect', () => { + // Captured from a real Copilot 1.0.95 pane inside tmux (a throwaway COPILOT_HOME, a local + // OpenAI-compatible endpoint): banner, two turns, status rows, composer between rules, footer. + const rule = '─'.repeat(100); + const pane = [ + ' ╭─╮╭─╮', + ' ╰─╯╰─╯ Copilot v1.0.95 uses AI.', + ' █ ▘▝ █ Check for mistakes.', + ' ▔▔▔▔', + ' ! Model "mock" is not in the built-in catalog. Using defaults for: prompt tokens', + ' (COPILOT_PROVIDER_MAX_PROMPT_TOKENS), output tokens (COPILOT_PROVIDER_MAX_OUTPUT_TOKENS). Run copilot help', + ' providers for configuration details.', + ' ● Tip: /theme', + ' └ View or set color mode', + ' ❯ multi please 11:16', + ' ● First line of the answer. ┃', + ' ┃', + ' Second paragraph with bold text. ┃', + ' ┃', + ' • bullet one ┃', + ' • bullet two ┃', + ' ❯ WORKDONE followed by _4711 11:17', + ' ● WORKDONE_4711', + ' ● MCP Servers reloaded: 1 server connected', + ' /tmp/work/.../scratch-2 Session: credits unavailable', + rule, + '❯', + rule, + ' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock', + ].join('\n'); + + it('is a transcript mode', () => { + expect(isExternalCliTranscriptMode('copilot')).toBe(true); + }); + + it('returns the last answer, with no banner, status rows, composer or footer in it', () => { + const blocks = parseExternalCliTranscript(pane, 'copilot'); + expect(getLastTranscriptResponse(blocks)).toBe('WORKDONE_4711'); + }); + + it('keeps a multi-paragraph answer whole and strips the prompt timestamp', () => { + const blocks = parseExternalCliTranscript(pane, 'copilot'); + const prompts = blocks.filter((b) => b.kind === 'prompt').map((b) => b.text); + expect(prompts).toEqual(['multi please', 'WORKDONE followed by _4711']); + const first = blocks.find((b) => b.kind === 'response')!; + expect(first.text).toContain('First line of the answer.'); + expect(first.text).toContain('Second paragraph with bold text.'); + expect(first.text).toContain('• bullet two'); + expect(first.text).not.toMatch(/Session:|open sidebar|Tip:/); + // The scrollbar gutter Copilot draws at the right edge of every row never reaches the text. + expect(first.text).not.toContain('┃'); + }); + + it('answers nothing for a session that has only drawn its banner or a trust dialog', () => { + const banner = pane.split('\n').slice(0, 9).join('\n'); + expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe(''); + const trust = [ + '│ Do you trust the files in this folder?', + '│ ❯ 1. Yes', + '│ 2. Yes, and remember this folder for future sessions', + ].join('\n'); + expect(getLastTranscriptResponse(parseExternalCliTranscript(trust, 'copilot'))).toBe(''); + }); + + it('does not take the empty composer glyph for a prompt', () => { + const blocks = parseExternalCliTranscript(pane, 'copilot'); + expect(blocks.some((b) => b.kind === 'prompt' && b.text === '')).toBe(false); + }); +});