mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
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:
1 parent
4c625fa63e
commit
6fe23b8320
6 files changed
+187
-4
No files matched your search
@@ -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(
|
||||
{},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user