fix(tui): default hyperlinks off for unknown terminals and tmux/screen

OSC 8 hyperlinks landed in #3248, but detectCapabilities() returned
hyperlinks: true in the unknown-terminal fallback. Terminals that
silently swallow OSC 8 (most xterm-compatible hosts, tmux/screen
without passthrough) end up dropping the URL from rendered markdown
links entirely, since the fallback 'text (url)' rendering is skipped
whenever hyperlinks is true.

- Unknown terminals now default to hyperlinks: false.
- tmux and screen (TMUX env, TERM starting with tmux/screen) force
  hyperlinks: false even when the outer terminal would otherwise
  advertise OSC 8 support. Image protocols also left disabled.
- Added detectCapabilities tests covering the known-capable set and
  the tmux/screen/unknown cases.
This commit is contained in:
Mario Zechner
2026-04-16 22:19:38 +02:00
parent c3ded498ad
commit 30a8a41fc3
3 changed files with 124 additions and 2 deletions

View File

@@ -4,7 +4,38 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { hyperlink, isImageLine } from "../src/terminal-image.js";
import { detectCapabilities, hyperlink, isImageLine } from "../src/terminal-image.js";
const ENV_KEYS = [
"TERM",
"TERM_PROGRAM",
"COLORTERM",
"TMUX",
"KITTY_WINDOW_ID",
"GHOSTTY_RESOURCES_DIR",
"WEZTERM_PANE",
"ITERM_SESSION_ID",
] as const;
function withEnv(overrides: Record<string, string | undefined>, fn: () => void): void {
const saved: Record<string, string | undefined> = {};
for (const key of ENV_KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
try {
for (const [k, v] of Object.entries(overrides)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
fn();
} finally {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
}
}
describe("isImageLine", () => {
describe("iTerm2 image protocol", () => {
@@ -152,6 +183,75 @@ describe("isImageLine", () => {
});
});
describe("detectCapabilities", () => {
it("defaults to hyperlinks: false for unknown terminals", () => {
withEnv({}, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, false);
assert.strictEqual(caps.images, null);
});
});
it("forces hyperlinks: false under tmux even if outer terminal supports OSC 8", () => {
withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, false);
assert.strictEqual(caps.images, null);
});
});
it("forces hyperlinks: false when TERM starts with 'tmux'", () => {
withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, false);
assert.strictEqual(caps.images, null);
});
});
it("forces hyperlinks: false when TERM starts with 'screen'", () => {
withEnv({ TERM: "screen-256color" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, false);
assert.strictEqual(caps.images, null);
});
});
it("enables hyperlinks for Ghostty", () => {
withEnv({ TERM_PROGRAM: "ghostty" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
});
});
it("enables hyperlinks for Kitty", () => {
withEnv({ KITTY_WINDOW_ID: "1" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
});
});
it("enables hyperlinks for WezTerm", () => {
withEnv({ WEZTERM_PANE: "0" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
});
});
it("enables hyperlinks for iTerm2", () => {
withEnv({ TERM_PROGRAM: "iterm.app" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
});
});
it("enables hyperlinks for VSCode", () => {
withEnv({ TERM_PROGRAM: "vscode" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
});
});
});
describe("hyperlink", () => {
it("wraps text in OSC 8 open and close sequences", () => {
const result = hyperlink("click me", "https://example.com");