feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:53:17 -05:00
1 parent 99cffc9ba6
commit 9801cc7fc7
10 files changed
+183 -44

No files matched your search

@@ -77,6 +77,7 @@ export interface TmuxSessionsV1 {
stream: unknown, stream: unknown,
name?: string, name?: string,
newName?: string, newName?: string,
hostId?: number,
) => Promise<void>; ) => Promise<void>;
/** Waits for a new session to exist and returns its confirmed name. */ /** Waits for a new session to exist and returns its confirmed name. */
waitForSession: (client: unknown, name: string) => Promise<string>; waitForSession: (client: unknown, name: string) => Promise<string>;
@@ -781,7 +781,12 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
const existingName = tmuxData.sessionName || undefined; const existingName = tmuxData.sessionName || undefined;
if (existingName) { if (existingName) {
void tmux void tmux
.attachOrCreate(session.sshStream, existingName) .attachOrCreate(
session.sshStream,
existingName,
undefined,
session.hostId,
)
.catch(() => {}); .catch(() => {});
session.tmuxSessionName = existingName; session.tmuxSessionName = existingName;
sshLogger.info("User selected tmux session to attach", { sshLogger.info("User selected tmux session to attach", {
@@ -798,7 +803,12 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
} else { } else {
const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`; const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`;
void tmux void tmux
.attachOrCreate(session.sshStream, undefined, newName) .attachOrCreate(
session.sshStream,
undefined,
newName,
session.hostId,
)
.catch(() => {}); .catch(() => {});
const sshConn = session.sshConn; const sshConn = session.sshConn;
if (sshConn) { if (sshConn) {
@@ -2159,7 +2169,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
// skip detection and reuse the same path as the manual // skip detection and reuse the same path as the manual
// "tmux_attach" websocket message. // "tmux_attach" websocket message.
void tmux void tmux
.attachOrCreate(stream, tmuxAttachSession) .attachOrCreate(stream, tmuxAttachSession, undefined, id)
.catch(() => {}); .catch(() => {});
{ {
const session = sessionManager.getSession(boundSessionId); const session = sessionManager.getSession(boundSessionId);
@@ -2196,7 +2206,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
} else if (detection.sessions.length === 0) { } else if (detection.sessions.length === 0) {
const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`; const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`;
void tmux void tmux
.attachOrCreate(stream, undefined, newName) .attachOrCreate(stream, undefined, newName, id)
.catch(() => {}); .catch(() => {});
const confirmed = await tmux.waitForSession( const confirmed = await tmux.waitForSession(
conn, conn,
+4
View File
@@ -23,6 +23,10 @@
"enable": { "enable": {
"label": "Enable Tmux Monitor", "label": "Enable Tmux Monitor",
"description": "Watch and attach to tmux sessions running on this host." "description": "Watch and attach to tmux sessions running on this host."
},
"mouseEnabled": {
"label": "Enable tmux mouse support",
"description": "Apply mouse support to the tmux session when attaching or creating a session. Other sessions and global tmux settings are unchanged."
} }
} }
}, },
+9 -1
View File
@@ -51,7 +51,15 @@
"enableKey": "enableTmuxMonitor", "enableKey": "enableTmuxMonitor",
"enableLabelKey": "settings.host.enable.label", "enableLabelKey": "settings.host.enable.label",
"enableDescriptionKey": "settings.host.enable.description", "enableDescriptionKey": "settings.host.enable.description",
"fields": [] "fields": [
{
"key": "mouseEnabled",
"type": "boolean",
"labelKey": "settings.host.mouseEnabled.label",
"descriptionKey": "settings.host.mouseEnabled.description",
"default": true
}
]
} }
}, },
"hostCapability": { "hostCapability": {
+11 -2
View File
@@ -22,6 +22,7 @@ export interface TmuxSessionsV1 {
stream: unknown, stream: unknown,
name?: string, name?: string,
newName?: string, newName?: string,
hostId?: number,
) => Promise<void>; ) => Promise<void>;
waitForSession: (client: unknown, name: string) => Promise<string>; waitForSession: (client: unknown, name: string) => Promise<string>;
} }
@@ -36,8 +37,16 @@ export function createTmuxSessionsService(ctx: PluginContext): TmuxSessionsV1 {
}; };
}, },
async attachOrCreate(stream, name, newName) { async attachOrCreate(stream, name, newName, hostId) {
attachOrCreateTmuxSession(stream as ClientChannel, name, newName); const mouseEnabled =
hostId === undefined ||
(await ctx.settings.getHost<boolean>(hostId, "mouseEnabled")) !== false;
attachOrCreateTmuxSession(
stream as ClientChannel,
name,
newName,
mouseEnabled,
);
}, },
async waitForSession(client, name) { async waitForSession(client, name) {
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type { Client, ClientChannel } from "ssh2"; import type { Client, ClientChannel } from "ssh2";
const TMUX_PATH_DIRS = [ const TMUX_PATH_DIRS = [
@@ -104,30 +105,6 @@ export async function detectTmux(conn: Client): Promise<TmuxDetectionResult> {
return { available: true, sessions }; return { available: true, sessions };
} }
// tmux options applied on every attach/create:
// - mouse on: enables mouse wheel / touch scrollback through tmux history
// - history-limit: deep scrollback buffer on the remote host
// - set-clipboard on: use OSC 52 to sync tmux selections to the client clipboard
// - mode-keys vi: use vi-style keys in copy mode
// - MouseDragEnd: stop the selection but keep it highlighted so the user can
// adjust and press Enter to copy (or drag again)
// - Enter: copy the (possibly adjusted) selection and exit copy mode
// - pane-mode-changed hook: on copy-mode entry, show a brief hint so users
// know to press Enter to copy the selection
// Using -q on set/set-hook to suppress errors on older tmux versions that don't support
// a particular option (e.g. set-clipboard on tmux < 2.5). Note: set-hook doesn't support -q.
const TMUX_OPTS =
`set -gq mouse on` +
` \\; set -gq history-limit 50000` +
` \\; set -gq set-clipboard on` +
` \\; set -gq aggressive-resize on` +
` \\; set -gq mode-keys vi` +
` \\; bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X stop-selection` +
` \\; bind-key -T copy-mode-vi Enter send-keys -X copy-selection-and-cancel` +
` \\; set-hook -g pane-mode-changed` +
` 'if -F "#{pane_in_mode}"` +
` "display-message -d 2500 \\"Adjust selection and press Enter to copy\\""'`;
/** /**
* Wait for a tmux session to appear by polling via exec channel. * Wait for a tmux session to appear by polling via exec channel.
* Returns the session name once found, or null on timeout. * Returns the session name once found, or null on timeout.
@@ -157,20 +134,29 @@ export async function waitForTmuxSession(
/** /**
* Write tmux attach or new-session command to the interactive shell stream. * Write tmux attach or new-session command to the interactive shell stream.
* Uses && exit so the shell only closes if tmux started successfully. * Uses && exit so the shell only closes if tmux started successfully.
*
* Options are set on this session only so the user's own tmux config stays
* untouched. -q keeps an older tmux that lacks an option from aborting the
* attach.
*/ */
export function attachOrCreateTmuxSession( export function attachOrCreateTmuxSession(
stream: ClientChannel, stream: ClientChannel,
existingSessionName?: string, existingSessionName?: string,
newSessionName?: string, newSessionName?: string,
mouseEnabled = true,
): void { ): void {
let command: string; const name =
if (existingSessionName) { existingSessionName || newSessionName || `termix-${randomUUID()}`;
command = `${tmuxCommand(`${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)}`)} && exit\r`; const target = shellEscape(`=${name}`);
} else { const commands = existingSessionName
const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : ""; ? []
command = `${tmuxCommand(`${TMUX_OPTS} \\; new-session${nameFlag}`)} && exit\r`; : [`new-session -d -s ${shellEscape(name)}`];
} commands.push(
stream.write(command); `set-option -q -t ${target} mouse ${mouseEnabled ? "on" : "off"}`,
`set-option -q -t ${target} history-limit 50000`,
`attach-session -t ${target}`,
);
stream.write(`${tmuxCommand(commands.join(" \\; "))} && exit\r`);
} }
export function shellEscape(s: string): string { export function shellEscape(s: string): string {
@@ -23,14 +23,14 @@ export function HostTmuxMonitorSection({
] ?? {}) as Record<string, unknown>; ] ?? {}) as Record<string, unknown>;
const enabled = settings.enableTmuxMonitor === true; const enabled = settings.enableTmuxMonitor === true;
const setEnabled = (value: boolean) => const setSetting = (key: string, value: boolean) =>
updateForm((current) => { updateForm((current) => {
const all = (current.pluginSettings ?? {}) as PluginSettingsForm; const all = (current.pluginSettings ?? {}) as PluginSettingsForm;
return { return {
...current, ...current,
pluginSettings: { pluginSettings: {
...all, ...all,
"tmux-monitor": { ...all["tmux-monitor"], enableTmuxMonitor: value }, "tmux-monitor": { ...all["tmux-monitor"], [key]: value },
}, },
}; };
}); });
@@ -58,7 +58,19 @@ export function HostTmuxMonitorSection({
</> </>
} }
> >
<FakeSwitch checked={enabled} onChange={setEnabled} /> <FakeSwitch
checked={enabled}
onChange={(value) => setSetting("enableTmuxMonitor", value)}
/>
</SettingRow>
<SettingRow
label={t("settings.host.mouseEnabled.label")}
description={t("settings.host.mouseEnabled.description")}
>
<FakeSwitch
checked={settings.mouseEnabled !== false}
onChange={(value) => setSetting("mouseEnabled", value)}
/>
</SettingRow> </SettingRow>
</div> </div>
</SectionCard> </SectionCard>
@@ -65,6 +65,27 @@ describe("createTmuxSessionsService", () => {
expect(writes[0]).toContain("main"); expect(writes[0]).toContain("main");
}); });
it("uses the host's saved mouse setting when attaching", async () => {
const { ctx } = createFakeContext({ pluginId: "tmux-monitor" });
await ctx.settings.setHost(7, "mouseEnabled", false);
const writes: string[] = [];
const service = createTmuxSessionsService(ctx);
await service.attachOrCreate(
{ write: (value: string) => writes.push(value) },
"main",
undefined,
7,
);
await service.attachOrCreate(
{ write: (value: string) => writes.push(value) },
"other",
undefined,
8,
);
expect(writes[0]).toContain("mouse off");
expect(writes[1]).toContain("mouse on");
});
it("waitForSession() falls back to the requested name on timeout", async () => { it("waitForSession() falls back to the requested name on timeout", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
try { try {
@@ -1,8 +1,12 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import type { Client } from "ssh2"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Client, ClientChannel } from "ssh2";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
attachOrCreateTmuxSession,
detectTmux, detectTmux,
tmuxCommand, tmuxCommand,
withTmuxPath, withTmuxPath,
@@ -76,3 +80,81 @@ describe("tmux command path handling", () => {
]); ]);
}); });
}); });
describe("session-scoped tmux mouse", () => {
it.skipIf(process.platform === "win32")(
"passes an exact session target and literal separator through both shells",
() => {
const dir = mkdtempSync(join(tmpdir(), "termix-tmux-"));
try {
writeFileSync(join(dir, "tmux"), '#!/bin/sh\nprintf "%s\\n" "$@"\n', {
mode: 0o755,
});
let command = "";
const stream = {
write: (value: string) => {
command = value;
},
};
const name = "qa's $(echo injected)";
attachOrCreateTmuxSession(
stream as unknown as ClientChannel,
name,
undefined,
false,
);
expect(command.endsWith("\r")).toBe(true);
const args = execFileSync("/bin/sh", ["-c", command.trim()], {
encoding: "utf8",
env: { ...process.env, PATH: `${dir}:/usr/bin:/bin` },
})
.trim()
.split("\n");
expect(args).toEqual([
"-u",
"set-option",
"-q",
"-t",
`=${name}`,
"mouse",
"off",
";",
"set-option",
"-q",
"-t",
`=${name}`,
"history-limit",
"50000",
";",
"attach-session",
"-t",
`=${name}`,
]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
it("creates the named session before configuring and attaching it", () => {
let command = "";
attachOrCreateTmuxSession(
{
write: (value: string) => {
command = value;
},
} as unknown as ClientChannel,
undefined,
"work",
);
expect(command.indexOf("new-session")).toBeLessThan(
command.indexOf("set-option"),
);
expect(command.indexOf("set-option")).toBeLessThan(
command.indexOf("attach-session"),
);
expect(command).toContain("mouse on");
expect(command).toContain("history-limit 50000");
expect(command).not.toMatch(/set -g|set-hook|bind-key|set-clipboard/);
});
});
@@ -92,11 +92,17 @@ describe("host editor section", () => {
protocols: { ssh: true }, protocols: { ssh: true },
}); });
fireEvent.click(screen.getByRole("button")); fireEvent.click(screen.getAllByRole("button")[0]);
const settings = (form.pluginSettings as Record<string, unknown>)[ const settings = (form.pluginSettings as Record<string, unknown>)[
"tmux-monitor" "tmux-monitor"
] as { enableTmuxMonitor: boolean }; ] as { enableTmuxMonitor: boolean };
expect(settings.enableTmuxMonitor).toBe(true); expect(settings.enableTmuxMonitor).toBe(true);
fireEvent.click(screen.getAllByRole("button")[1]);
expect(
(form.pluginSettings as Record<string, Record<string, unknown>>)[
"tmux-monitor"
],
).toEqual({ enableTmuxMonitor: true, mouseEnabled: false });
}); });
}); });