diff --git a/src/shared/terminal-github-pr-link-detector.test.ts b/src/shared/terminal-github-pr-link-detector.test.ts index 41488aab1b4..360fd16d63e 100644 --- a/src/shared/terminal-github-pr-link-detector.test.ts +++ b/src/shared/terminal-github-pr-link-detector.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createTerminalGitHubPRLinkDetector } from './terminal-github-pr-link-detector' +const issue8126Url = 'https://github.com/owner/repo/pull/10' + afterEach(() => { vi.restoreAllMocks() }) @@ -18,6 +20,65 @@ describe('createTerminalGitHubPRLinkDetector', () => { ]) }) + it('detects issue 8126 Claude Code PR links with attached ANSI reset', () => { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe(`${issue8126Url}\x1b[22m\n`)).toEqual([ + { + url: issue8126Url, + slug: { owner: 'owner', repo: 'repo' }, + number: 10 + } + ]) + }) + + it('strips an ANSI reset split across PTY chunks', () => { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe(`${issue8126Url}\x1b`)).toEqual([]) + expect(observe('[22m\n')).toEqual([ + { + url: issue8126Url, + slug: { owner: 'owner', repo: 'repo' }, + number: 10 + } + ]) + }) + + it('rejects PR URLs corrupted by cursor movement', () => { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe('https://github.com/owne\x1b[1Cr/repo/pull/10\n')).toEqual([]) + }) + + it('rejects PR URLs fused across terminal rows', () => { + for (const cursorMove of ['\x1b[1A', '\x1b[1B']) { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe(`https://github.com/owner/repo/pull/${cursorMove}10\n`)).toEqual([]) + } + }) + + it('does not fuse screen-editing controls into PR URLs', () => { + for (const screenEdit of ['\x08', '\x0b', '\x0c', '\x1bD', '\x1b[2J', '\x1b[2K', '\x1b[1S']) { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe(`https://github.com/owner/repo/pull/1${screenEdit}0\n`)).toEqual([]) + } + }) + + it('deduplicates styled and plain instances', () => { + const observe = createTerminalGitHubPRLinkDetector() + + expect(observe(`${issue8126Url}\x1b[22m\n${issue8126Url}\n`)).toEqual([ + { + url: issue8126Url, + slug: { owner: 'owner', repo: 'repo' }, + number: 10 + } + ]) + }) + it('waits for a boundary when the URL is split across PTY chunks', () => { const observe = createTerminalGitHubPRLinkDetector() diff --git a/src/shared/terminal-github-pr-link-detector.ts b/src/shared/terminal-github-pr-link-detector.ts index ee7d92e60c6..83a9d8ff95b 100644 --- a/src/shared/terminal-github-pr-link-detector.ts +++ b/src/shared/terminal-github-pr-link-detector.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-control-regex -- ANSI SGR sequences are raw PTY input. */ /** * Chunk-boundary-safe GitHub PR URL scan over PTY output. * @@ -11,6 +12,9 @@ import type { RepoSlug } from './github-links' import { parseGitHubIssueOrPRLink } from './github-links' const GITHUB_PR_PATH_MARKER = '/pull/' +const TERMINAL_SGR_PATTERN = /\x1b\[[0-?]*[ -/]*m/g +const TERMINAL_CURSOR_CONTROL_PATTERN = /[\x08\x0b\x0c]/g +const TERMINAL_CONTROL_GUARD = '\ufffd' const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const const TRAILING_TERMINAL_PUNCTUATION_RE = /[),.;\]}]+$/ const MAX_CARRY_LENGTH = 512 @@ -27,6 +31,9 @@ function trimTerminalUrl(candidate: string): string { } function parseTerminalGitHubPRUrl(candidate: string): TerminalGitHubPRLink | null { + if (candidate.includes('\x1b') || candidate.includes(TERMINAL_CONTROL_GUARD)) { + return null + } const url = trimTerminalUrl(candidate) const parsed = parseGitHubIssueOrPRLink(url) if (!parsed || parsed.type !== 'pr') { @@ -128,12 +135,19 @@ export function createTerminalGitHubPRLinkDetector(): (data: string) => Terminal const seenUrls = new Set() return (data: string): TerminalGitHubPRLink[] => { - const combined = carry ? carry + data : data + const rawCombined = carry ? carry + data : data - if (!combined.includes(GITHUB_PR_PATH_MARKER)) { - carry = getPotentialGitHubPRCarry(combined) + // Why: PTY output is a hot path; avoid multi-pass ANSI normalization for + // chunks that cannot contain a GitHub pull-request URL. + if (!rawCombined.includes(GITHUB_PR_PATH_MARKER)) { + carry = getPotentialGitHubPRCarry(rawCombined) return [] } + // Why: SGR styling has no screen width, so removing it is safe. Cursor + // controls get a guard; other escape sequences remain URL-invalid. + const combined = rawCombined + .replace(TERMINAL_SGR_PATTERN, '') + .replace(TERMINAL_CURSOR_CONTROL_PATTERN, TERMINAL_CONTROL_GUARD) const links: TerminalGitHubPRLink[] = [] // Why: PTY data may echo a huge pasted line. Scan URL candidates directly @@ -154,7 +168,7 @@ export function createTerminalGitHubPRLinkDetector(): (data: string) => Terminal links.push(parsed) } - carry = getPotentialGitHubPRCarry(combined) + carry = getPotentialGitHubPRCarry(rawCombined) return links } }