/* eslint-disable no-control-regex -- ANSI SGR sequences are raw PTY input. */ /** * Chunk-boundary-safe GitHub PR URL scan over PTY output. * * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit * `pr-link` facts from its per-PTY tracker for local/SSH PTYs, while the * renderer keeps byte-scanning for remote-runtime PTYs and the kill-switch-off * path. Both paths must share the carry/dedupe semantics or links split across * chunks would resolve differently per authority mode. */ 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 const MAX_TERMINAL_GITHUB_PR_URL_LENGTH = 2048 export type TerminalGitHubPRLink = { url: string slug: RepoSlug number: number } function trimTerminalUrl(candidate: string): string { return candidate.replace(TRAILING_TERMINAL_PUNCTUATION_RE, '') } 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') { return null } return { url, slug: parsed.slug, number: parsed.number } } function endsWithHttpSchemePrefixFragment(value: string): string { for (const prefix of HTTP_SCHEME_PREFIXES) { for (let length = Math.min(prefix.length - 1, value.length); length > 0; length--) { if (value.endsWith(prefix.slice(0, length))) { return value.slice(value.length - length) } } } return '' } function lastIndexOfHttpScheme(value: string, fromIndex?: number): number { let lastIndex = -1 for (const prefix of HTTP_SCHEME_PREFIXES) { const candidate = fromIndex === undefined ? value.lastIndexOf(prefix) : value.lastIndexOf(prefix, fromIndex) if (candidate > lastIndex) { lastIndex = candidate } } return lastIndex } function getPotentialGitHubPRCarry(value: string): string { // Why bounded: carry is always a suffix of at most MAX_CARRY_LENGTH, so a scheme // further back can only ever be dropped — scanning to it is O(chunk) per PTY write. const windowStart = value.length > MAX_CARRY_LENGTH ? value.length - MAX_CARRY_LENGTH : 0 const tailWindow = windowStart === 0 ? value : value.slice(windowStart) const schemeIndexInWindow = lastIndexOfHttpScheme(tailWindow) if (schemeIndexInWindow !== -1) { const schemeIndex = windowStart + schemeIndexInWindow return hasTerminalUrlWhitespace(value, schemeIndex, value.length) ? '' : value.slice(schemeIndex) } const fragment = endsWithHttpSchemePrefixFragment(tailWindow) if (fragment === '' || windowStart === 0) { return fragment } // Why look behind: an older scheme means the URL already overran the cap, so the // carry is abandoned rather than restarted from this fragment. return lastIndexOfHttpScheme(value, windowStart - 1) === -1 ? fragment : '' } function hasTerminalUrlWhitespace(value: string, start: number, end: number): boolean { for (let index = start; index < end; index += 1) { if (/\s/.test(value.charAt(index))) { return true } } return false } type TerminalUrlCandidate = { rawUrl: string endIndex: number } function findNextHttpSchemeIndex(value: string, start: number): number { let nextIndex = -1 for (const prefix of HTTP_SCHEME_PREFIXES) { const candidate = value.indexOf(prefix, start) if (candidate !== -1 && (nextIndex === -1 || candidate < nextIndex)) { nextIndex = candidate } } return nextIndex } function isTerminalUrlTerminator(char: string): boolean { return char === '"' || char === "'" || char === '<' || char === '>' || /\s/.test(char) } function findTerminalUrlCandidateEnd(value: string, start: number): number { const scanEnd = Math.min(value.length, start + MAX_TERMINAL_GITHUB_PR_URL_LENGTH + 1) for (let index = start; index < scanEnd; index += 1) { if (isTerminalUrlTerminator(value.charAt(index))) { return index } } return scanEnd } function* iterateTerminalUrlCandidates(value: string): Generator { let searchStart = 0 while (searchStart < value.length) { const candidateStart = findNextHttpSchemeIndex(value, searchStart) if (candidateStart === -1) { return } const candidateEnd = findTerminalUrlCandidateEnd(value, candidateStart) const rawUrl = value.slice(candidateStart, candidateEnd) searchStart = Math.max(candidateEnd, candidateStart + 1) if ( rawUrl.length > MAX_TERMINAL_GITHUB_PR_URL_LENGTH || !rawUrl.includes(GITHUB_PR_PATH_MARKER) ) { continue } yield { rawUrl, endIndex: candidateEnd } } } export function createTerminalGitHubPRLinkDetector(): (data: string) => TerminalGitHubPRLink[] { let carry = '' const seenUrls = new Set() return (data: string): TerminalGitHubPRLink[] => { const rawCombined = carry ? carry + data : data // 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 // instead of running a global regex across the whole chunk when it contains // a /pull/ substring. for (const { rawUrl, endIndex } of iterateTerminalUrlCandidates(combined)) { // Why: PTY chunks can split the PR number; wait for a boundary before // treating a URL at chunk-end as complete. if (endIndex === combined.length) { continue } const parsed = parseTerminalGitHubPRUrl(rawUrl) if (!parsed || seenUrls.has(parsed.url)) { continue } seenUrls.add(parsed.url) links.push(parsed) } carry = getPotentialGitHubPRCarry(rawCombined) return links } }