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.
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
export type ImageProtocol = "kitty" | "iterm2" | null;
|
export type ImageProtocol = "kitty" | "iterm2" | null;
|
||||||
|
|
||||||
export interface TerminalCapabilities {
|
export interface TerminalCapabilities {
|
||||||
@@ -39,19 +41,42 @@ export function setCellDimensions(dims: CellDimensions): void {
|
|||||||
cellDimensions = dims;
|
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 termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
|
||||||
const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || "";
|
const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || "";
|
||||||
const term = process.env.TERM?.toLowerCase() || "";
|
const term = process.env.TERM?.toLowerCase() || "";
|
||||||
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
|
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
|
||||||
const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit";
|
const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit";
|
||||||
|
|
||||||
// tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps
|
// Emit OSC 8 hyperlinks only when tmux confirms it forwards.
|
||||||
// sequences differently). Force hyperlinks off whenever we detect them, even
|
// Image protocols are unreliable under tmux, so leave `images: null`.
|
||||||
// when the outer terminal would otherwise support OSC 8. Image protocols are
|
if (process.env.TMUX || term.startsWith("tmux")) {
|
||||||
// also unreliable under tmux/screen, so leave `images: null` for safety.
|
return { images: null, trueColor: hasTrueColorHint, hyperlinks: tmuxForwardsHyperlink() };
|
||||||
const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen");
|
}
|
||||||
if (inTmuxOrScreen) {
|
|
||||||
|
// screen does not forward OSC 8 hyperlinks, so keep them off there.
|
||||||
|
if (term.startsWith("screen")) {
|
||||||
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
|
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" }, () => {
|
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.hyperlinks, false);
|
||||||
assert.strictEqual(caps.images, null);
|
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" }, () => {
|
withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => {
|
||||||
const caps = detectCapabilities();
|
const caps = detectCapabilities(() => true);
|
||||||
assert.strictEqual(caps.hyperlinks, false);
|
assert.strictEqual(caps.hyperlinks, true);
|
||||||
assert.strictEqual(caps.images, null);
|
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", () => {
|
it("does not inherit Windows Terminal truecolor through tmux", () => {
|
||||||
withEnv({ WT_SESSION: "session", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => {
|
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.trueColor, false);
|
||||||
assert.strictEqual(caps.hyperlinks, false);
|
assert.strictEqual(caps.hyperlinks, false);
|
||||||
assert.strictEqual(caps.images, null);
|
assert.strictEqual(caps.images, null);
|
||||||
@@ -303,7 +314,7 @@ describe("detectCapabilities", () => {
|
|||||||
|
|
||||||
it("trusts explicit truecolor hints through tmux", () => {
|
it("trusts explicit truecolor hints through tmux", () => {
|
||||||
withEnv({ COLORTERM: "truecolor", TMUX: "/tmp/tmux-1000/default,1234,0", TERM: "tmux-256color" }, () => {
|
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.trueColor, true);
|
||||||
assert.strictEqual(caps.hyperlinks, false);
|
assert.strictEqual(caps.hyperlinks, false);
|
||||||
assert.strictEqual(caps.images, null);
|
assert.strictEqual(caps.images, null);
|
||||||
|
|||||||
Reference in New Issue
Block a user