diff --git a/apps/desktop/src/main/lib/terminal-ipcs.ts b/apps/desktop/src/main/lib/terminal-ipcs.ts index c59935ff6c6..94ff91611b3 100644 --- a/apps/desktop/src/main/lib/terminal-ipcs.ts +++ b/apps/desktop/src/main/lib/terminal-ipcs.ts @@ -10,7 +10,7 @@ let ipcHandlersRegistered = false; export function registerTerminalIPCs(window: BrowserWindowType) { // Initialize tmux manager (restore sessions) only once - if (!ipcHandlersRegistered) { + if (!ipcHandlersRegistered) { tmuxManager.initialize().catch((error) => { console.error("[Terminal IPC] Failed to initialize tmux manager:", error); }); @@ -44,13 +44,14 @@ export function registerTerminalIPCs(window: BrowserWindowType) { }, ); - // Send input to terminal - ipcMain.on( - "terminal-input", - (_event, message: { id: string; data: string }) => { - tmuxManager.write(message.id, message.data); - }, - ); + // Send input to terminal (exit copy-mode first so we don't snap back) + ipcMain.on( + "terminal-input", + (_event, message: { id: string; data: string }) => { + tmuxManager.scrollFinish(message.id); + tmuxManager.write(message.id, message.data); + }, + ); // Resize terminal with sequence tracking ipcMain.on( @@ -84,10 +85,19 @@ export function registerTerminalIPCs(window: BrowserWindowType) { }, ); - // Kill terminal (destroy tmux session completely) - ipcMain.on("terminal-kill", (_event, id: string) => { - tmuxManager.kill(id); - }); + // Kill terminal (destroy tmux session completely) + ipcMain.on("terminal-kill", (_event, id: string) => { + tmuxManager.kill(id); + }); + + // Scroll tmux history by N lines (positive = down, negative = up) + ipcMain.on( + "terminal-scroll-lines", + (_event, message: { id: string; amount: number }) => { + tmuxManager.scrollLines(message.id, message.amount); + }, + ); + // Get terminal history ipcMain.handle("terminal-get-history", (_event, id: string) => { diff --git a/apps/desktop/src/main/lib/tmux-manager.ts b/apps/desktop/src/main/lib/tmux-manager.ts index 74c4bd0d95b..da460bf81cd 100644 --- a/apps/desktop/src/main/lib/tmux-manager.ts +++ b/apps/desktop/src/main/lib/tmux-manager.ts @@ -93,8 +93,11 @@ class TmuxManager { /** * Initialize tmux session manager - restore sessions from disk */ - async initialize(): Promise { - const savedSessions = this.loadSessionsFromDisk(); + async initialize(): Promise { + // Ensure server-level settings (affect all sessions/panes) + this.applyServerSettings(); + + const savedSessions = this.loadSessionsFromDisk(); // Verify each session exists in tmux and prepare for lazy reattach for (const metadata of savedSessions) { @@ -177,44 +180,87 @@ class TmuxManager { /** * Apply tmux settings to make session invisible and optimized */ - private applySessionSettings(sid: string): void { - const settings = [ - ["status", "off"], - ["set-titles", "off"], - ["allow-rename", "off"], - ["mouse", "off"], - ["focus-events", "on"], - ["history-limit", "200000"], - ["remain-on-exit", "off"], - ["detach-on-destroy", "off"], - ["escape-time", "0"], - ["default-terminal", "xterm-256color"], - ]; - - for (const [option, value] of settings) { - spawnSync("tmux", [ - "-L", - this.TMUX_SOCKET, - "set", - "-t", - sid, - option, - value, - ]); - } - - // Add terminal-overrides for true color support - spawnSync("tmux", [ - "-L", - this.TMUX_SOCKET, - "set", - "-t", - sid, - "-as", - "terminal-overrides", - ",*:Tc", - ]); - } + private applySessionSettings(sid: string): void { + // Keep settings minimal and focused on invisibility and correct behavior + const settings = [ + ["status", "off"], + ["set-titles", "off"], + ["allow-rename", "off"], + // Turn off tmux mouse so xterm selection remains native + ["mouse", "off"], + // Hide transient messages like copy-mode position overlays + ["display-time", "0"], + ["focus-events", "on"], + ["history-limit", "200000"], + ["remain-on-exit", "off"], + ["detach-on-destroy", "off"], + ["escape-time", "0"], + ["default-terminal", "xterm-256color"], + ]; + + for (const [option, value] of settings) { + spawnSync("tmux", [ + "-L", + this.TMUX_SOCKET, + "set", + "-t", + sid, + option, + value, + ]); + } + + // Server-level history-limit is applied in applyServerSettings() + + // Leave key bindings and prefixes at user defaults + + // Add terminal-overrides for true color support + spawnSync("tmux", [ + "-L", + this.TMUX_SOCKET, + "set", + "-t", + sid, + "-as", + "terminal-overrides", + ",*:Tc", + ]); + + // Do not alter default mouse key bindings – keep selection behavior in xterm + } + + /** + * Apply server/global settings that maximize scrollback across all sessions + */ + private applyServerSettings(): void { + try { + // Server-level hard cap for history size + spawnSync("tmux", [ + "-L", + this.TMUX_SOCKET, + "set", + "-s", + "history-limit", + "1000000", + ]); + } catch (e) { + console.warn("[TmuxManager] Failed to set server history-limit", e); + } + + try { + // Global default for future windows/panes + spawnSync("tmux", [ + "-L", + this.TMUX_SOCKET, + "set", + "-g", + "history-limit", + "1000000", + ]); + } catch (e) { + console.warn("[TmuxManager] Failed to set global history-limit", e); + } + } /** * Create or reattach to a terminal session @@ -601,19 +647,69 @@ class TmuxManager { /** * Emit terminal data to all windows viewing this terminal */ - private emitMessage(sid: string, data: string): void { - const windows = this.terminalWindows.get(sid); - if (windows && windows.size > 0) { - for (const window of windows) { - if (!window.isDestroyed()) { - window.webContents.send("terminal-on-data", { - id: sid, - data, - }); - } - } - } - } + private emitMessage(sid: string, data: string): void { + // Strip mouse reporting enable sequences so xterm never flips into mouse mode + const sanitizeMouseSeq = (input: string): string => { + try { + return input.replace(/\x1b\[\?([0-9;]+)h/g, (_m, nums: string) => { + const blocked = new Set([9, 1000, 1002, 1003, 1005, 1006, 1015]); + const kept: number[] = []; + for (const part of String(nums).split(";")) { + const n = Number(part); + if (!blocked.has(n)) kept.push(n); + } + return kept.length ? `\x1b[?${kept.join(";")}h` : ""; + }); + } catch { + return input; + } + }; + + const windows = this.terminalWindows.get(sid); + if (windows && windows.size > 0) { + for (const window of windows) { + if (!window.isDestroyed()) { + window.webContents.send("terminal-on-data", { + id: sid, + data: sanitizeMouseSeq(data), + }); + } + } + } + } + + /** Scroll tmux history by amount (positive = down, negative = up) */ + scrollLines(sid: string, amount: number): void { + try { + const count = Math.max(1, Math.min(100, Math.abs(Math.trunc(amount)))); + const direction = amount > 0 ? "scroll-down" : "scroll-up"; + // Enter copy-mode if not already + spawnSync("tmux", ["-L", this.TMUX_SOCKET, "copy-mode", "-e", "-t", sid]); + // Repeat scroll efficiently + spawnSync("tmux", [ + "-L", + this.TMUX_SOCKET, + "send-keys", + "-t", + sid, + "-X", + "-N", + String(count), + direction, + ]); + } catch (e) { + console.warn("[TmuxManager] Failed to scroll lines:", sid, amount, e); + } + } + + /** Exit copy-mode after scrolling to restore normal cursor */ + scrollFinish(sid: string): void { + try { + spawnSync("tmux", ["-L", this.TMUX_SOCKET, "send-keys", "-t", sid, "-X", "cancel"]); + } catch (e) { + console.warn("[TmuxManager] Failed to cancel copy-mode:", sid, e); + } + } /** * Load persisted sessions from disk diff --git a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx index a38b851b94b..b98d60fdea5 100644 --- a/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/MainContent/Terminal.tsx @@ -161,6 +161,9 @@ export default function TerminalComponent({ theme: currentTheme === "light" ? TERMINAL_THEME.LIGHT : TERMINAL_THEME.DARK, scrollback: 9999999, // Very large scrollback buffer (practical maximum) + // Favor native-feeling selection and copy (Cmd+C) in xterm + macOptionClickForcesSelection: true, + rightClickSelectsWord: true, // Use xterm.js defaults for all other settings to match standard terminal behavior // scrollOnUserInput: true (default) // altClickMovesCursor: true (default - matches iTerm2) @@ -254,6 +257,31 @@ export default function TerminalComponent({ // This ensures the terminal has correct dimensions when it first appears customFit(); + // Attach custom wheel handler to scroll tmux history when in normal buffer + try { + term.attachCustomWheelEventHandler((ev: WheelEvent) => { + // If alternate buffer (full-screen app), let xterm transform wheel to keys + const isAlternate = (term as any)?.buffer?.active?.type === "alternate"; + if (isAlternate) return true; // allow default handling + + // If user is selecting (mouse button down), don't hijack + if ((ev.buttons & 1) === 1) return true; + + // Convert delta to line count (heuristic) + const lines = Math.max(1, Math.abs(Math.round(ev.deltaY / 40))); + const amount = ev.deltaY > 0 ? lines : -lines; + if (terminalIdRef.current && amount !== 0) { + window.ipcRenderer.send("terminal-scroll-lines", { + id: terminalIdRef.current, + amount, + }); + // Prevent default so viewport/xterm doesn't fight with tmux copy-mode + return false; + } + return true; + }); + } catch {} + // Create/attach terminal session with proper dimensions // This is deferred until the terminal is initialized so we can pass correct cols/rows if (terminalId && cwd) { diff --git a/apps/desktop/src/shared/ipc-channels/terminal.ts b/apps/desktop/src/shared/ipc-channels/terminal.ts index d9de741fc4b..86dfbcbf3ed 100644 --- a/apps/desktop/src/shared/ipc-channels/terminal.ts +++ b/apps/desktop/src/shared/ipc-channels/terminal.ts @@ -39,5 +39,10 @@ export interface TerminalChannels { request: string; response: NoResponse; }; -} + /** Scroll tmux history lines (positive = down, negative = up) */ + "terminal-scroll-lines": { + request: { id: string; amount: number }; + response: NoResponse; + }; +}