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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user