Skip to content

Commit 3a00c83

Browse files
🤖 fix: support SSH workspaces in Open Terminal button (#557)
Fixes the "Open Terminal" button for SSH workspaces, which was failing with ENOENT errors because it tried to open a local path that doesn't exist locally. ## Changes The IPC handler now accepts `workspaceId` instead of `workspacePath`, looks up the workspace metadata, and spawns the appropriate terminal: - **SSH workspaces**: Spawns a local terminal that runs `ssh -t <host> 'cd <path> && exec $SHELL'` - Supports SSH options: port (`-p`), identity file (`-i`) - Works on all platforms: macOS (Ghostty, Terminal.app), Windows (cmd), Linux (9+ terminal emulators) - **Local workspaces**: Works exactly as before (spawn terminal with cwd set) All callers updated to pass `workspaceId` instead of `namedWorkspacePath`. ## Testing - `make typecheck` passes ✅ - For local workspaces: Should work exactly as before - For SSH workspaces: Should spawn a local terminal that SSHs in and opens in the workspace directory _Generated with `cmux`_
1 parent 712c5de commit 3a00c83

File tree

10 files changed

+207
-86
lines changed

10 files changed

+207
-86
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"lockfileVersion": 1,
3+
"configVersion": 0,
34
"workspaces": {
45
"": {
56
"name": "mux",

src/App.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,16 +155,9 @@ function AppInner() {
155155
}
156156
}, [selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);
157157

158-
const openWorkspaceInTerminal = useCallback(
159-
(workspaceId: string) => {
160-
// Look up workspace metadata to get the workspace path (directory uses workspace name)
161-
const metadata = workspaceMetadata.get(workspaceId);
162-
if (metadata) {
163-
void window.api.workspace.openTerminal(metadata.namedWorkspacePath);
164-
}
165-
},
166-
[workspaceMetadata]
167-
);
158+
const openWorkspaceInTerminal = useCallback((workspaceId: string) => {
159+
void window.api.workspace.openTerminal(workspaceId);
160+
}, []);
168161

169162
const handleRemoveProject = useCallback(
170163
async (path: string) => {

src/browser/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ const webApi: IPCApi = {
232232
getInfo: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId),
233233
executeBash: (workspaceId, script, options) =>
234234
invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options),
235-
openTerminal: (workspacePath) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath),
235+
openTerminal: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspaceId),
236236

237237
onChat: (workspaceId, callback) => {
238238
const channel = getChatChannel(workspaceId);

src/components/AIView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
168168
);
169169

170170
const handleOpenTerminal = useCallback(() => {
171-
void window.api.workspace.openTerminal(namedWorkspacePath);
172-
}, [namedWorkspacePath]);
171+
void window.api.workspace.openTerminal(workspaceId);
172+
}, [workspaceId]);
173173

174174
// Auto-scroll when messages or todos update (during streaming)
175175
useEffect(() => {

src/components/RuntimeBadge.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from "react";
22
import { cn } from "@/lib/utils";
33
import type { RuntimeConfig } from "@/types/runtime";
4+
import { isSSHRuntime } from "@/types/runtime";
45
import { extractSshHostname } from "@/utils/ui/runtimeBadge";
56
import { TooltipWrapper, Tooltip } from "./Tooltip";
67

@@ -48,7 +49,7 @@ export function RuntimeBadge({ runtimeConfig, className }: RuntimeBadgeProps) {
4849
</svg>
4950
</span>
5051
<Tooltip align="right">
51-
SSH: {runtimeConfig?.type === "ssh" ? runtimeConfig.host : hostname}
52+
SSH: {isSSHRuntime(runtimeConfig) ? runtimeConfig.host : hostname}
5253
</Tooltip>
5354
</TooltipWrapper>
5455
);

src/components/WorkspaceHeader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
2424
}) => {
2525
const gitStatus = useGitStatus(workspaceId);
2626
const handleOpenTerminal = useCallback(() => {
27-
void window.api.workspace.openTerminal(namedWorkspacePath);
28-
}, [namedWorkspacePath]);
27+
void window.api.workspace.openTerminal(workspaceId);
28+
}, [workspaceId]);
2929

3030
return (
3131
<div className="bg-separator border-border-light flex items-center justify-between border-b px-[15px] py-1 [@media(max-width:768px)]:flex-wrap [@media(max-width:768px)]:gap-2 [@media(max-width:768px)]:py-2 [@media(max-width:768px)]:pl-[60px]">

src/preload.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ const api: IPCApi = {
8181
getInfo: (workspaceId) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId),
8282
executeBash: (workspaceId, script, options) =>
8383
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options),
84-
openTerminal: (workspacePath) =>
85-
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath),
84+
openTerminal: (workspaceId) =>
85+
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspaceId),
8686

8787
onChat: (workspaceId: string, callback) => {
8888
const channel = getChatChannel(workspaceId);

src/services/ipcMain.ts

Lines changed: 183 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { DisposableTempDir } from "@/services/tempDir";
2828
import { InitStateManager } from "@/services/initStateManager";
2929
import { createRuntime } from "@/runtime/runtimeFactory";
3030
import type { RuntimeConfig } from "@/types/runtime";
31+
import { isSSHRuntime } from "@/types/runtime";
3132
import { validateProjectPath } from "@/utils/pathUtils";
3233
import { ExtensionMetadataService } from "@/services/ExtensionMetadataService";
3334
/**
@@ -957,76 +958,29 @@ export class IpcMain {
957958
}
958959
);
959960

960-
ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, async (_event, workspacePath: string) => {
961+
ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, async (_event, workspaceId: string) => {
961962
try {
962-
if (process.platform === "darwin") {
963-
// macOS - try Ghostty first, fallback to Terminal.app
964-
const terminal = await this.findAvailableCommand(["ghostty", "terminal"]);
965-
if (terminal === "ghostty") {
966-
// Match main: pass workspacePath to 'open -a Ghostty' to avoid regressions
967-
const cmd = "open";
968-
const args = ["-a", "Ghostty", workspacePath];
969-
log.info(`Opening terminal: ${cmd} ${args.join(" ")}`);
970-
const child = spawn(cmd, args, {
971-
detached: true,
972-
stdio: "ignore",
973-
});
974-
child.unref();
975-
} else {
976-
// Terminal.app opens in the directory when passed as argument
977-
const cmd = "open";
978-
const args = ["-a", "Terminal", workspacePath];
979-
log.info(`Opening terminal: ${cmd} ${args.join(" ")}`);
980-
const child = spawn(cmd, args, {
981-
detached: true,
982-
stdio: "ignore",
983-
});
984-
child.unref();
985-
}
986-
} else if (process.platform === "win32") {
987-
// Windows
988-
const cmd = "cmd";
989-
const args = ["/c", "start", "cmd", "/K", "cd", "/D", workspacePath];
990-
log.info(`Opening terminal: ${cmd} ${args.join(" ")}`);
991-
const child = spawn(cmd, args, {
992-
detached: true,
993-
shell: true,
994-
stdio: "ignore",
963+
// Look up workspace metadata to get runtime config
964+
const allMetadata = await this.config.getAllWorkspaceMetadata();
965+
const workspace = allMetadata.find((w) => w.id === workspaceId);
966+
967+
if (!workspace) {
968+
log.error(`Workspace not found: ${workspaceId}`);
969+
return;
970+
}
971+
972+
const runtimeConfig = workspace.runtimeConfig;
973+
974+
if (isSSHRuntime(runtimeConfig)) {
975+
// SSH workspace - spawn local terminal that SSHs into remote host
976+
await this.openTerminal({
977+
type: "ssh",
978+
sshConfig: runtimeConfig,
979+
remotePath: workspace.namedWorkspacePath,
995980
});
996-
child.unref();
997981
} else {
998-
// Linux - try terminal emulators in order of preference
999-
// x-terminal-emulator is checked first as it respects user's system-wide preference
1000-
const terminals = [
1001-
{ cmd: "x-terminal-emulator", args: [], cwd: workspacePath },
1002-
{ cmd: "ghostty", args: ["--working-directory=" + workspacePath] },
1003-
{ cmd: "alacritty", args: ["--working-directory", workspacePath] },
1004-
{ cmd: "kitty", args: ["--directory", workspacePath] },
1005-
{ cmd: "wezterm", args: ["start", "--cwd", workspacePath] },
1006-
{ cmd: "gnome-terminal", args: ["--working-directory", workspacePath] },
1007-
{ cmd: "konsole", args: ["--workdir", workspacePath] },
1008-
{ cmd: "xfce4-terminal", args: ["--working-directory", workspacePath] },
1009-
{ cmd: "xterm", args: [], cwd: workspacePath },
1010-
];
1011-
1012-
const availableTerminal = await this.findAvailableTerminal(terminals);
1013-
1014-
if (availableTerminal) {
1015-
const cwdInfo = availableTerminal.cwd ? ` (cwd: ${availableTerminal.cwd})` : "";
1016-
log.info(
1017-
`Opening terminal: ${availableTerminal.cmd} ${availableTerminal.args.join(" ")}${cwdInfo}`
1018-
);
1019-
const child = spawn(availableTerminal.cmd, availableTerminal.args, {
1020-
cwd: availableTerminal.cwd ?? workspacePath,
1021-
detached: true,
1022-
stdio: "ignore",
1023-
});
1024-
child.unref();
1025-
} else {
1026-
log.error(
1027-
"No terminal emulator found. Tried: " + terminals.map((t) => t.cmd).join(", ")
1028-
);
1029-
}
982+
// Local workspace - spawn terminal with cwd set
983+
await this.openTerminal({ type: "local", workspacePath: workspace.namedWorkspacePath });
1030984
}
1031985
} catch (error) {
1032986
const message = error instanceof Error ? error.message : String(error);
@@ -1383,6 +1337,168 @@ export class IpcMain {
13831337
}
13841338
}
13851339

1340+
/**
1341+
* Open a terminal (local or SSH) with platform-specific handling
1342+
*/
1343+
private async openTerminal(
1344+
config:
1345+
| { type: "local"; workspacePath: string }
1346+
| {
1347+
type: "ssh";
1348+
sshConfig: Extract<RuntimeConfig, { type: "ssh" }>;
1349+
remotePath: string;
1350+
}
1351+
): Promise<void> {
1352+
const isSSH = config.type === "ssh";
1353+
1354+
// Build SSH args if needed
1355+
let sshArgs: string[] | null = null;
1356+
if (isSSH) {
1357+
sshArgs = [];
1358+
// Add port if specified
1359+
if (config.sshConfig.port) {
1360+
sshArgs.push("-p", String(config.sshConfig.port));
1361+
}
1362+
// Add identity file if specified
1363+
if (config.sshConfig.identityFile) {
1364+
sshArgs.push("-i", config.sshConfig.identityFile);
1365+
}
1366+
// Force pseudo-terminal allocation
1367+
sshArgs.push("-t");
1368+
// Add host
1369+
sshArgs.push(config.sshConfig.host);
1370+
// Add remote command to cd into directory and start shell
1371+
// Use single quotes to prevent local shell expansion
1372+
// exec $SHELL replaces the SSH process with the shell, avoiding nested processes
1373+
sshArgs.push(`cd '${config.remotePath.replace(/'/g, "'\\''")}' && exec $SHELL`);
1374+
}
1375+
1376+
const logPrefix = isSSH ? "SSH terminal" : "terminal";
1377+
1378+
if (process.platform === "darwin") {
1379+
// macOS - try Ghostty first, fallback to Terminal.app
1380+
const terminal = await this.findAvailableCommand(["ghostty", "terminal"]);
1381+
if (terminal === "ghostty") {
1382+
const cmd = "open";
1383+
let args: string[];
1384+
if (isSSH && sshArgs) {
1385+
// Ghostty: Use --command flag to run SSH
1386+
// Build the full SSH command as a single string
1387+
const sshCommand = ["ssh", ...sshArgs].join(" ");
1388+
args = ["-n", "-a", "Ghostty", "--args", `--command=${sshCommand}`];
1389+
} else {
1390+
// Ghostty: Pass workspacePath to 'open -a Ghostty' to avoid regressions
1391+
if (config.type !== "local") throw new Error("Expected local config");
1392+
args = ["-a", "Ghostty", config.workspacePath];
1393+
}
1394+
log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`);
1395+
const child = spawn(cmd, args, {
1396+
detached: true,
1397+
stdio: "ignore",
1398+
});
1399+
child.unref();
1400+
} else {
1401+
// Terminal.app
1402+
const cmd = isSSH ? "osascript" : "open";
1403+
let args: string[];
1404+
if (isSSH && sshArgs) {
1405+
// Terminal.app: Use osascript with proper AppleScript structure
1406+
// Properly escape single quotes in args before wrapping in quotes
1407+
const sshCommand = `ssh ${sshArgs
1408+
.map((arg) => {
1409+
if (arg.includes(" ") || arg.includes("'")) {
1410+
// Escape single quotes by ending quote, adding escaped quote, starting quote again
1411+
return `'${arg.replace(/'/g, "'\\''")}'`;
1412+
}
1413+
return arg;
1414+
})
1415+
.join(" ")}`;
1416+
// Escape double quotes for AppleScript string
1417+
const escapedCommand = sshCommand.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1418+
const script = `tell application "Terminal"\nactivate\ndo script "${escapedCommand}"\nend tell`;
1419+
args = ["-e", script];
1420+
} else {
1421+
// Terminal.app opens in the directory when passed as argument
1422+
if (config.type !== "local") throw new Error("Expected local config");
1423+
args = ["-a", "Terminal", config.workspacePath];
1424+
}
1425+
log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`);
1426+
const child = spawn(cmd, args, {
1427+
detached: true,
1428+
stdio: "ignore",
1429+
});
1430+
child.unref();
1431+
}
1432+
} else if (process.platform === "win32") {
1433+
// Windows
1434+
const cmd = "cmd";
1435+
let args: string[];
1436+
if (isSSH && sshArgs) {
1437+
// Windows - use cmd to start ssh
1438+
args = ["/c", "start", "cmd", "/K", "ssh", ...sshArgs];
1439+
} else {
1440+
if (config.type !== "local") throw new Error("Expected local config");
1441+
args = ["/c", "start", "cmd", "/K", "cd", "/D", config.workspacePath];
1442+
}
1443+
log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`);
1444+
const child = spawn(cmd, args, {
1445+
detached: true,
1446+
shell: true,
1447+
stdio: "ignore",
1448+
});
1449+
child.unref();
1450+
} else {
1451+
// Linux - try terminal emulators in order of preference
1452+
let terminals: Array<{ cmd: string; args: string[]; cwd?: string }>;
1453+
1454+
if (isSSH && sshArgs) {
1455+
// x-terminal-emulator is checked first as it respects user's system-wide preference
1456+
terminals = [
1457+
{ cmd: "x-terminal-emulator", args: ["-e", "ssh", ...sshArgs] },
1458+
{ cmd: "ghostty", args: ["ssh", ...sshArgs] },
1459+
{ cmd: "alacritty", args: ["-e", "ssh", ...sshArgs] },
1460+
{ cmd: "kitty", args: ["ssh", ...sshArgs] },
1461+
{ cmd: "wezterm", args: ["start", "--", "ssh", ...sshArgs] },
1462+
{ cmd: "gnome-terminal", args: ["--", "ssh", ...sshArgs] },
1463+
{ cmd: "konsole", args: ["-e", "ssh", ...sshArgs] },
1464+
{ cmd: "xfce4-terminal", args: ["-e", `ssh ${sshArgs.join(" ")}`] },
1465+
{ cmd: "xterm", args: ["-e", "ssh", ...sshArgs] },
1466+
];
1467+
} else {
1468+
if (config.type !== "local") throw new Error("Expected local config");
1469+
const workspacePath = config.workspacePath;
1470+
terminals = [
1471+
{ cmd: "x-terminal-emulator", args: [], cwd: workspacePath },
1472+
{ cmd: "ghostty", args: ["--working-directory=" + workspacePath] },
1473+
{ cmd: "alacritty", args: ["--working-directory", workspacePath] },
1474+
{ cmd: "kitty", args: ["--directory", workspacePath] },
1475+
{ cmd: "wezterm", args: ["start", "--cwd", workspacePath] },
1476+
{ cmd: "gnome-terminal", args: ["--working-directory", workspacePath] },
1477+
{ cmd: "konsole", args: ["--workdir", workspacePath] },
1478+
{ cmd: "xfce4-terminal", args: ["--working-directory", workspacePath] },
1479+
{ cmd: "xterm", args: [], cwd: workspacePath },
1480+
];
1481+
}
1482+
1483+
const availableTerminal = await this.findAvailableTerminal(terminals);
1484+
1485+
if (availableTerminal) {
1486+
const cwdInfo = availableTerminal.cwd ? ` (cwd: ${availableTerminal.cwd})` : "";
1487+
log.info(
1488+
`Opening ${logPrefix}: ${availableTerminal.cmd} ${availableTerminal.args.join(" ")}${cwdInfo}`
1489+
);
1490+
const child = spawn(availableTerminal.cmd, availableTerminal.args, {
1491+
cwd: availableTerminal.cwd,
1492+
detached: true,
1493+
stdio: "ignore",
1494+
});
1495+
child.unref();
1496+
} else {
1497+
log.error("No terminal emulator found. Tried: " + terminals.map((t) => t.cmd).join(", "));
1498+
}
1499+
}
1500+
}
1501+
13861502
/**
13871503
* Find the first available command from a list of commands
13881504
*/

src/stores/GitStatusStore.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "@/utils/git/gitStatus";
88
import { useSyncExternalStore } from "react";
99
import { MapStore } from "./MapStore";
10+
import { isSSHRuntime } from "@/types/runtime";
1011

1112
/**
1213
* External store for git status of all workspaces.
@@ -258,7 +259,7 @@ export class GitStatusStore {
258259
* For SSH workspaces: workspace ID (each has its own git repo)
259260
*/
260261
private getFetchKey(metadata: FrontendWorkspaceMetadata): string {
261-
const isSSH = metadata.runtimeConfig?.type === "ssh";
262+
const isSSH = isSSHRuntime(metadata.runtimeConfig);
262263
return isSSH ? metadata.id : metadata.projectName;
263264
}
264265

src/types/runtime.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,12 @@ export function buildRuntimeString(mode: RuntimeMode, host: string): string | un
7474
}
7575
return undefined;
7676
}
77+
78+
/**
79+
* Type guard to check if a runtime config is SSH
80+
*/
81+
export function isSSHRuntime(
82+
config: RuntimeConfig | undefined
83+
): config is Extract<RuntimeConfig, { type: "ssh" }> {
84+
return config?.type === "ssh";
85+
}

0 commit comments

Comments
 (0)