From 30a8a41fc3c8419f973a3e6bde95c41aabe14d75 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 16 Apr 2026 22:19:38 +0200 Subject: [PATCH] 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. --- packages/tui/CHANGELOG.md | 8 ++ packages/tui/src/terminal-image.ts | 16 +++- packages/tui/test/terminal-image.test.ts | 102 ++++++++++++++++++++++- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 0d46e4ba..57ddcd8e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,14 @@ ## [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 ### Fixed diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index a8406a1c..b300d8cf 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -42,6 +42,16 @@ export function detectCapabilities(): TerminalCapabilities { const term = process.env.TERM?.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") { return { images: "kitty", trueColor: true, hyperlinks: true }; } @@ -66,8 +76,12 @@ export function detectCapabilities(): TerminalCapabilities { 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"; - return { images: null, trueColor, hyperlinks: true }; + return { images: null, trueColor, hyperlinks: false }; } export function getCapabilities(): TerminalCapabilities { diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index feeebafe..dd67e6bf 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -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, fn: () => void): void { + const saved: Record = {}; + 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");