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:
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added OSC 8 hyperlink rendering for markdown links when the terminal advertises support. Introduces a public `hyperlink(text, url)` helper and a `setCapabilities()` test override in `packages/tui` ([#3248](https://github.com/badlogic/pi-mono/pull/3248) by [@ofa1](https://github.com/ofa1)).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Tightened `detectCapabilities()` to default `hyperlinks: false` for unknown terminals and to force `hyperlinks: false` under tmux/screen (including nested sessions where the outer terminal would otherwise advertise OSC 8). Prevents markdown link URLs from disappearing on terminals that silently swallow OSC 8 sequences ([#3248](https://github.com/badlogic/pi-mono/pull/3248)).
|
||||||
|
|
||||||
## [0.67.5] - 2026-04-16
|
## [0.67.5] - 2026-04-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -42,6 +42,16 @@ export function detectCapabilities(): TerminalCapabilities {
|
|||||||
const term = process.env.TERM?.toLowerCase() || "";
|
const term = process.env.TERM?.toLowerCase() || "";
|
||||||
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
|
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
|
||||||
|
|
||||||
|
// tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps
|
||||||
|
// sequences differently). Force hyperlinks off whenever we detect them, even
|
||||||
|
// when the outer terminal would otherwise support OSC 8. Image protocols are
|
||||||
|
// also unreliable under tmux/screen, so leave `images: null` for safety.
|
||||||
|
const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen");
|
||||||
|
if (inTmuxOrScreen) {
|
||||||
|
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
|
||||||
|
return { images: null, trueColor, hyperlinks: false };
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
|
if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
|
||||||
return { images: "kitty", trueColor: true, hyperlinks: true };
|
return { images: "kitty", trueColor: true, hyperlinks: true };
|
||||||
}
|
}
|
||||||
@@ -66,8 +76,12 @@ export function detectCapabilities(): TerminalCapabilities {
|
|||||||
return { images: null, trueColor: true, hyperlinks: true };
|
return { images: null, trueColor: true, hyperlinks: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just
|
||||||
|
// text" on terminals that swallow it, which means the URL disappears from
|
||||||
|
// the rendered output. Default to the legacy `text (url)` behavior unless we
|
||||||
|
// have positively identified a hyperlink-capable terminal above.
|
||||||
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
|
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
|
||||||
return { images: null, trueColor, hyperlinks: true };
|
return { images: null, trueColor, hyperlinks: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCapabilities(): TerminalCapabilities {
|
export function getCapabilities(): TerminalCapabilities {
|
||||||
|
|||||||
@@ -4,7 +4,38 @@
|
|||||||
|
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { describe, it } from "node:test";
|
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("isImageLine", () => {
|
||||||
describe("iTerm2 image protocol", () => {
|
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", () => {
|
describe("hyperlink", () => {
|
||||||
it("wraps text in OSC 8 open and close sequences", () => {
|
it("wraps text in OSC 8 open and close sequences", () => {
|
||||||
const result = hyperlink("click me", "https://example.com");
|
const result = hyperlink("click me", "https://example.com");
|
||||||
|
|||||||
Reference in New Issue
Block a user