Skip to content
34 changes: 22 additions & 12 deletions apps/desktop/src/main/lib/terminal-ipcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) => {
Expand Down
202 changes: 149 additions & 53 deletions apps/desktop/src/main/lib/tmux-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,11 @@ class TmuxManager {
/**
* Initialize tmux session manager - restore sessions from disk
*/
async initialize(): Promise<void> {
const savedSessions = this.loadSessionsFromDisk();
async initialize(): Promise<void> {
// 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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/shared/ipc-channels/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}
Loading