From d61a7aad7eccc3176d7c2cfbd2d2294ea97e5b5d Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 12 Jun 2026 16:32:28 +0000 Subject: [PATCH] fix(tui): respect OSC 8 hyperlinks under tmux >= 3.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability gate hard-disabled OSC 8 whenever a multiplexer was detected (TMUX env or TERM starting with tmux/screen), which pre-dates tmux 3.4's OSC 8 support and has no override. Inside tmux that leaves URLs as plain text; when one wraps at the pane edge the outer terminal's regex link detector cannot join the hard-wrapped rows and the URL becomes unclickable with a newline embedded in the copy. Add two pure helpers next to the sync-output ones: - hyperlinksUserOverride(env): PI_FORCE_HYPERLINKS=1 / PI_NO_HYPERLINKS=1. Opt-out beats force-on. - shouldEnableHyperlinksByDefault(env, terminalId): user override > static per-terminal flag > screen-always-off > tmux gate (>= 3.4 via TERM_PROGRAM_VERSION which tmux self-sets since 3.2a) > static flag. GNU screen never gained OSC 8, so it stays off unless explicitly forced. Older tmux without a reported version also stays off, since we can not distinguish 3.3 from 3.5. Outer terminals not configured for hyperlinks see tmux drop the sequence — identical to today's behavior — so the upgrade is safe. Fixes #2403 --- packages/tui/CHANGELOG.md | 8 ++ packages/tui/src/terminal-capabilities.ts | 72 ++++++++++- .../tui/test/terminal-capabilities.test.ts | 113 ++++++++++++++++++ 3 files changed, 187 insertions(+), 6 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 09f2d355a..c703b8683 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- `PI_FORCE_HYPERLINKS=1` / `PI_NO_HYPERLINKS=1` env overrides for the OSC 8 hyperlink capability, mirroring the `PI_FORCE_SYNC_OUTPUT`/`PI_NO_SYNC_OUTPUT` shape (opt-out beats force-on). + +### Changed + +- Auto-enable OSC 8 hyperlinks inside tmux when tmux self-reports >= 3.4 via `TERM_PROGRAM_VERSION`; tmux 3.4 stores OSC 8 as a cell attribute and forwards it to outer terminals whose `terminal-features` include `hyperlinks`. Older tmux, GNU screen, and tmux without a reported version still default off. Resolution is factored into `hyperlinksUserOverride()` and `shouldEnableHyperlinksByDefault()` mirroring the sync-output helpers ([#2403](https://github.com/can1357/oh-my-pi/issues/2403)). + ## [15.11.8] - 2026-06-12 ### Changed diff --git a/packages/tui/src/terminal-capabilities.ts b/packages/tui/src/terminal-capabilities.ts index 11ef01a7a..9c7f444ec 100644 --- a/packages/tui/src/terminal-capabilities.ts +++ b/packages/tui/src/terminal-capabilities.ts @@ -249,6 +249,66 @@ export function detectRectangularSgrSupport(terminalId: TerminalId, env: NodeJS. } return true; } +/** + * Resolve an explicit user override for OSC 8 hyperlinks. Returns `false` for + * an opt-out, `true` for a force-on, or `null` when the user has expressed no + * preference. Opt-out beats force-on so a kill switch is unambiguous, mirroring + * {@link synchronizedOutputUserOverride}. + */ +export function hyperlinksUserOverride(env: NodeJS.ProcessEnv = Bun.env): boolean | null { + if (env.PI_NO_HYPERLINKS === "1") return false; + if (env.PI_FORCE_HYPERLINKS === "1") return true; + return null; +} + +/** + * Parse tmux's self-reported version from `TERM_PROGRAM_VERSION`. tmux sets + * `TERM_PROGRAM=tmux` and `TERM_PROGRAM_VERSION=` automatically since + * 3.2a; older releases (or any path that does not surface the version) yield + * `null` and the caller treats tmux conservatively. + */ +function parseTmuxVersionFromEnv(env: NodeJS.ProcessEnv): { major: number; minor: number } | null { + if (env.TERM_PROGRAM?.toLowerCase() !== "tmux") return null; + return parseMajorMinorVersion(env.TERM_PROGRAM_VERSION); +} + +/** + * Whether OSC 8 hyperlinks should be enabled by default. + * + * Policy (highest precedence first): + * 1. Explicit user override (`PI_NO_HYPERLINKS=1` off, `PI_FORCE_HYPERLINKS=1` + * on). Opt-out wins ties. + * 2. Static terminal capability — terminals whose {@link TerminalInfo} marks + * `hyperlinks: false` (e.g. `base`) stay off unless the user forced on. + * 3. GNU screen always off: screen never gained OSC 8 support. + * 4. tmux: enabled when tmux self-reports >= 3.4 via `TERM_PROGRAM_VERSION` + * (tmux 3.4 stores OSC 8 as a cell attribute and forwards it to outer + * terminals whose `terminal-features` include `hyperlinks`). Older or + * unknown versions stay off; on outer terminals without the feature + * configured, tmux silently drops the sequence — identical to today. + * 5. Otherwise honor the static terminal capability. + */ +export function shouldEnableHyperlinksByDefault( + env: NodeJS.ProcessEnv = Bun.env, + terminalId: TerminalId = TERMINAL_ID, +): boolean { + const override = hyperlinksUserOverride(env); + if (override !== null) return override; + + if (!getTerminalInfo(terminalId).hyperlinks) return false; + + const term = env.TERM?.toLowerCase() ?? ""; + if (env.STY || term.startsWith("screen")) return false; + + if (env.TMUX || term.startsWith("tmux")) { + const version = parseTmuxVersionFromEnv(env); + if (!version) return false; + return version.major > 3 || (version.major === 3 && version.minor >= 4); + } + + return true; +} + function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null { if (!process.stdout.isTTY) return null; if (terminalId === "vscode" || terminalId === "alacritty") return null; @@ -336,12 +396,12 @@ export const TERMINAL: RuntimeTerminal = (() => { const fallbackImageProtocol = getFallbackImageProtocol(resolved.id); if (fallbackImageProtocol) resolved.imageProtocol = fallbackImageProtocol; } - // tmux and screen multiplexers do not reliably forward OSC 8 hyperlinks - // to the outer terminal, so force them off regardless of detected terminal. - const term = Bun.env.TERM?.toLowerCase() ?? ""; - if (resolved.hyperlinks && (Bun.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"))) { - resolved.hyperlinks = false; - } + // Hyperlink (OSC 8) capability. The static per-terminal flag lives on + // KNOWN_TERMINALS; shouldEnableHyperlinksByDefault folds in runtime context — + // PI_FORCE_HYPERLINKS / PI_NO_HYPERLINKS overrides plus a tmux>=3.4 gate so + // modern tmux forwards OSC 8 to outer terminals that opt in via + // `terminal-features "*:hyperlinks"`. + resolved.hyperlinks = shouldEnableHyperlinksByDefault(Bun.env, resolved.id); // DECCARA rectangular-SGR background fills. The static per-terminal capability // lives on KNOWN_TERMINALS; here we fold in runtime context — multiplexer and // the PI_NO_DECCARA kill switch via detectRectangularSgrSupport — and force it diff --git a/packages/tui/test/terminal-capabilities.test.ts b/packages/tui/test/terminal-capabilities.test.ts index d6e5c309c..7d6b94693 100644 --- a/packages/tui/test/terminal-capabilities.test.ts +++ b/packages/tui/test/terminal-capabilities.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test"; import { + hyperlinksUserOverride, + shouldEnableHyperlinksByDefault, shouldEnableSynchronizedOutputByDefault, synchronizedOutputUserOverride, } from "@oh-my-pi/pi-tui/terminal-capabilities"; @@ -92,3 +94,114 @@ describe("shouldEnableSynchronizedOutputByDefault", () => { ).toBe(true); }); }); + +describe("hyperlinksUserOverride", () => { + it("returns null when neither override is set", () => { + expect(hyperlinksUserOverride({})).toBeNull(); + expect(hyperlinksUserOverride({ TERM: "xterm-256color" })).toBeNull(); + }); + + it("returns true for the force-on flag", () => { + expect(hyperlinksUserOverride({ PI_FORCE_HYPERLINKS: "1" })).toBe(true); + }); + + it("returns false for the opt-out flag", () => { + expect(hyperlinksUserOverride({ PI_NO_HYPERLINKS: "1" })).toBe(false); + }); + + it("resolves opt-out ahead of force-on when both are set", () => { + expect(hyperlinksUserOverride({ PI_NO_HYPERLINKS: "1", PI_FORCE_HYPERLINKS: "1" })).toBe(false); + }); + + it("ignores values other than the literal '1'", () => { + // Mirrors the sync-output knobs: only the canonical `1` toggles them; + // `true`/`yes` are not accepted to keep the contract obvious. + expect(hyperlinksUserOverride({ PI_FORCE_HYPERLINKS: "true" })).toBeNull(); + expect(hyperlinksUserOverride({ PI_FORCE_HYPERLINKS: "0" })).toBeNull(); + expect(hyperlinksUserOverride({ PI_NO_HYPERLINKS: "0" })).toBeNull(); + }); +}); + +describe("shouldEnableHyperlinksByDefault", () => { + it("enables hyperlinks on every known direct terminal", () => { + for (const id of ["kitty", "ghostty", "wezterm", "iterm2", "alacritty", "vscode"] as const) { + expect(shouldEnableHyperlinksByDefault({}, id)).toBe(true); + } + }); + + it("keeps the base/trueColor fallback terminals off", () => { + expect(shouldEnableHyperlinksByDefault({}, "base")).toBe(false); + expect(shouldEnableHyperlinksByDefault({}, "trueColor")).toBe(false); + }); + + it("keeps GNU screen always off, even when the inner terminal supports OSC 8", () => { + expect(shouldEnableHyperlinksByDefault({ STY: "1234.pts-0.host" }, "wezterm")).toBe(false); + expect(shouldEnableHyperlinksByDefault({ TERM: "screen-256color" }, "kitty")).toBe(false); + }); + + it("keeps tmux off when no version is reported (old tmux without TERM_PROGRAM_VERSION)", () => { + expect(shouldEnableHyperlinksByDefault({ TMUX: "/tmp/tmux-1000/default,1,0" }, "wezterm")).toBe(false); + expect(shouldEnableHyperlinksByDefault({ TERM: "tmux-256color" }, "wezterm")).toBe(false); + }); + + it("keeps tmux off when self-reported version is below 3.4", () => { + expect( + shouldEnableHyperlinksByDefault( + { TMUX: "/tmp/tmux-1000/default,1,0", TERM_PROGRAM: "tmux", TERM_PROGRAM_VERSION: "3.3a" }, + "wezterm", + ), + ).toBe(false); + expect( + shouldEnableHyperlinksByDefault( + { TMUX: "/tmp/tmux-1000/default,1,0", TERM_PROGRAM: "tmux", TERM_PROGRAM_VERSION: "2.9" }, + "kitty", + ), + ).toBe(false); + }); + + it("enables tmux >= 3.4 since tmux forwards OSC 8 cell attributes to the outer terminal", () => { + expect( + shouldEnableHyperlinksByDefault( + { TMUX: "/tmp/tmux-1000/default,1,0", TERM_PROGRAM: "tmux", TERM_PROGRAM_VERSION: "3.4" }, + "wezterm", + ), + ).toBe(true); + expect( + shouldEnableHyperlinksByDefault( + { TMUX: "/tmp/tmux-1000/default,1,0", TERM_PROGRAM: "tmux", TERM_PROGRAM_VERSION: "3.5a" }, + "wezterm", + ), + ).toBe(true); + expect( + shouldEnableHyperlinksByDefault( + { TMUX: "/tmp/tmux-1000/default,1,0", TERM_PROGRAM: "tmux", TERM_PROGRAM_VERSION: "4.0" }, + "kitty", + ), + ).toBe(true); + }); + + it("respects the static per-terminal flag even when force-on is absent", () => { + expect( + shouldEnableHyperlinksByDefault( + { TMUX: "/tmp/tmux-1000/default,1,0", TERM_PROGRAM: "tmux", TERM_PROGRAM_VERSION: "3.5a" }, + "base", + ), + ).toBe(false); + }); + + it("lets PI_NO_HYPERLINKS beat every positive heuristic", () => { + expect(shouldEnableHyperlinksByDefault({ PI_NO_HYPERLINKS: "1" }, "kitty")).toBe(false); + expect( + shouldEnableHyperlinksByDefault( + { PI_NO_HYPERLINKS: "1", TERM_PROGRAM: "tmux", TERM_PROGRAM_VERSION: "3.5a", TMUX: "1" }, + "wezterm", + ), + ).toBe(false); + }); + + it("lets PI_FORCE_HYPERLINKS override the conservative defaults (old tmux, screen, base terminal)", () => { + expect(shouldEnableHyperlinksByDefault({ PI_FORCE_HYPERLINKS: "1" }, "base")).toBe(true); + expect(shouldEnableHyperlinksByDefault({ PI_FORCE_HYPERLINKS: "1", TMUX: "1" }, "wezterm")).toBe(true); + expect(shouldEnableHyperlinksByDefault({ PI_FORCE_HYPERLINKS: "1", STY: "1.pts-0" }, "kitty")).toBe(true); + }); +});