Skip to content

Commit d3a03ba

Browse files
committed
🤖 fix: support SSH workspaces in Open Terminal button
- Change IPC handler to accept workspaceId instead of workspacePath - Look up workspace metadata to determine if SSH or local - For SSH: spawn local terminal that runs 'ssh -t host "cd path && exec $SHELL"' - For local: spawn terminal with cwd set (existing behavior) - Support SSH options: port (-p), identity file (-i) - Handle all platforms: macOS (Ghostty, Terminal.app), Windows (cmd), Linux (9+ terminal emulators) - Update all callers to pass workspaceId instead of path Generated with `cmux`
1 parent 0bc0e7e commit d3a03ba

File tree

7 files changed

+179
-80
lines changed

7 files changed

+179
-80
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: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,9 @@ function AppInner() {
157157

158158
const openWorkspaceInTerminal = useCallback(
159159
(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-
}
160+
void window.api.workspace.openTerminal(workspaceId);
165161
},
166-
[workspaceMetadata]
162+
[]
167163
);
168164

169165
const handleRemoveProject = useCallback(

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
@@ -165,8 +165,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
165165
);
166166

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

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

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: 169 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -905,76 +905,30 @@ export class IpcMain {
905905
}
906906
);
907907

908-
ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, async (_event, workspacePath: string) => {
908+
ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, async (_event, workspaceId: string) => {
909909
try {
910-
if (process.platform === "darwin") {
911-
// macOS - try Ghostty first, fallback to Terminal.app
912-
const terminal = await this.findAvailableCommand(["ghostty", "terminal"]);
913-
if (terminal === "ghostty") {
914-
// Match main: pass workspacePath to 'open -a Ghostty' to avoid regressions
915-
const cmd = "open";
916-
const args = ["-a", "Ghostty", workspacePath];
917-
log.info(`Opening terminal: ${cmd} ${args.join(" ")}`);
918-
const child = spawn(cmd, args, {
919-
detached: true,
920-
stdio: "ignore",
921-
});
922-
child.unref();
923-
} else {
924-
// Terminal.app opens in the directory when passed as argument
925-
const cmd = "open";
926-
const args = ["-a", "Terminal", workspacePath];
927-
log.info(`Opening terminal: ${cmd} ${args.join(" ")}`);
928-
const child = spawn(cmd, args, {
929-
detached: true,
930-
stdio: "ignore",
931-
});
932-
child.unref();
933-
}
934-
} else if (process.platform === "win32") {
935-
// Windows
936-
const cmd = "cmd";
937-
const args = ["/c", "start", "cmd", "/K", "cd", "/D", workspacePath];
938-
log.info(`Opening terminal: ${cmd} ${args.join(" ")}`);
939-
const child = spawn(cmd, args, {
940-
detached: true,
941-
shell: true,
942-
stdio: "ignore",
910+
// Look up workspace metadata to get runtime config
911+
const allMetadata = this.config.getAllWorkspaceMetadata();
912+
const workspace = allMetadata.find((w) => w.id === workspaceId);
913+
914+
if (!workspace) {
915+
log.error(`Workspace not found: ${workspaceId}`);
916+
return;
917+
}
918+
919+
const runtimeConfig = workspace.runtimeConfig;
920+
const isSSH = runtimeConfig?.type === "ssh";
921+
922+
if (isSSH && runtimeConfig.type === "ssh") {
923+
// SSH workspace - spawn local terminal that SSHs into remote host
924+
await this.openTerminal({
925+
type: "ssh",
926+
sshConfig: runtimeConfig,
927+
remotePath: workspace.namedWorkspacePath,
943928
});
944-
child.unref();
945929
} else {
946-
// Linux - try terminal emulators in order of preference
947-
// x-terminal-emulator is checked first as it respects user's system-wide preference
948-
const terminals = [
949-
{ cmd: "x-terminal-emulator", args: [], cwd: workspacePath },
950-
{ cmd: "ghostty", args: ["--working-directory=" + workspacePath] },
951-
{ cmd: "alacritty", args: ["--working-directory", workspacePath] },
952-
{ cmd: "kitty", args: ["--directory", workspacePath] },
953-
{ cmd: "wezterm", args: ["start", "--cwd", workspacePath] },
954-
{ cmd: "gnome-terminal", args: ["--working-directory", workspacePath] },
955-
{ cmd: "konsole", args: ["--workdir", workspacePath] },
956-
{ cmd: "xfce4-terminal", args: ["--working-directory", workspacePath] },
957-
{ cmd: "xterm", args: [], cwd: workspacePath },
958-
];
959-
960-
const availableTerminal = await this.findAvailableTerminal(terminals);
961-
962-
if (availableTerminal) {
963-
const cwdInfo = availableTerminal.cwd ? ` (cwd: ${availableTerminal.cwd})` : "";
964-
log.info(
965-
`Opening terminal: ${availableTerminal.cmd} ${availableTerminal.args.join(" ")}${cwdInfo}`
966-
);
967-
const child = spawn(availableTerminal.cmd, availableTerminal.args, {
968-
cwd: availableTerminal.cwd ?? workspacePath,
969-
detached: true,
970-
stdio: "ignore",
971-
});
972-
child.unref();
973-
} else {
974-
log.error(
975-
"No terminal emulator found. Tried: " + terminals.map((t) => t.cmd).join(", ")
976-
);
977-
}
930+
// Local workspace - spawn terminal with cwd set
931+
await this.openTerminal({ type: "local", workspacePath: workspace.namedWorkspacePath });
978932
}
979933
} catch (error) {
980934
const message = error instanceof Error ? error.message : String(error);
@@ -1326,6 +1280,154 @@ export class IpcMain {
13261280
}
13271281
}
13281282

1283+
/**
1284+
* Open a terminal (local or SSH) with platform-specific handling
1285+
*/
1286+
private async openTerminal(
1287+
config:
1288+
| { type: "local"; workspacePath: string }
1289+
| {
1290+
type: "ssh";
1291+
sshConfig: Extract<RuntimeConfig, { type: "ssh" }>;
1292+
remotePath: string;
1293+
}
1294+
): Promise<void> {
1295+
// Build SSH args if needed
1296+
let sshArgs: string[] | null = null;
1297+
if (config.type === "ssh") {
1298+
sshArgs = [];
1299+
// Add port if specified
1300+
if (config.sshConfig.port) {
1301+
sshArgs.push("-p", String(config.sshConfig.port));
1302+
}
1303+
// Add identity file if specified
1304+
if (config.sshConfig.identityFile) {
1305+
sshArgs.push("-i", config.sshConfig.identityFile);
1306+
}
1307+
// Force pseudo-terminal allocation
1308+
sshArgs.push("-t");
1309+
// Add host
1310+
sshArgs.push(config.sshConfig.host);
1311+
// Add remote command to cd into directory and start shell
1312+
// Use single quotes to prevent local shell expansion
1313+
// exec $SHELL replaces the SSH process with the shell, avoiding nested processes
1314+
sshArgs.push(`cd '${config.remotePath.replace(/'/g, "'\\''")}' && exec $SHELL`);
1315+
}
1316+
1317+
const isSSH = config.type === "ssh";
1318+
const logPrefix = isSSH ? "SSH terminal" : "terminal";
1319+
1320+
if (process.platform === "darwin") {
1321+
// macOS - try Ghostty first, fallback to Terminal.app
1322+
const terminal = await this.findAvailableCommand(["ghostty", "terminal"]);
1323+
if (terminal === "ghostty") {
1324+
const cmd = "open";
1325+
let args: string[];
1326+
if (isSSH && sshArgs) {
1327+
// Ghostty: Run ssh command directly
1328+
args = ["-a", "Ghostty", "--args", "ssh", ...sshArgs];
1329+
} else {
1330+
// Ghostty: Pass workspacePath to 'open -a Ghostty' to avoid regressions
1331+
if (config.type !== "local") throw new Error("Expected local config");
1332+
args = ["-a", "Ghostty", config.workspacePath];
1333+
}
1334+
log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`);
1335+
const child = spawn(cmd, args, {
1336+
detached: true,
1337+
stdio: "ignore",
1338+
});
1339+
child.unref();
1340+
} else {
1341+
// Terminal.app
1342+
const cmd = isSSH ? "osascript" : "open";
1343+
let args: string[];
1344+
if (isSSH && sshArgs) {
1345+
// Terminal.app: Use osascript to run ssh command
1346+
const sshCommand = `ssh ${sshArgs.map((arg) => (arg.includes(" ") ? `'${arg}'` : arg)).join(" ")}`;
1347+
const script = `tell application "Terminal" to do script "${sshCommand.replace(/"/g, '\\"')}"`;
1348+
args = ["-e", script];
1349+
} else {
1350+
// Terminal.app opens in the directory when passed as argument
1351+
if (config.type !== "local") throw new Error("Expected local config");
1352+
args = ["-a", "Terminal", config.workspacePath];
1353+
}
1354+
log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`);
1355+
const child = spawn(cmd, args, {
1356+
detached: true,
1357+
stdio: "ignore",
1358+
});
1359+
child.unref();
1360+
}
1361+
} else if (process.platform === "win32") {
1362+
// Windows
1363+
const cmd = "cmd";
1364+
let args: string[];
1365+
if (isSSH && sshArgs) {
1366+
// Windows - use cmd to start ssh
1367+
args = ["/c", "start", "cmd", "/K", "ssh", ...sshArgs];
1368+
} else {
1369+
if (config.type !== "local") throw new Error("Expected local config");
1370+
args = ["/c", "start", "cmd", "/K", "cd", "/D", config.workspacePath];
1371+
}
1372+
log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`);
1373+
const child = spawn(cmd, args, {
1374+
detached: true,
1375+
shell: true,
1376+
stdio: "ignore",
1377+
});
1378+
child.unref();
1379+
} else {
1380+
// Linux - try terminal emulators in order of preference
1381+
let terminals: Array<{ cmd: string; args: string[]; cwd?: string }>;
1382+
1383+
if (isSSH && sshArgs) {
1384+
// x-terminal-emulator is checked first as it respects user's system-wide preference
1385+
terminals = [
1386+
{ cmd: "x-terminal-emulator", args: ["-e", "ssh", ...sshArgs] },
1387+
{ cmd: "ghostty", args: ["ssh", ...sshArgs] },
1388+
{ cmd: "alacritty", args: ["-e", "ssh", ...sshArgs] },
1389+
{ cmd: "kitty", args: ["ssh", ...sshArgs] },
1390+
{ cmd: "wezterm", args: ["start", "--", "ssh", ...sshArgs] },
1391+
{ cmd: "gnome-terminal", args: ["--", "ssh", ...sshArgs] },
1392+
{ cmd: "konsole", args: ["-e", "ssh", ...sshArgs] },
1393+
{ cmd: "xfce4-terminal", args: ["-e", `ssh ${sshArgs.join(" ")}`] },
1394+
{ cmd: "xterm", args: ["-e", "ssh", ...sshArgs] },
1395+
];
1396+
} else {
1397+
if (config.type !== "local") throw new Error("Expected local config");
1398+
const workspacePath = config.workspacePath;
1399+
terminals = [
1400+
{ cmd: "x-terminal-emulator", args: [], cwd: workspacePath },
1401+
{ cmd: "ghostty", args: ["--working-directory=" + workspacePath] },
1402+
{ cmd: "alacritty", args: ["--working-directory", workspacePath] },
1403+
{ cmd: "kitty", args: ["--directory", workspacePath] },
1404+
{ cmd: "wezterm", args: ["start", "--cwd", workspacePath] },
1405+
{ cmd: "gnome-terminal", args: ["--working-directory", workspacePath] },
1406+
{ cmd: "konsole", args: ["--workdir", workspacePath] },
1407+
{ cmd: "xfce4-terminal", args: ["--working-directory", workspacePath] },
1408+
{ cmd: "xterm", args: [], cwd: workspacePath },
1409+
];
1410+
}
1411+
1412+
const availableTerminal = await this.findAvailableTerminal(terminals);
1413+
1414+
if (availableTerminal) {
1415+
const cwdInfo = availableTerminal.cwd ? ` (cwd: ${availableTerminal.cwd})` : "";
1416+
log.info(
1417+
`Opening ${logPrefix}: ${availableTerminal.cmd} ${availableTerminal.args.join(" ")}${cwdInfo}`
1418+
);
1419+
const child = spawn(availableTerminal.cmd, availableTerminal.args, {
1420+
cwd: availableTerminal.cwd,
1421+
detached: true,
1422+
stdio: "ignore",
1423+
});
1424+
child.unref();
1425+
} else {
1426+
log.error("No terminal emulator found. Tried: " + terminals.map((t) => t.cmd).join(", "));
1427+
}
1428+
}
1429+
}
1430+
13291431
/**
13301432
* Find the first available command from a list of commands
13311433
*/

0 commit comments

Comments
 (0)