diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 1001d5bda..1444bbffe 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed wrapped OSC 8 hyperlinks to preserve BEL terminators so OAuth login URLs remain clickable on every wrapped line. + ## [0.73.0] - 2026-05-04 ### Fixed diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 59d213284..a6bcc8a5f 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -312,6 +312,42 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt return null; } +type Osc8Terminator = "\x07" | "\x1b\\"; + +interface ActiveHyperlink { + params: string; + url: string; + terminator: Osc8Terminator; +} + +function parseOsc8Hyperlink(ansiCode: string): ActiveHyperlink | null | undefined { + if (!ansiCode.startsWith("\x1b]8;")) { + return undefined; + } + + const terminator: Osc8Terminator = ansiCode.endsWith("\x07") ? "\x07" : "\x1b\\"; + const body = ansiCode.slice(4, terminator === "\x07" ? -1 : -2); + const separatorIndex = body.indexOf(";"); + if (separatorIndex === -1) { + return undefined; + } + + const params = body.slice(0, separatorIndex); + const url = body.slice(separatorIndex + 1); + if (!url) { + return null; + } + return { params, url, terminator }; +} + +function formatOsc8Hyperlink(hyperlink: ActiveHyperlink): string { + return `\x1b]8;${hyperlink.params};${hyperlink.url}${hyperlink.terminator}`; +} + +function formatOsc8Close(terminator: Osc8Terminator): string { + return `\x1b]8;;${terminator}`; +} + /** * Track active ANSI SGR codes to preserve styling across line breaks. */ @@ -327,13 +363,16 @@ class AnsiCodeTracker { private strikethrough = false; private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240" private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240" - private activeHyperlink: string | null = null; // Active OSC 8 hyperlink URL, or null + private activeHyperlink: ActiveHyperlink | null = null; process(ansiCode: string): void { - // OSC 8 hyperlink: \x1b]8;;\x1b\\ (open) or \x1b]8;;\x1b\\ (close) - if (ansiCode.startsWith("\x1b]8;")) { - const m = ansiCode.match(/^\x1b\]8;[^;]*;([^\x1b\x07]*)/); - this.activeHyperlink = m?.[1] ? m[1] : null; + // OSC 8 hyperlink: \x1b]8;;\x1b\\ (open) or \x1b]8;;\x1b\\ (close). + // Preserve the original terminator because some terminals only make BEL-terminated + // links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST + // made only the first physical line clickable in those terminals. + const hyperlink = parseOsc8Hyperlink(ansiCode); + if (hyperlink !== undefined) { + this.activeHyperlink = hyperlink; return; } @@ -495,7 +534,7 @@ class AnsiCodeTracker { let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : ""; if (this.activeHyperlink) { - result += `\x1b]8;;${this.activeHyperlink}\x1b\\`; + result += formatOsc8Hyperlink(this.activeHyperlink); } return result; } @@ -528,7 +567,7 @@ class AnsiCodeTracker { result += "\x1b[24m"; // Underline off only } if (this.activeHyperlink) { - result += "\x1b]8;;\x1b\\"; // Close hyperlink; re-opened at line start via getActiveCodes() + result += formatOsc8Close(this.activeHyperlink.terminator); // Re-opened at line start via getActiveCodes() } return result; } diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index e0944ded1..bd24e8159 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -191,6 +191,21 @@ describe("wrapTextWithAnsi with OSC 8 hyperlinks", () => { } }); + it("preserves BEL terminators when wrapping OAuth-style hyperlinks", () => { + const url = `https://example.com/oauth/${"a".repeat(32)}`; + const input = `\x1b]8;;${url}\x07${url}\x1b]8;;\x07`; + const lines = wrapTextWithAnsi(input, 20); + + assert.ok(lines.length > 1); + for (const line of lines) { + assert.ok(line.includes(`\x1b]8;;${url}\x07`), `Line "${line}" does not reopen the hyperlink with BEL`); + assert.ok(!line.includes(`\x1b]8;;${url}\x1b\\`), `Line "${line}" reopens the hyperlink with ST`); + } + for (const line of lines.slice(0, -1)) { + assert.ok(line.endsWith("\x1b]8;;\x07"), `Line "${line}" does not close the hyperlink with BEL`); + } + }); + it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => { const url = "https://example.com"; const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;