From e37f1f14838f7a973adac7a49e00e5d7921c1f20 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 17 Oct 2026 04:40:58 +0800 Subject: [PATCH] fix(copilot): agent read keeps shell prompts and ruled paragraphs inside an answer A submitted prompt is only a prompt at column 0 or 1, so an indented shell prompt in a code block stays answer text. Only the last rule, composer row, rule triple is the composer, so a paragraph between two rules in an answer is no longer dropped. A test for each. --- src/web/response-viewer-transcript.ts | 27 +++++++++++++--- test/response-viewer-transcript.test.ts | 41 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/web/response-viewer-transcript.ts b/src/web/response-viewer-transcript.ts index 3bc56eb1..eb63eddb 100644 --- a/src/web/response-viewer-transcript.ts +++ b/src/web/response-viewer-transcript.ts @@ -28,6 +28,12 @@ interface PaneDialect { 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; /** @@ -44,7 +50,9 @@ const PANE_DIALECTS: Record = { // 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.*)$/, + // 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: [ @@ -55,7 +63,8 @@ const PANE_DIALECTS: Record = { /\/ commands · \? help/, /^\s*[◉◎]\s+Working\b/, ], - ignore: [/^\s*❯\s*$/], + 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/], @@ -339,8 +348,18 @@ function parseDialectTranscript(cleaned: string, dialect: PaneDialect): Response 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. - const isComposerRow = (i: number) => - i > 0 && i < rows.length - 1 && isDividerOnlyLine(rows[i - 1] ?? '') && isDividerOnlyLine(rows[i + 1] ?? ''); + 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 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; diff --git a/test/response-viewer-transcript.test.ts b/test/response-viewer-transcript.test.ts index 28dc422a..f1ad4c77 100644 --- a/test/response-viewer-transcript.test.ts +++ b/test/response-viewer-transcript.test.ts @@ -514,6 +514,47 @@ describe('GitHub Copilot pane dialect', () => { expect(first.text).not.toContain('┃'); }); + // The footer and composer every screen below ends with. + const tail = [ + ' /tmp/work/.../scratch-2 Session: credits unavailable', + rule, + '❯', + rule, + ' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock', + ]; + + it("keeps a shell prompt inside an answer's code block as answer text, not a new prompt", () => { + const screen = [ + ' ❯ how do I run the tests 11:20', + ' ● Run this from the repo root:', + '', + ' ❯ npm test', + '', + ' Then read the summary line.', + ...tail, + ].join('\n'); + const blocks = parseExternalCliTranscript(screen, 'copilot'); + expect(blocks.filter((b) => b.kind === 'prompt').map((b) => b.text)).toEqual(['how do I run the tests']); + const answer = getLastTranscriptResponse(blocks); + expect(answer).toContain('Run this from the repo root:'); + expect(answer).toContain('❯ npm test'); + expect(answer).toContain('Then read the summary line.'); + }); + + it('keeps a paragraph between two rules inside an answer, and treats only the last triple as the composer', () => { + const screen = [ + ' ❯ quote the passage 11:21', + ' ● Here it is:', + rule, + ' Quoted passage between two rules.', + rule, + ...tail, + ].join('\n'); + expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe( + 'Quoted passage between two rules.' + ); + }); + 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('');