fix(tui): respect OSC 8 hyperlinks under tmux >= 3.4

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
This commit is contained in:
roboomp
2026-06-12 16:32:28 +00:00
parent db421bb2ef
commit d61a7aad7e
3 changed files with 187 additions and 6 deletions
+8
View File
@@ -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
+66 -6
View File
@@ -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=<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
@@ -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);
});
});