fix(tui): preserve OSC 8 hyperlink terminators
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.73.0] - 2026-05-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -312,6 +312,42 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt
|
|||||||
return null;
|
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.
|
* Track active ANSI SGR codes to preserve styling across line breaks.
|
||||||
*/
|
*/
|
||||||
@@ -327,13 +363,16 @@ class AnsiCodeTracker {
|
|||||||
private strikethrough = false;
|
private strikethrough = false;
|
||||||
private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240"
|
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 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 {
|
process(ansiCode: string): void {
|
||||||
// OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close)
|
// OSC 8 hyperlink: \x1b]8;;<url>\x1b\\ (open) or \x1b]8;;\x1b\\ (close).
|
||||||
if (ansiCode.startsWith("\x1b]8;")) {
|
// Preserve the original terminator because some terminals only make BEL-terminated
|
||||||
const m = ansiCode.match(/^\x1b\]8;[^;]*;([^\x1b\x07]*)/);
|
// links clickable. OAuth login URLs use BEL, so reopening wrapped lines with ST
|
||||||
this.activeHyperlink = m?.[1] ? m[1] : null;
|
// made only the first physical line clickable in those terminals.
|
||||||
|
const hyperlink = parseOsc8Hyperlink(ansiCode);
|
||||||
|
if (hyperlink !== undefined) {
|
||||||
|
this.activeHyperlink = hyperlink;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +534,7 @@ class AnsiCodeTracker {
|
|||||||
|
|
||||||
let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : "";
|
let result = codes.length > 0 ? `\x1b[${codes.join(";")}m` : "";
|
||||||
if (this.activeHyperlink) {
|
if (this.activeHyperlink) {
|
||||||
result += `\x1b]8;;${this.activeHyperlink}\x1b\\`;
|
result += formatOsc8Hyperlink(this.activeHyperlink);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -528,7 +567,7 @@ class AnsiCodeTracker {
|
|||||||
result += "\x1b[24m"; // Underline off only
|
result += "\x1b[24m"; // Underline off only
|
||||||
}
|
}
|
||||||
if (this.activeHyperlink) {
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => {
|
||||||
const url = "https://example.com";
|
const url = "https://example.com";
|
||||||
const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;
|
const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;
|
||||||
|
|||||||
Reference in New Issue
Block a user