From 1418ccb7885ad4ee360805730c70d111910c4fa5 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 19 Nov 2025 19:42:31 +0000 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=A4=96=20feat:=20restore=20native=20t?= =?UTF-8?q?erminals=20for=20Electron=20desktop=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add mode parameter to IpcMain constructor ('desktop' | 'browser') - Desktop mode: Always use native OS terminals for both local and SSH - Browser mode: Continue using web terminals via window.open() - Remove TerminalWindowManager (only managed ghostty-web Electron windows) - Simplify terminal routing logic in TERMINAL_WINDOW_OPEN handler - Keep TERMINAL_WINDOW_CLOSE as no-op for API compatibility Result: -157 LoC, better UX for desktop users with native terminal integration Generated with `mux` --- src/desktop/main.ts | 8 +- src/desktop/terminalWindowManager.ts | 136 --------------------------- src/node/services/ipcMain.ts | 67 +++++-------- 3 files changed, 27 insertions(+), 184 deletions(-) delete mode 100644 src/desktop/terminalWindowManager.ts diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 63d6dfa03..f37f7a993 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -308,22 +308,16 @@ async function loadServices(): Promise { { Config: ConfigClass }, { IpcMain: IpcMainClass }, { UpdaterService: UpdaterServiceClass }, - { TerminalWindowManager: TerminalWindowManagerClass }, ] = await Promise.all([ import("@/node/config"), import("@/node/services/ipcMain"), import("@/desktop/updater"), - import("@/desktop/terminalWindowManager"), ]); /* eslint-enable no-restricted-syntax */ config = new ConfigClass(); - ipcMain = new IpcMainClass(config); + ipcMain = new IpcMainClass(config, "desktop"); await ipcMain.initialize(); - // Set TerminalWindowManager for desktop mode (pop-out terminal windows) - const terminalWindowManager = new TerminalWindowManagerClass(config); - ipcMain.setTerminalWindowManager(terminalWindowManager); - loadTokenizerModules().catch((error) => { console.error("Failed to preload tokenizer modules:", error); }); diff --git a/src/desktop/terminalWindowManager.ts b/src/desktop/terminalWindowManager.ts deleted file mode 100644 index d63e1e995..000000000 --- a/src/desktop/terminalWindowManager.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Terminal Window Manager - * - * Manages pop-out terminal windows for workspaces. - * Each workspace can have multiple terminal windows open simultaneously. - */ - -import { BrowserWindow } from "electron"; -import * as path from "path"; -import { log } from "@/node/services/log"; -import type { Config } from "@/node/config"; - -export class TerminalWindowManager { - private windows = new Map>(); // workspaceId -> Set of windows - private windowCount = 0; // Counter for unique window IDs - private readonly config: Config; - - constructor(config: Config) { - this.config = config; - } - - /** - * Open a new terminal window for a workspace - * Multiple windows can be open for the same workspace - */ - async openTerminalWindow(workspaceId: string): Promise { - this.windowCount++; - const windowId = this.windowCount; - - // Look up workspace metadata to get project and branch names - const allWorkspaces = await this.config.getAllWorkspaceMetadata(); - const workspace = allWorkspaces.find((ws) => ws.id === workspaceId); - - let title: string; - if (workspace) { - title = `Terminal ${windowId} — ${workspace.projectName} (${workspace.name})`; - } else { - // Fallback if workspace not found - title = `Terminal ${windowId} — ${workspaceId}`; - } - - const terminalWindow = new BrowserWindow({ - width: 1000, - height: 600, - title, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - // __dirname is dist/services/ but preload.js is in dist/ - preload: path.join(__dirname, "../preload.js"), - }, - backgroundColor: "#1e1e1e", - }); - - // Track the window - if (!this.windows.has(workspaceId)) { - this.windows.set(workspaceId, new Set()); - } - this.windows.get(workspaceId)!.add(terminalWindow); - - // Clean up when window is closed - terminalWindow.on("closed", () => { - const windowSet = this.windows.get(workspaceId); - if (windowSet) { - windowSet.delete(terminalWindow); - if (windowSet.size === 0) { - this.windows.delete(workspaceId); - } - } - log.info(`Terminal window ${windowId} closed for workspace: ${workspaceId}`); - }); - - // Load the terminal page - const isDev = !process.env.NODE_ENV || process.env.NODE_ENV === "development"; - - if (isDev) { - // Development mode - load from Vite dev server - await terminalWindow.loadURL( - `http://localhost:5173/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}` - ); - terminalWindow.webContents.openDevTools(); - } else { - // Production mode - load from built files - await terminalWindow.loadFile(path.join(__dirname, "../terminal.html"), { - query: { workspaceId }, - }); - } - - log.info(`Terminal window ${windowId} opened for workspace: ${workspaceId}`); - } - - /** - * Close all terminal windows for a workspace - */ - closeTerminalWindow(workspaceId: string): void { - const windowSet = this.windows.get(workspaceId); - if (windowSet) { - for (const window of windowSet) { - if (!window.isDestroyed()) { - window.close(); - } - } - this.windows.delete(workspaceId); - } - } - - /** - * Close all terminal windows for all workspaces - */ - closeAll(): void { - for (const [workspaceId, windowSet] of this.windows.entries()) { - for (const window of windowSet) { - if (!window.isDestroyed()) { - window.close(); - } - } - this.windows.delete(workspaceId); - } - } - - /** - * Get all windows for a workspace - */ - getWindows(workspaceId: string): BrowserWindow[] { - const windowSet = this.windows.get(workspaceId); - if (!windowSet) return []; - return Array.from(windowSet).filter((w) => !w.isDestroyed()); - } - - /** - * Get count of open terminal windows for a workspace - */ - getWindowCount(workspaceId: string): number { - return this.getWindows(workspaceId).length; - } -} diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index d76819023..f714333af 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -31,7 +31,7 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; import { validateProjectPath } from "@/node/utils/pathUtils"; import { PTYService } from "@/node/services/ptyService"; -import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; + import type { TerminalCreateParams, TerminalResizeParams } from "@/common/types/terminal"; import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; import { generateWorkspaceName } from "./workspaceTitleGenerator"; @@ -56,7 +56,7 @@ export class IpcMain { private readonly initStateManager: InitStateManager; private readonly extensionMetadata: ExtensionMetadataService; private readonly ptyService: PTYService; - private terminalWindowManager?: TerminalWindowManager; + private readonly mode: "desktop" | "browser"; private readonly sessions = new Map(); private readonly sessionSubscriptions = new Map< string, @@ -66,8 +66,9 @@ export class IpcMain { private registered = false; - constructor(config: Config) { + constructor(config: Config, mode: "desktop" | "browser" = "browser") { this.config = config; + this.mode = mode; this.historyService = new HistoryService(config); this.partialService = new PartialService(config, this.historyService); this.initStateManager = new InitStateManager(config); @@ -95,13 +96,7 @@ export class IpcMain { await this.extensionMetadata.initialize(); } - /** - * Set the terminal window manager (desktop mode only). - * Server mode doesn't use pop-out terminal windows. - */ - setTerminalWindowManager(manager: TerminalWindowManager): void { - this.terminalWindowManager = manager; - } + /** * Setup listeners to update metadata store based on AIService events. @@ -1599,47 +1594,37 @@ export class IpcMain { } const runtimeConfig = workspace.runtimeConfig; - const isSSH = isSSHRuntime(runtimeConfig); - const isDesktop = !!this.terminalWindowManager; - - // Terminal routing logic: - // - Desktop + Local: Native terminal - // - Desktop + SSH: Web terminal (ghostty-web Electron window) - // - Browser + Local: Web terminal (browser tab) - // - Browser + SSH: Web terminal (browser tab) - if (isDesktop && !isSSH) { - // Desktop + Local: Native terminal - log.info(`Opening native terminal for local workspace: ${workspaceId}`); - await this.openTerminal({ type: "local", workspacePath: workspace.namedWorkspacePath }); - } else if (isDesktop && isSSH) { - // Desktop + SSH: Web terminal (ghostty-web Electron window) - log.info(`Opening ghostty-web terminal for SSH workspace: ${workspaceId}`); - await this.terminalWindowManager!.openTerminalWindow(workspaceId); + const isDesktop = this.mode === "desktop"; + + if (isDesktop) { + // Desktop mode: Always use native terminal (both local and SSH) + if (isSSHRuntime(runtimeConfig)) { + log.info(`Opening native SSH terminal for workspace: ${workspaceId}`); + await this.openTerminal({ + type: "ssh", + sshConfig: runtimeConfig, + remotePath: workspace.namedWorkspacePath, + }); + } else { + log.info(`Opening native terminal for local workspace: ${workspaceId}`); + await this.openTerminal({ type: "local", workspacePath: workspace.namedWorkspacePath }); + } } else { - // Browser mode (local or SSH): Web terminal (browser window) - // Browser will handle opening the terminal window via window.open() + // Browser mode: Web terminal handled by browser (window.open) log.info( - `Browser mode: terminal UI handled by browser for ${isSSH ? "SSH" : "local"} workspace: ${workspaceId}` + `Browser mode: terminal UI handled by browser for ${isSSHRuntime(runtimeConfig) ? "SSH" : "local"} workspace: ${workspaceId}` ); } - - log.info(`Terminal opened successfully for workspace: ${workspaceId}`); } catch (err) { log.error("Error opening terminal window:", err); throw err; } }); - ipcMain.handle(IPC_CHANNELS.TERMINAL_WINDOW_CLOSE, (_event, workspaceId: string) => { - try { - if (!this.terminalWindowManager) { - throw new Error("Terminal window manager not available (desktop mode only)"); - } - this.terminalWindowManager.closeTerminalWindow(workspaceId); - } catch (err) { - log.error("Error closing terminal window:", err); - throw err; - } + ipcMain.handle(IPC_CHANNELS.TERMINAL_WINDOW_CLOSE, () => { + // No-op: Desktop mode uses native terminals (user closes them directly) + // Browser mode handles closing via browser (user closes tab/window) + return Promise.resolve(); }); } From 86afbafdcfd9efaefb61116e75a68569a795e89c Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 19 Nov 2025 19:50:42 +0000 Subject: [PATCH 2/8] fix: open Ghostty in new window for local workspaces Use -n flag and --working-directory arg to ensure each 'Open Terminal' command spawns a new Ghostty window instead of reusing an existing tab. Generated with `mux` --- src/node/services/ipcMain.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index f714333af..1b7f99d0f 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1748,9 +1748,9 @@ export class IpcMain { const sshCommand = ["ssh", ...sshArgs].join(" "); args = ["-n", "-a", "Ghostty", "--args", `--command=${sshCommand}`]; } else { - // Ghostty: Pass workspacePath to 'open -a Ghostty' to avoid regressions + // Ghostty: Use -n to open new window, pass working directory via --args if (config.type !== "local") throw new Error("Expected local config"); - args = ["-a", "Ghostty", config.workspacePath]; + args = ["-n", "-a", "Ghostty", "--args", `--working-directory=${config.workspacePath}`]; } log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); const child = spawn(cmd, args, { From 0de58538e067a646fee59d183e840c8cf9f1a4e0 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 19 Nov 2025 19:55:43 +0000 Subject: [PATCH 3/8] fix: improve SSH terminal connection reliability Match PTYService's SSH args building for native terminals: - Use expandTildeForSSH for proper path quoting and $HOME expansion - Add connection multiplexing (ControlMaster) for faster connections - Add connection timeouts and keep-alive settings - Add StrictHostKeyChecking=no when using identity file - Use 'exec $SHELL -i' for interactive shell (not just exec $SHELL) This fixes the issue where SSH terminals would open and immediately exit. Generated with `mux` --- src/node/services/ipcMain.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 1b7f99d0f..1ffaa3eb8 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -31,7 +31,8 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; import { validateProjectPath } from "@/node/utils/pathUtils"; import { PTYService } from "@/node/services/ptyService"; - +import { expandTildeForSSH } from "@/node/runtime/tildeExpansion"; +import { getControlPath } from "@/node/runtime/sshConnectionPool"; import type { TerminalCreateParams, TerminalResizeParams } from "@/common/types/terminal"; import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; import { generateWorkspaceName } from "./workspaceTitleGenerator"; @@ -1723,7 +1724,19 @@ export class IpcMain { // Add identity file if specified if (config.sshConfig.identityFile) { sshArgs.push("-i", config.sshConfig.identityFile); + sshArgs.push("-o", "StrictHostKeyChecking=no"); + sshArgs.push("-o", "UserKnownHostsFile=/dev/null"); + sshArgs.push("-o", "LogLevel=ERROR"); } + // Add connection multiplexing (reuse SSHRuntime's controlPath logic) + const controlPath = getControlPath(config.sshConfig); + sshArgs.push("-o", "ControlMaster=auto"); + sshArgs.push("-o", `ControlPath=${controlPath}`); + sshArgs.push("-o", "ControlPersist=60"); + // Add connection timeout + sshArgs.push("-o", "ConnectTimeout=15"); + sshArgs.push("-o", "ServerAliveInterval=5"); + sshArgs.push("-o", "ServerAliveCountMax=2"); // Force pseudo-terminal allocation sshArgs.push("-t"); // Add host From 49873ed813a6811c4eb55862b626d4e03c223aa8 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 19 Nov 2025 19:57:47 +0000 Subject: [PATCH 4/8] fix: use expandTildeForSSH for proper path quoting in SSH terminals This completes the SSH terminal improvements by using expandTildeForSSH for proper $HOME expansion and path quoting instead of single-quote escaping. Generated with `mux` --- src/node/services/ipcMain.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 1ffaa3eb8..383c28f1c 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1742,9 +1742,9 @@ export class IpcMain { // Add host sshArgs.push(config.sshConfig.host); // Add remote command to cd into directory and start shell - // Use single quotes to prevent local shell expansion - // exec $SHELL replaces the SSH process with the shell, avoiding nested processes - sshArgs.push(`cd '${config.remotePath.replace(/'/g, "'\\''")}' && exec $SHELL`); + // expandTildeForSSH handles proper quoting and $HOME expansion + const expandedPath = expandTildeForSSH(config.remotePath); + sshArgs.push(`cd ${expandedPath} && exec $SHELL -i`); } const logPrefix = isSSH ? "SSH terminal" : "terminal"; From de01520523b43a1d04fa82954094b3569c51b351 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 19 Nov 2025 20:02:22 +0000 Subject: [PATCH 5/8] fix: call ghostty directly for SSH terminals instead of using open --command The issue was that using 'open -a Ghostty --args --command=...' would cause the terminal to exit immediately after the SSH command completes. Instead, call 'ghostty ssh ...' directly, which keeps the terminal open for the interactive SSH session. Generated with `mux` --- src/node/services/ipcMain.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 383c28f1c..cdb1aefee 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1753,16 +1753,17 @@ export class IpcMain { // macOS - try Ghostty first, fallback to Terminal.app const terminal = await this.findAvailableCommand(["ghostty", "terminal"]); if (terminal === "ghostty") { - const cmd = "open"; + let cmd: string; let args: string[]; if (isSSH && sshArgs) { - // Ghostty: Use --command flag to run SSH - // Build the full SSH command as a single string - const sshCommand = ["ssh", ...sshArgs].join(" "); - args = ["-n", "-a", "Ghostty", "--args", `--command=${sshCommand}`]; + // Ghostty: Call ghostty directly with SSH command + // This ensures the terminal stays open for the interactive SSH session + cmd = "ghostty"; + args = ["ssh", ...sshArgs]; } else { - // Ghostty: Use -n to open new window, pass working directory via --args + // Ghostty: Use open with working directory for local terminals if (config.type !== "local") throw new Error("Expected local config"); + cmd = "open"; args = ["-n", "-a", "Ghostty", "--args", `--working-directory=${config.workspacePath}`]; } log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); From 795c007e00cbda90025e747b65f6a8379e428dde Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 19 Nov 2025 20:04:03 +0000 Subject: [PATCH 6/8] fix: use Terminal.app for SSH connections on macOS Ghostty doesn't reliably handle SSH via 'ghostty ssh ...' command. Use proven Terminal.app + osascript approach for SSH terminals, keep Ghostty for local terminals where it works well. Generated with `mux` --- src/node/services/ipcMain.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index cdb1aefee..a3bc7d27b 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1750,8 +1750,8 @@ export class IpcMain { const logPrefix = isSSH ? "SSH terminal" : "terminal"; if (process.platform === "darwin") { - // macOS - try Ghostty first, fallback to Terminal.app - const terminal = await this.findAvailableCommand(["ghostty", "terminal"]); + // macOS - try Ghostty for local, always use Terminal.app for SSH (proven to work) + const terminal = isSSH ? "terminal" : await this.findAvailableCommand(["ghostty", "terminal"]); if (terminal === "ghostty") { let cmd: string; let args: string[]; From b87e4b0b08220b1cd80c1977e47039b55182ce56 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 19 Nov 2025 20:26:13 +0000 Subject: [PATCH 7/8] fix: use simplified SSH command format for Ghostty Match the working format: ssh -t host 'cd /path && exec $SHELL' - Remove ControlMaster and other complex SSH options (not needed for native terminals) - Use single-quoted remote command - Properly escape $SHELL in the command string - Call via 'open -n -a Ghostty --args --command=...' Generated with `mux` --- src/node/services/ipcMain.ts | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index a3bc7d27b..7f3aac44a 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1750,20 +1750,35 @@ export class IpcMain { const logPrefix = isSSH ? "SSH terminal" : "terminal"; if (process.platform === "darwin") { - // macOS - try Ghostty for local, always use Terminal.app for SSH (proven to work) - const terminal = isSSH ? "terminal" : await this.findAvailableCommand(["ghostty", "terminal"]); + // macOS - try Ghostty first, fallback to Terminal.app + const terminal = await this.findAvailableCommand(["ghostty", "terminal"]); if (terminal === "ghostty") { - let cmd: string; + const cmd = "open"; let args: string[]; if (isSSH && sshArgs) { - // Ghostty: Call ghostty directly with SSH command - // This ensures the terminal stays open for the interactive SSH session - cmd = "ghostty"; - args = ["ssh", ...sshArgs]; + // Ghostty: Build SSH command with single-quoted remote command + // Format: ssh -t host 'cd /path && exec $SHELL' + // The remote command uses single quotes and plain $SHELL (not \$SHELL -i) + const expandedPath = expandTildeForSSH(config.remotePath); + const remoteCommand = `cd ${expandedPath} && exec \\$SHELL`; + + // Build SSH args without the remote command (we'll add it separately) + const sshBaseArgs: string[] = []; + if (config.sshConfig.port) { + sshBaseArgs.push("-p", String(config.sshConfig.port)); + } + if (config.sshConfig.identityFile) { + sshBaseArgs.push("-i", config.sshConfig.identityFile); + } + sshBaseArgs.push("-t"); + sshBaseArgs.push(config.sshConfig.host); + + // Build the full SSH command string with single-quoted remote command + const sshCommand = `ssh ${sshBaseArgs.join(" ")} '${remoteCommand.replace(/'/g, "'\\''")}'`; + args = ["-n", "-a", "Ghostty", "--args", `--command=${sshCommand}`]; } else { - // Ghostty: Use open with working directory for local terminals + // Ghostty: Use -n to open new window, pass working directory via --args if (config.type !== "local") throw new Error("Expected local config"); - cmd = "open"; args = ["-n", "-a", "Ghostty", "--args", `--working-directory=${config.workspacePath}`]; } log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); From e519af1f37fc2fd3102ae2300e04c3382b7223c5 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 19 Nov 2025 20:33:50 +0000 Subject: [PATCH 8/8] fix: open local terminals in tabs instead of new windows Revert to main's behavior for local Ghostty terminals: - Remove -n flag (don't force new instance) - Pass directory path directly to 'open -a Ghostty /path' - This opens new tabs in existing Ghostty window instead of new windows SSH terminals still use -n to ensure new window per connection. Generated with `mux` --- src/node/services/ipcMain.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 7f3aac44a..0d6d71b4a 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1777,9 +1777,9 @@ export class IpcMain { const sshCommand = `ssh ${sshBaseArgs.join(" ")} '${remoteCommand.replace(/'/g, "'\\''")}'`; args = ["-n", "-a", "Ghostty", "--args", `--command=${sshCommand}`]; } else { - // Ghostty: Use -n to open new window, pass working directory via --args + // Ghostty: Pass workspacePath to 'open -a Ghostty' to open in new tab if (config.type !== "local") throw new Error("Expected local config"); - args = ["-n", "-a", "Ghostty", "--args", `--working-directory=${config.workspacePath}`]; + args = ["-a", "Ghostty", config.workspacePath]; } log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`); const child = spawn(cmd, args, {