From 9d2bceba5d9d6ba79f158ef813d7c29d920edba4 Mon Sep 17 00:00:00 2001 From: Marek Pazik Date: Fri, 29 May 2026 21:54:06 +1000 Subject: [PATCH] fix(tui): forward OSC 8 hyperlinks under tmux when the client supports them detectCapabilities previously disabled hyperlinks under tmux unconditionally. tmux re-emits OSC 8 to the outer terminal only when the attached client advertises the 'hyperlinks' feature in client_termfeatures, and strips them otherwise. Probe the running server with 'tmux display-message -p #{client_termfeatures}' and enable hyperlinks only when the feature is listed. The probe fails closed: any error (no tmux/server, timeout, old tmux) yields false. images stays null; only the hyperlink decision changed. The probe is injected into detectCapabilities so it stays testable without spawning a process. --- packages/tui/src/terminal-image.ts | 39 +++++++++++++++++++----- packages/tui/test/terminal-image.test.ts | 25 ++++++++++----- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index 22059c49..d7012c1d 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -1,3 +1,5 @@ +import { execSync } from "node:child_process"; + export type ImageProtocol = "kitty" | "iterm2" | null; export interface TerminalCapabilities { @@ -39,19 +41,42 @@ export function setCellDimensions(dims: CellDimensions): void { cellDimensions = dims; } -export function detectCapabilities(): TerminalCapabilities { +/** + * Checks whether the attached tmux client forwards OSC 8 hyperlinks to the + * outer terminal. tmux only re-emits them when its `client_termfeatures` lists + * `hyperlinks`, and strips them otherwise. On any error fallbacks `false`. + */ +function probeTmuxHyperlinks(): boolean { + try { + const termfeatures = execSync("tmux display-message -p '#{client_termfeatures}'", { + encoding: "utf8", + timeout: 250, + stdio: ["ignore", "pipe", "ignore"], + }); + return termfeatures + .split(",") + .map((feature) => feature.trim()) + .includes("hyperlinks"); + } catch { + return false; + } +} + +export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities { const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || ""; const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || ""; const term = process.env.TERM?.toLowerCase() || ""; const colorTerm = process.env.COLORTERM?.toLowerCase() || ""; const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; - // 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) { + // Emit OSC 8 hyperlinks only when tmux confirms it forwards. + // Image protocols are unreliable under tmux, so leave `images: null`. + if (process.env.TMUX || term.startsWith("tmux")) { + return { images: null, trueColor: hasTrueColorHint, hyperlinks: tmuxForwardsHyperlink() }; + } + + // screen does not forward OSC 8 hyperlinks, so keep them off there. + if (term.startsWith("screen")) { return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index b179c329..4e9a3fc9 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -207,19 +207,30 @@ describe("detectCapabilities", () => { }); }); - it("forces hyperlinks: false under tmux even if outer terminal supports OSC 8", () => { + it("enables hyperlinks under tmux when the client forwards them", () => { withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.hyperlinks, true); + assert.strictEqual(caps.images, null); + }); + }); + + it("disables hyperlinks under tmux when the client does not forward them", () => { + withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => { + const caps = detectCapabilities(() => false); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null); }); }); - it("forces hyperlinks: false when TERM starts with 'tmux'", () => { + it("checks tmux capability when TERM starts with 'tmux'", () => { withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => { - const caps = detectCapabilities(); - assert.strictEqual(caps.hyperlinks, false); + const caps = detectCapabilities(() => true); + assert.strictEqual(caps.hyperlinks, true); assert.strictEqual(caps.images, null); + + const caps2 = detectCapabilities(() => false); + assert.strictEqual(caps2.hyperlinks, false); }); }); @@ -294,7 +305,7 @@ describe("detectCapabilities", () => { it("does not inherit Windows Terminal truecolor through tmux", () => { withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => false); assert.strictEqual(caps.trueColor, false); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null); @@ -303,7 +314,7 @@ describe("detectCapabilities", () => { it("trusts explicit truecolor hints through tmux", () => { withEnv({ COLORTERM: "truecolor", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => { - const caps = detectCapabilities(); + const caps = detectCapabilities(() => false); assert.strictEqual(caps.trueColor, true); assert.strictEqual(caps.hyperlinks, false); assert.strictEqual(caps.images, null);