native: detect OSC 8 hyperlinks in more terminals (#1575)

Without OSC 8 the terminal detects URLs one row at a time, so a wrapped
link opens only the part on the clicked row. Enable hyperlinks for
xterm.js (XTVERSION), TERM_PROGRAM vscode/zed/mintty, and VTE 0.52.2 or
newer. TERM_PROGRAM and VTE_VERSION count only outside a multiplexer and
are revoked when one is detected later. FORCE_HYPERLINK overrides
detection; 0, false, or off disables hyperlinks.
This commit is contained in:
Simon Klee authored and GitHub committed 2026-10-08 10:21:06 +02:00
1 parent 4c625fa63e
commit 6fe23b8320
6 files changed
+187 -4

No files matched your search

+2
View File
@@ -559,6 +559,8 @@ const DEFAULT_FORWARDED_ENV_KEYS = [
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
"TERM_FEATURES",
"VTE_VERSION",
"FORCE_HYPERLINK",
"ALACRITTY_SOCKET",
"ALACRITTY_LOG",
"COLORTERM",
@@ -254,6 +254,46 @@ test("unidentified truecolor terminals preserve the visible Markdown link fallba
}
})
test("VS Code receives a wrapped Markdown URL as an OSC 8 hyperlink", async () => {
const previousTerm = process.env.TERM
const previousTermProgram = process.env.TERM_PROGRAM
process.env.TERM = "xterm-256color"
process.env.TERM_PROGRAM = "vscode"
const url = "https://example.com/catalog/moparts,2004,dodge,ram+1500,4.7l+v8,1432463,brake+pad"
try {
const stdout = createCollectingStdout(32, 8)
const renderer = await createCliRenderer({
stdin: createTestStdin(),
stdout,
remote: false,
forwardEnvKeys: ["TERM", "TERM_PROGRAM"],
})
destroyFns.push(() => renderer.destroy())
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })
destroyFns.push(() => syntaxStyle.destroy())
renderer.root.add(new MarkdownRenderable(renderer, { content: `See ${url} now.`, syntaxStyle }))
const frame = () => new TextDecoder().decode(renderer.currentRenderBuffer.getRealCharBytes(true))
for (let attempt = 0; attempt < 100 && !frame().replace(/\s/g, "").includes(url); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10))
await renderer.idle()
}
await flushWritable(stdout)
expect(renderer.capabilities?.hyperlinks).toBe(true)
expect(frame().replace(/\s/g, "")).toContain(url)
expect(frame()).not.toContain(url)
expect(stdout.getWrittenBytes().toString("binary")).toContain(`;${url}\x1b\\`)
} finally {
if (previousTerm === undefined) delete process.env.TERM
else process.env.TERM = previousTerm
if (previousTermProgram === undefined) delete process.env.TERM_PROGRAM
else process.env.TERM_PROGRAM = previousTermProgram
}
})
test("auto images use detected Kitty graphics and delete cleared placements", async () => {
const stdin = createTestStdin()
const stdout = createCollectingStdout(8, 4)
@@ -177,6 +177,26 @@ describe("remote detection", () => {
expect(caps.unicode).toBe("wcwidth")
})
test("explicit local mode forwards hyperlink detection keys by default", async () => {
const baseEnv = {
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? "",
TMPDIR: process.env.TMPDIR ?? "/tmp",
}
const vte = await getCapabilitiesFromChild(
{ remote: false },
{ ...baseEnv, TERM: "xterm-256color", VTE_VERSION: "7600" },
)
const forcedOff = await getCapabilitiesFromChild(
{ remote: false },
{ ...baseEnv, TERM: "xterm-kitty", FORCE_HYPERLINK: "0" },
)
expect(vte.hyperlinks).toBe(true)
expect(forcedOff.hyperlinks).toBe(false)
})
test("process stdout with memory output preserves auto remote detection", async () => {
const caps = await getCapabilitiesFromChild(
{},
+39 -2
View File
@@ -217,6 +217,8 @@ startup_cursor_query_pending: bool = false,
startup_cursor_query_captured: bool = false,
explicit_width_probe_reports_pending: u8 = 0,
unicode_wide_locked: ?bool = null,
host_env_hyperlinks: bool = false,
hyperlinks_override: ?bool = null,
state: struct {
alt_screen: bool = false,
@@ -668,6 +670,7 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
self.graphics_enabled = true;
self.image_protocol = .auto;
self.skip_explicit_width_query = false;
self.hyperlinks_override = null;
// Always just try to enable bracketed paste, even if it was reported as not supported
self.caps.bracketed_paste = true;
@@ -879,8 +882,10 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
}
}
if (self.is_foot and self.multiplexer != .none) {
// Multiplexed panes inherit TERM, TERM_PROGRAM, and VTE_VERSION from the outer terminal.
if ((self.is_foot or self.host_env_hyperlinks) and self.multiplexer != .none) {
self.caps.hyperlinks = false;
self.host_env_hyperlinks = false;
}
if (!self.caps.hyperlinks and self.term_info.from_xtversion) {
@@ -909,6 +914,20 @@ fn checkEnvironmentOverrides(self: *Terminal) void {
}
}
if (!self.caps.hyperlinks and !self.term_info.from_xtversion and self.multiplexer == .none) {
if (isHyperlinkHostEnv(env_map)) {
self.caps.hyperlinks = true;
self.host_env_hyperlinks = true;
}
}
if (env_map.get("FORCE_HYPERLINK")) |value| {
self.hyperlinks_override = !(std.mem.eql(u8, value, "0") or std.ascii.eqlIgnoreCase(value, "false") or std.ascii.eqlIgnoreCase(value, "off"));
}
if (self.hyperlinks_override) |enabled| {
self.caps.hyperlinks = enabled;
}
if (!self.caps.osc52 and !self.term_info.from_xtversion) {
if (env_map.get("WT_SESSION") != null) {
self.caps.osc52 = true;
@@ -1378,6 +1397,9 @@ pub fn processCapabilityResponse(self: *Terminal, response: []const u8) void {
if (!self.caps.hyperlinks and isHyperlinkTerm(response)) {
self.caps.hyperlinks = true;
}
if (self.hyperlinks_override) |enabled| {
self.caps.hyperlinks = enabled;
}
if (self.isInTmux()) {
// tmux has reported pane focus changes since 1.8 but answers DECRQM
@@ -1440,7 +1462,22 @@ fn isHyperlinkTerm(value: []const u8) bool {
std.ascii.findIgnoreCase(value, "wezterm") != null or
std.ascii.findIgnoreCase(value, "alacritty") != null or
std.ascii.findIgnoreCase(value, "foot") != null or
std.ascii.findIgnoreCase(value, "iterm") != null;
std.ascii.findIgnoreCase(value, "iterm") != null or
std.ascii.findIgnoreCase(value, "xterm.js") != null;
}
fn isHyperlinkHostEnv(env_map: *const std.process.Environ.Map) bool {
if (env_map.get("TERM_PROGRAM")) |program| {
if (std.mem.eql(u8, program, "vscode") or
std.mem.eql(u8, program, "zed") or
std.mem.eql(u8, program, "mintty")) return true;
}
// VTE releases before 0.52.2 include OSC 8 versions that can crash.
if (env_map.get("VTE_VERSION")) |version| {
const parsed = std.fmt.parseInt(u32, version, 10) catch return false;
return parsed >= 5202;
}
return false;
}
pub fn getCapabilities(self: *Terminal) Capabilities {
@@ -852,6 +852,76 @@ test "environment overrides - rgb does not imply hyperlinks after recheck for in
try testing.expect(!term.caps.hyperlinks);
}
test "environment overrides - TERM_PROGRAM and VTE_VERSION enable hyperlinks for OSC 8 terminals" {
const cases = [_]struct { key: []const u8, value: []const u8, hyperlinks: bool }{
.{ .key = "TERM_PROGRAM", .value = "vscode", .hyperlinks = true },
.{ .key = "TERM_PROGRAM", .value = "zed", .hyperlinks = true },
.{ .key = "TERM_PROGRAM", .value = "mintty", .hyperlinks = true },
.{ .key = "TERM_PROGRAM", .value = "Apple_Terminal", .hyperlinks = false },
.{ .key = "VTE_VERSION", .value = "5202", .hyperlinks = true },
.{ .key = "VTE_VERSION", .value = "5201", .hyperlinks = false },
.{ .key = "VTE_VERSION", .value = "invalid", .hyperlinks = false },
};
for (cases) |case| {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "xterm-256color");
try env.put(case.key, case.value);
const term = Terminal.init(.{ .env_map = &env });
try testing.expectEqual(case.hyperlinks, term.caps.hyperlinks);
}
}
test "environment overrides - TERM_PROGRAM hyperlinks require a direct terminal" {
const multiplexers = [_]struct {
key: []const u8,
value: []const u8,
kind: Terminal.Multiplexer,
}{
.{ .key = "STY", .value = "1234.session", .kind = .screen },
.{ .key = "TMUX", .value = "/tmp/tmux-1000/default,1,0", .kind = .tmux },
.{ .key = "ZELLIJ", .value = "0", .kind = .zellij },
};
for (multiplexers) |multiplexer| {
var term = Terminal.init(.{});
defer term.deinit();
try term.setHostEnvVar(testing.allocator, "TERM_PROGRAM", "vscode");
try testing.expect(term.caps.hyperlinks);
try term.setHostEnvVar(testing.allocator, multiplexer.key, multiplexer.value);
try testing.expectEqual(multiplexer.kind, term.multiplexer);
try testing.expect(!term.caps.hyperlinks);
}
}
test "environment overrides - FORCE_HYPERLINK forces hyperlink support" {
const enabled_values = [_][]const u8{ "1", "" };
for (enabled_values) |value| {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "xterm-256color");
try env.put("ZELLIJ", "0");
try env.put("FORCE_HYPERLINK", value);
const term = Terminal.init(.{ .env_map = &env });
try testing.expect(term.caps.hyperlinks);
}
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "xterm-kitty");
try env.put("FORCE_HYPERLINK", "0");
var term = Terminal.init(.{ .env_map = &env });
try testing.expect(!term.caps.hyperlinks);
term.processCapabilityResponse("\x1bP>|kitty(0.40.1)\x1b\\");
try testing.expect(!term.caps.hyperlinks);
}
test "setHostEnvVar detects ansi256 separately from rgb" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
@@ -1107,6 +1177,14 @@ test "processCapabilityResponse - wezterm applies osc52 and hyperlink heuristics
try testing.expect(term.caps.hyperlinks);
}
test "processCapabilityResponse - xterm.js enables hyperlinks" {
var term: Terminal = .{};
term.processCapabilityResponse("\x1bP>|xterm.js(6.0.0)\x1b\\");
try testing.expect(term.caps.hyperlinks);
}
test "processCapabilityResponse - foot applies osc52 heuristic without explicit cursor positioning" {
var term: Terminal = .{};
@@ -103,7 +103,12 @@ Read [Image](/docs/components/image) for protocol selection. Do not choose a pro
| `osc52_support` | `TerminalCapabilityState` | Query result when OpenTUI can distinguish unknown, supported, and unsupported |
| `notifications` | `boolean` | A notification protocol was selected |
The renderer emits OSC 8 sequences only when `hyperlinks` is true. Text still renders when it is false.
The renderer emits OSC 8 sequences only when `hyperlinks` is true. Text still renders when it is false. Without OSC 8,
the terminal detects URLs one row at a time, so a wrapped URL opens only the part on the clicked row.
OpenTUI enables `hyperlinks` for Ghostty, Kitty, WezTerm, Alacritty, foot, iTerm2, xterm.js, VS Code, Zed, mintty,
VTE 0.52.2 or newer, and Windows Terminal. `TERM_PROGRAM` and `VTE_VERSION` count only outside tmux, Zellij, and GNU
Screen. Set `FORCE_HYPERLINK` to override detection.
Hyperlink URLs use a native slot with a maximum of 512 UTF-8 bytes. A longer URL fails link allocation and the text
loses its hyperlink metadata.
@@ -143,7 +148,7 @@ session, OpenTUI does not apply the process host's forwarded terminal heuristics
`forwardEnvKeys` selects process environment names that OpenTUI sends to native detection. Explicit remote mode
defaults to an empty list. Other modes default to the documented terminal, SSH, tmux, Zellij, width, graphics,
notification, WSL, and Windows Terminal keys.
notification, hyperlink, WSL, and Windows Terminal keys.
For a remote terminal, forward only values that describe the remote endpoint:
@@ -192,6 +197,7 @@ Terminal detection reads these protocol controls:
| `OPENTUI_FORCE_NOZWJ` | Presence selects a native no-ZWJ width mode |
| `OPENTUI_NOTIFICATION_PROTOCOL` | Selects `osc9`, `osc777`, `osc99`, or a disabled value |
| `OPENTUI_NOTIFICATIONS` | `0`, `false`, or `off` disables notifications |
| `FORCE_HYPERLINK` | `0`, `false`, or `off` disables OSC 8 hyperlinks. Any other value, including an empty value, enables them. |
Read [Environment variables](/docs/reference/env-vars) for the complete value and timing rules.