diff --git a/plugins/ssh-terminal/src/backend/services.ts b/plugins/ssh-terminal/src/backend/services.ts index f65e3cf3b..48f72f7c8 100644 --- a/plugins/ssh-terminal/src/backend/services.ts +++ b/plugins/ssh-terminal/src/backend/services.ts @@ -77,6 +77,7 @@ export interface TmuxSessionsV1 { stream: unknown, name?: string, newName?: string, + hostId?: number, ) => Promise; /** Waits for a new session to exist and returns its confirmed name. */ waitForSession: (client: unknown, name: string) => Promise; diff --git a/plugins/ssh-terminal/src/backend/terminal-socket.ts b/plugins/ssh-terminal/src/backend/terminal-socket.ts index 472479ac9..398570d5c 100644 --- a/plugins/ssh-terminal/src/backend/terminal-socket.ts +++ b/plugins/ssh-terminal/src/backend/terminal-socket.ts @@ -781,7 +781,12 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { const existingName = tmuxData.sessionName || undefined; if (existingName) { void tmux - .attachOrCreate(session.sshStream, existingName) + .attachOrCreate( + session.sshStream, + existingName, + undefined, + session.hostId, + ) .catch(() => {}); session.tmuxSessionName = existingName; sshLogger.info("User selected tmux session to attach", { @@ -798,7 +803,12 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { } else { const newName = `termix-${session.hostId}-${Date.now().toString(36).slice(-4)}`; void tmux - .attachOrCreate(session.sshStream, undefined, newName) + .attachOrCreate( + session.sshStream, + undefined, + newName, + session.hostId, + ) .catch(() => {}); const sshConn = session.sshConn; if (sshConn) { @@ -2159,7 +2169,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { // skip detection and reuse the same path as the manual // "tmux_attach" websocket message. void tmux - .attachOrCreate(stream, tmuxAttachSession) + .attachOrCreate(stream, tmuxAttachSession, undefined, id) .catch(() => {}); { const session = sessionManager.getSession(boundSessionId); @@ -2196,7 +2206,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) { } else if (detection.sessions.length === 0) { const newName = `termix-${id}-${Date.now().toString(36).slice(-4)}`; void tmux - .attachOrCreate(stream, undefined, newName) + .attachOrCreate(stream, undefined, newName, id) .catch(() => {}); const confirmed = await tmux.waitForSession( conn, diff --git a/plugins/tmux-monitor/locales/en.json b/plugins/tmux-monitor/locales/en.json index 983a87794..6c9a87e69 100644 --- a/plugins/tmux-monitor/locales/en.json +++ b/plugins/tmux-monitor/locales/en.json @@ -23,6 +23,10 @@ "enable": { "label": "Enable Tmux Monitor", "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." } } }, diff --git a/plugins/tmux-monitor/manifest.json b/plugins/tmux-monitor/manifest.json index ce4646517..210f51fcf 100644 --- a/plugins/tmux-monitor/manifest.json +++ b/plugins/tmux-monitor/manifest.json @@ -51,7 +51,15 @@ "enableKey": "enableTmuxMonitor", "enableLabelKey": "settings.host.enable.label", "enableDescriptionKey": "settings.host.enable.description", - "fields": [] + "fields": [ + { + "key": "mouseEnabled", + "type": "boolean", + "labelKey": "settings.host.mouseEnabled.label", + "descriptionKey": "settings.host.mouseEnabled.description", + "default": true + } + ] } }, "hostCapability": { diff --git a/plugins/tmux-monitor/src/backend/service.ts b/plugins/tmux-monitor/src/backend/service.ts index 87ddb02af..254aac9c0 100644 --- a/plugins/tmux-monitor/src/backend/service.ts +++ b/plugins/tmux-monitor/src/backend/service.ts @@ -22,6 +22,7 @@ export interface TmuxSessionsV1 { stream: unknown, name?: string, newName?: string, + hostId?: number, ) => Promise; waitForSession: (client: unknown, name: string) => Promise; } @@ -36,8 +37,16 @@ export function createTmuxSessionsService(ctx: PluginContext): TmuxSessionsV1 { }; }, - async attachOrCreate(stream, name, newName) { - attachOrCreateTmuxSession(stream as ClientChannel, name, newName); + async attachOrCreate(stream, name, newName, hostId) { + const mouseEnabled = + hostId === undefined || + (await ctx.settings.getHost(hostId, "mouseEnabled")) !== false; + attachOrCreateTmuxSession( + stream as ClientChannel, + name, + newName, + mouseEnabled, + ); }, async waitForSession(client, name) { diff --git a/plugins/tmux-monitor/src/backend/tmux-commands.ts b/plugins/tmux-monitor/src/backend/tmux-commands.ts index 231131c1b..0afe5b41d 100644 --- a/plugins/tmux-monitor/src/backend/tmux-commands.ts +++ b/plugins/tmux-monitor/src/backend/tmux-commands.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { Client, ClientChannel } from "ssh2"; const TMUX_PATH_DIRS = [ @@ -104,30 +105,6 @@ export async function detectTmux(conn: Client): Promise { 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. * 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. * 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( stream: ClientChannel, existingSessionName?: string, newSessionName?: string, + mouseEnabled = true, ): void { - let command: string; - if (existingSessionName) { - command = `${tmuxCommand(`${TMUX_OPTS} \\; attach-session -t ${shellEscape(existingSessionName)}`)} && exit\r`; - } else { - const nameFlag = newSessionName ? ` -s ${shellEscape(newSessionName)}` : ""; - command = `${tmuxCommand(`${TMUX_OPTS} \\; new-session${nameFlag}`)} && exit\r`; - } - stream.write(command); + const name = + existingSessionName || newSessionName || `termix-${randomUUID()}`; + const target = shellEscape(`=${name}`); + const commands = existingSessionName + ? [] + : [`new-session -d -s ${shellEscape(name)}`]; + commands.push( + `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 { diff --git a/plugins/tmux-monitor/src/frontend/HostTmuxMonitorSection.tsx b/plugins/tmux-monitor/src/frontend/HostTmuxMonitorSection.tsx index ebf67497d..c1430fe59 100644 --- a/plugins/tmux-monitor/src/frontend/HostTmuxMonitorSection.tsx +++ b/plugins/tmux-monitor/src/frontend/HostTmuxMonitorSection.tsx @@ -23,14 +23,14 @@ export function HostTmuxMonitorSection({ ] ?? {}) as Record; const enabled = settings.enableTmuxMonitor === true; - const setEnabled = (value: boolean) => + const setSetting = (key: string, value: boolean) => updateForm((current) => { const all = (current.pluginSettings ?? {}) as PluginSettingsForm; return { ...current, pluginSettings: { ...all, - "tmux-monitor": { ...all["tmux-monitor"], enableTmuxMonitor: value }, + "tmux-monitor": { ...all["tmux-monitor"], [key]: value }, }, }; }); @@ -58,7 +58,19 @@ export function HostTmuxMonitorSection({ } > - + setSetting("enableTmuxMonitor", value)} + /> + + + setSetting("mouseEnabled", value)} + /> diff --git a/plugins/tmux-monitor/tests/backend/service.test.ts b/plugins/tmux-monitor/tests/backend/service.test.ts index 732a8a13a..5339e20fa 100644 --- a/plugins/tmux-monitor/tests/backend/service.test.ts +++ b/plugins/tmux-monitor/tests/backend/service.test.ts @@ -65,6 +65,27 @@ describe("createTmuxSessionsService", () => { 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 () => { vi.useFakeTimers(); try { diff --git a/plugins/tmux-monitor/tests/backend/tmux-commands.test.ts b/plugins/tmux-monitor/tests/backend/tmux-commands.test.ts index 17160c5ff..3a7688bae 100644 --- a/plugins/tmux-monitor/tests/backend/tmux-commands.test.ts +++ b/plugins/tmux-monitor/tests/backend/tmux-commands.test.ts @@ -1,8 +1,12 @@ import { EventEmitter } from "node:events"; 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 { + attachOrCreateTmuxSession, detectTmux, tmuxCommand, 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/); + }); +}); diff --git a/plugins/tmux-monitor/tests/frontend/activate.test.tsx b/plugins/tmux-monitor/tests/frontend/activate.test.tsx index 17a305cdb..4fd7fae47 100644 --- a/plugins/tmux-monitor/tests/frontend/activate.test.tsx +++ b/plugins/tmux-monitor/tests/frontend/activate.test.tsx @@ -92,11 +92,17 @@ describe("host editor section", () => { protocols: { ssh: true }, }); - fireEvent.click(screen.getByRole("button")); + fireEvent.click(screen.getAllByRole("button")[0]); const settings = (form.pluginSettings as Record)[ "tmux-monitor" ] as { enableTmuxMonitor: boolean }; expect(settings.enableTmuxMonitor).toBe(true); + fireEvent.click(screen.getAllByRole("button")[1]); + expect( + (form.pluginSettings as Record>)[ + "tmux-monitor" + ], + ).toEqual({ enableTmuxMonitor: true, mouseEnabled: false }); }); });