fix(terminal): detect PR links with ANSI resets (#8216)

* fix(terminal): detect PR links with ANSI resets (#8126)

* fix(terminal): safely normalize PR link styling

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Rod Boev
2026-07-10 23:41:20 -07:00
committed by GitHub
co-authored by Jinjing
parent 588dba6354
commit 04a040801b
2 changed files with 79 additions and 4 deletions
@@ -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()
+18 -4
View File
@@ -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<string>()
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
}
}