Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "mux",
Expand Down
13 changes: 3 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,16 +155,9 @@ function AppInner() {
}
}, [selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);

const openWorkspaceInTerminal = useCallback(
(workspaceId: string) => {
// Look up workspace metadata to get the workspace path (directory uses workspace name)
const metadata = workspaceMetadata.get(workspaceId);
if (metadata) {
void window.api.workspace.openTerminal(metadata.namedWorkspacePath);
}
},
[workspaceMetadata]
);
const openWorkspaceInTerminal = useCallback((workspaceId: string) => {
void window.api.workspace.openTerminal(workspaceId);
}, []);

const handleRemoveProject = useCallback(
async (path: string) => {
Expand Down
2 changes: 1 addition & 1 deletion src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ const webApi: IPCApi = {
getInfo: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId),
executeBash: (workspaceId, script, options) =>
invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options),
openTerminal: (workspacePath) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath),
openTerminal: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspaceId),

onChat: (workspaceId, callback) => {
const channel = getChatChannel(workspaceId);
Expand Down
4 changes: 2 additions & 2 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
);

const handleOpenTerminal = useCallback(() => {
void window.api.workspace.openTerminal(namedWorkspacePath);
}, [namedWorkspacePath]);
void window.api.workspace.openTerminal(workspaceId);
}, [workspaceId]);

// Auto-scroll when messages or todos update (during streaming)
useEffect(() => {
Expand Down
3 changes: 2 additions & 1 deletion src/components/RuntimeBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import { cn } from "@/lib/utils";
import type { RuntimeConfig } from "@/types/runtime";
import { isSSHRuntime } from "@/types/runtime";
import { extractSshHostname } from "@/utils/ui/runtimeBadge";
import { TooltipWrapper, Tooltip } from "./Tooltip";

Expand Down Expand Up @@ -48,7 +49,7 @@ export function RuntimeBadge({ runtimeConfig, className }: RuntimeBadgeProps) {
</svg>
</span>
<Tooltip align="right">
SSH: {runtimeConfig?.type === "ssh" ? runtimeConfig.host : hostname}
SSH: {isSSHRuntime(runtimeConfig) ? runtimeConfig.host : hostname}
</Tooltip>
</TooltipWrapper>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/WorkspaceHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export const WorkspaceHeader: React.FC<WorkspaceHeaderProps> = ({
}) => {
const gitStatus = useGitStatus(workspaceId);
const handleOpenTerminal = useCallback(() => {
void window.api.workspace.openTerminal(namedWorkspacePath);
}, [namedWorkspacePath]);
void window.api.workspace.openTerminal(workspaceId);
}, [workspaceId]);

return (
<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]">
Expand Down
4 changes: 2 additions & 2 deletions src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ const api: IPCApi = {
getInfo: (workspaceId) => ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId),
executeBash: (workspaceId, script, options) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options),
openTerminal: (workspacePath) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath),
openTerminal: (workspaceId) =>
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspaceId),

onChat: (workspaceId: string, callback) => {
const channel = getChatChannel(workspaceId);
Expand Down
250 changes: 183 additions & 67 deletions src/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { DisposableTempDir } from "@/services/tempDir";
import { InitStateManager } from "@/services/initStateManager";
import { createRuntime } from "@/runtime/runtimeFactory";
import type { RuntimeConfig } from "@/types/runtime";
import { isSSHRuntime } from "@/types/runtime";
import { validateProjectPath } from "@/utils/pathUtils";
import { ExtensionMetadataService } from "@/services/ExtensionMetadataService";
/**
Expand Down Expand Up @@ -957,76 +958,29 @@ export class IpcMain {
}
);

ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, async (_event, workspacePath: string) => {
ipcMain.handle(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, async (_event, workspaceId: string) => {
try {
if (process.platform === "darwin") {
// macOS - try Ghostty first, fallback to Terminal.app
const terminal = await this.findAvailableCommand(["ghostty", "terminal"]);
if (terminal === "ghostty") {
// Match main: pass workspacePath to 'open -a Ghostty' to avoid regressions
const cmd = "open";
const args = ["-a", "Ghostty", workspacePath];
log.info(`Opening terminal: ${cmd} ${args.join(" ")}`);
const child = spawn(cmd, args, {
detached: true,
stdio: "ignore",
});
child.unref();
} else {
// Terminal.app opens in the directory when passed as argument
const cmd = "open";
const args = ["-a", "Terminal", workspacePath];
log.info(`Opening terminal: ${cmd} ${args.join(" ")}`);
const child = spawn(cmd, args, {
detached: true,
stdio: "ignore",
});
child.unref();
}
} else if (process.platform === "win32") {
// Windows
const cmd = "cmd";
const args = ["/c", "start", "cmd", "/K", "cd", "/D", workspacePath];
log.info(`Opening terminal: ${cmd} ${args.join(" ")}`);
const child = spawn(cmd, args, {
detached: true,
shell: true,
stdio: "ignore",
// Look up workspace metadata to get runtime config
const allMetadata = await this.config.getAllWorkspaceMetadata();
const workspace = allMetadata.find((w) => w.id === workspaceId);

if (!workspace) {
log.error(`Workspace not found: ${workspaceId}`);
return;
}

const runtimeConfig = workspace.runtimeConfig;

if (isSSHRuntime(runtimeConfig)) {
// SSH workspace - spawn local terminal that SSHs into remote host
await this.openTerminal({
type: "ssh",
sshConfig: runtimeConfig,
remotePath: workspace.namedWorkspacePath,
});
child.unref();
} else {
// Linux - try terminal emulators in order of preference
// x-terminal-emulator is checked first as it respects user's system-wide preference
const terminals = [
{ cmd: "x-terminal-emulator", args: [], cwd: workspacePath },
{ cmd: "ghostty", args: ["--working-directory=" + workspacePath] },
{ cmd: "alacritty", args: ["--working-directory", workspacePath] },
{ cmd: "kitty", args: ["--directory", workspacePath] },
{ cmd: "wezterm", args: ["start", "--cwd", workspacePath] },
{ cmd: "gnome-terminal", args: ["--working-directory", workspacePath] },
{ cmd: "konsole", args: ["--workdir", workspacePath] },
{ cmd: "xfce4-terminal", args: ["--working-directory", workspacePath] },
{ cmd: "xterm", args: [], cwd: workspacePath },
];

const availableTerminal = await this.findAvailableTerminal(terminals);

if (availableTerminal) {
const cwdInfo = availableTerminal.cwd ? ` (cwd: ${availableTerminal.cwd})` : "";
log.info(
`Opening terminal: ${availableTerminal.cmd} ${availableTerminal.args.join(" ")}${cwdInfo}`
);
const child = spawn(availableTerminal.cmd, availableTerminal.args, {
cwd: availableTerminal.cwd ?? workspacePath,
detached: true,
stdio: "ignore",
});
child.unref();
} else {
log.error(
"No terminal emulator found. Tried: " + terminals.map((t) => t.cmd).join(", ")
);
}
// Local workspace - spawn terminal with cwd set
await this.openTerminal({ type: "local", workspacePath: workspace.namedWorkspacePath });
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -1383,6 +1337,168 @@ export class IpcMain {
}
}

/**
* Open a terminal (local or SSH) with platform-specific handling
*/
private async openTerminal(
config:
| { type: "local"; workspacePath: string }
| {
type: "ssh";
sshConfig: Extract<RuntimeConfig, { type: "ssh" }>;
remotePath: string;
}
): Promise<void> {
const isSSH = config.type === "ssh";

// Build SSH args if needed
let sshArgs: string[] | null = null;
if (isSSH) {
sshArgs = [];
// Add port if specified
if (config.sshConfig.port) {
sshArgs.push("-p", String(config.sshConfig.port));
}
// Add identity file if specified
if (config.sshConfig.identityFile) {
sshArgs.push("-i", config.sshConfig.identityFile);
}
// Force pseudo-terminal allocation
sshArgs.push("-t");
// 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`);
}

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"]);
if (terminal === "ghostty") {
const cmd = "open";
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}`];
} else {
// Ghostty: Pass workspacePath to 'open -a Ghostty' to avoid regressions
if (config.type !== "local") throw new Error("Expected local config");
args = ["-a", "Ghostty", config.workspacePath];
}
log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`);
const child = spawn(cmd, args, {
detached: true,
stdio: "ignore",
});
child.unref();
} else {
// Terminal.app
const cmd = isSSH ? "osascript" : "open";
let args: string[];
if (isSSH && sshArgs) {
// Terminal.app: Use osascript with proper AppleScript structure
// Properly escape single quotes in args before wrapping in quotes
const sshCommand = `ssh ${sshArgs
.map((arg) => {
if (arg.includes(" ") || arg.includes("'")) {
// Escape single quotes by ending quote, adding escaped quote, starting quote again
return `'${arg.replace(/'/g, "'\\''")}'`;
}
return arg;
})
.join(" ")}`;
// Escape double quotes for AppleScript string
const escapedCommand = sshCommand.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const script = `tell application "Terminal"\nactivate\ndo script "${escapedCommand}"\nend tell`;
args = ["-e", script];
} else {
// Terminal.app opens in the directory when passed as argument
if (config.type !== "local") throw new Error("Expected local config");
args = ["-a", "Terminal", config.workspacePath];
}
log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`);
const child = spawn(cmd, args, {
detached: true,
stdio: "ignore",
});
child.unref();
}
} else if (process.platform === "win32") {
// Windows
const cmd = "cmd";
let args: string[];
if (isSSH && sshArgs) {
// Windows - use cmd to start ssh
args = ["/c", "start", "cmd", "/K", "ssh", ...sshArgs];
} else {
if (config.type !== "local") throw new Error("Expected local config");
args = ["/c", "start", "cmd", "/K", "cd", "/D", config.workspacePath];
}
log.info(`Opening ${logPrefix}: ${cmd} ${args.join(" ")}`);
const child = spawn(cmd, args, {
detached: true,
shell: true,
stdio: "ignore",
});
child.unref();
} else {
// Linux - try terminal emulators in order of preference
let terminals: Array<{ cmd: string; args: string[]; cwd?: string }>;

if (isSSH && sshArgs) {
// x-terminal-emulator is checked first as it respects user's system-wide preference
terminals = [
{ cmd: "x-terminal-emulator", args: ["-e", "ssh", ...sshArgs] },
{ cmd: "ghostty", args: ["ssh", ...sshArgs] },
{ cmd: "alacritty", args: ["-e", "ssh", ...sshArgs] },
{ cmd: "kitty", args: ["ssh", ...sshArgs] },
{ cmd: "wezterm", args: ["start", "--", "ssh", ...sshArgs] },
{ cmd: "gnome-terminal", args: ["--", "ssh", ...sshArgs] },
{ cmd: "konsole", args: ["-e", "ssh", ...sshArgs] },
{ cmd: "xfce4-terminal", args: ["-e", `ssh ${sshArgs.join(" ")}`] },
{ cmd: "xterm", args: ["-e", "ssh", ...sshArgs] },
];
} else {
if (config.type !== "local") throw new Error("Expected local config");
const workspacePath = config.workspacePath;
terminals = [
{ cmd: "x-terminal-emulator", args: [], cwd: workspacePath },
{ cmd: "ghostty", args: ["--working-directory=" + workspacePath] },
{ cmd: "alacritty", args: ["--working-directory", workspacePath] },
{ cmd: "kitty", args: ["--directory", workspacePath] },
{ cmd: "wezterm", args: ["start", "--cwd", workspacePath] },
{ cmd: "gnome-terminal", args: ["--working-directory", workspacePath] },
{ cmd: "konsole", args: ["--workdir", workspacePath] },
{ cmd: "xfce4-terminal", args: ["--working-directory", workspacePath] },
{ cmd: "xterm", args: [], cwd: workspacePath },
];
}

const availableTerminal = await this.findAvailableTerminal(terminals);

if (availableTerminal) {
const cwdInfo = availableTerminal.cwd ? ` (cwd: ${availableTerminal.cwd})` : "";
log.info(
`Opening ${logPrefix}: ${availableTerminal.cmd} ${availableTerminal.args.join(" ")}${cwdInfo}`
);
const child = spawn(availableTerminal.cmd, availableTerminal.args, {
cwd: availableTerminal.cwd,
detached: true,
stdio: "ignore",
});
child.unref();
} else {
log.error("No terminal emulator found. Tried: " + terminals.map((t) => t.cmd).join(", "));
}
}
}

/**
* Find the first available command from a list of commands
*/
Expand Down
3 changes: 2 additions & 1 deletion src/stores/GitStatusStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@/utils/git/gitStatus";
import { useSyncExternalStore } from "react";
import { MapStore } from "./MapStore";
import { isSSHRuntime } from "@/types/runtime";

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

Expand Down
9 changes: 9 additions & 0 deletions src/types/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,12 @@ export function buildRuntimeString(mode: RuntimeMode, host: string): string | un
}
return undefined;
}

/**
* Type guard to check if a runtime config is SSH
*/
export function isSSHRuntime(
config: RuntimeConfig | undefined
): config is Extract<RuntimeConfig, { type: "ssh" }> {
return config?.type === "ssh";
}