fix(tui): preserve OSC 8 hyperlink terminators

This commit is contained in:
Mario Zechner
2026-05-05 13:48:15 +02:00
Unverified
parent 755da309dd
commit c806dea15e
3 changed files with 65 additions and 7 deletions
+4
View File
@@ -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
+46 -7
View File
@@ -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;;<url>\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;;<url>\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;
}
+15
View File
@@ -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`;