diff --git a/collab-electron/src/main/config.ts b/collab-electron/src/main/config.ts index 79b1d7c9..2e2ccc2d 100644 --- a/collab-electron/src/main/config.ts +++ b/collab-electron/src/main/config.ts @@ -127,3 +127,11 @@ export function getTerminalTarget(): TerminalTarget { const target = getPref(config, "terminalTarget"); return isTerminalTarget(target) ? target : "auto"; } + +export function getTerminalCommand(): string | null { + const config = loadConfig(); + const value = getPref(config, "terminalCommand"); + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed === "" ? null : trimmed; +} diff --git a/collab-electron/src/main/index.ts b/collab-electron/src/main/index.ts index 23bb006c..15f6e9dd 100644 --- a/collab-electron/src/main/index.ts +++ b/collab-electron/src/main/index.ts @@ -48,7 +48,7 @@ import { } from "./analytics"; import { stopImageWorker } from "./image-service"; import { installCli } from "./cli-installer"; -import { listTerminalTargets } from "./terminal-target"; +import { commandExists, listTerminalTargets } from "./terminal-target"; import { readSessionMeta } from "./tmux"; import { registerBrowserIpc } from "./ipc-browser"; import { registerAgentIpc } from "./acp-agent"; @@ -568,6 +568,15 @@ ipcMain.handle( () => listTerminalTargets(), ); +ipcMain.handle( + "terminal:check-shell-command", + (_event, command: string) => { + const trimmed = typeof command === "string" ? command.trim() : ""; + if (trimmed === "") return true; + return commandExists(trimmed); + }, +); + ipcMain.handle( "theme:set", (_event, mode: string) => { diff --git a/collab-electron/src/main/pty.ts b/collab-electron/src/main/pty.ts index aed3ab3f..436a57f3 100644 --- a/collab-electron/src/main/pty.ts +++ b/collab-electron/src/main/pty.ts @@ -32,7 +32,7 @@ import { SIDECAR_PID_PATH, } from "./sidecar/protocol"; import { COLLAB_DIR } from "./paths"; -import { resolveTerminalTarget } from "./terminal-target"; +import { resolveShellPath, resolveTerminalTarget } from "./terminal-target"; interface PtySession { pty: pty.IPty; @@ -486,7 +486,7 @@ export async function createSession( cwdGuestPath?: string; }> { const resolvedCwd = cwd || os.homedir(); - const shell = process.env.SHELL || "/bin/zsh"; + const shell = resolveShellPath(); const c = cols || 80; const r = rows || 24; @@ -520,6 +520,7 @@ export async function createSession( "-c", resolvedCwd, "-x", String(c), "-y", String(r), + shell, ); if (zshIntegrated) { diff --git a/collab-electron/src/main/terminal-target.ts b/collab-electron/src/main/terminal-target.ts index 3a2e0dca..03a34d6a 100644 --- a/collab-electron/src/main/terminal-target.ts +++ b/collab-electron/src/main/terminal-target.ts @@ -1,7 +1,7 @@ import { execFileSync } from "node:child_process"; import * as os from "node:os"; import { displayBasename, hostPathToGuestPath, parseWslUncPath } from "@collab/shared/path-utils"; -import { type TerminalTarget } from "./config"; +import { getTerminalCommand, type TerminalTarget } from "./config"; export interface TerminalTargetOption { id: TerminalTarget; @@ -33,7 +33,7 @@ interface WslDistro { isDefault: boolean; } -function commandExists(command: string): boolean { +export function commandExists(command: string): boolean { try { execFileSync( process.platform === "win32" ? "where.exe" : "which", @@ -121,7 +121,9 @@ function resolveWindowsAutoTarget( return defaultDistro ? `wsl:${defaultDistro}` : "powershell"; } -function resolveShellPath(): string { +export function resolveShellPath(): string { + const override = getTerminalCommand(); + if (override && commandExists(override)) return override; if (process.platform === "darwin") { return process.env.SHELL || "/bin/zsh"; } diff --git a/collab-electron/src/preload/universal.ts b/collab-electron/src/preload/universal.ts index 030bb989..37b22241 100644 --- a/collab-electron/src/preload/universal.ts +++ b/collab-electron/src/preload/universal.ts @@ -156,6 +156,8 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("pref:set", key, value), listTerminalTargets: () => ipcRenderer.invoke("terminal:list-targets"), + checkShellCommand: (command: string) => + ipcRenderer.invoke("terminal:check-shell-command", command), getWorkspacePref: (key: string, workspacePath: string) => ipcRenderer.invoke("workspace-pref:get", { key, workspacePath }), setWorkspacePref: (key: string, value: unknown, workspacePath: string) => diff --git a/collab-electron/src/windows/settings/src/App.tsx b/collab-electron/src/windows/settings/src/App.tsx index b993666d..aff080d6 100644 --- a/collab-electron/src/windows/settings/src/App.tsx +++ b/collab-electron/src/windows/settings/src/App.tsx @@ -20,6 +20,7 @@ interface SettingsApi { label: string; isDefault?: boolean; }>>; + checkShellCommand: (command: string) => Promise; setTheme: (mode: string) => Promise; getAppVersion: () => Promise; getAgents: () => Promise; @@ -386,6 +387,90 @@ function RadioOption({ ); } +function ShellCommandField() { + const [value, setValue] = useState(""); + const [loaded, setLoaded] = useState(false); + const [status, setStatus] = useState<"ok" | "missing" | null>(null); + + const validate = useCallback(async (command: string) => { + const trimmed = command.trim(); + if (trimmed === "") { + setStatus(null); + return; + } + try { + const exists = await api.checkShellCommand(trimmed); + setStatus(exists ? "ok" : "missing"); + } catch { + setStatus(null); + } + }, []); + + useEffect(() => { + api.getPref("terminalCommand") + .then((v) => { + if (typeof v === "string") { + setValue(v); + void validate(v); + } + }) + .catch(() => { }) + .finally(() => setLoaded(true)); + }, [validate]); + + async function commit() { + const trimmed = value.trim(); + await api.setPref("terminalCommand", trimmed); + await validate(trimmed); + } + + return ( +
+

Shell command

+ { + setValue(e.target.value); + setStatus(null); + }} + onBlur={() => { void commit(); }} + onKeyDown={(e) => { + if (e.key === "Enter") { + (e.target as HTMLInputElement).blur(); + } + }} + placeholder="Default (login shell)" + className="w-full rounded-md px-3 py-2 text-sm font-mono focus:outline-none" + style={{ + backgroundColor: + "color-mix(in srgb, var(--foreground) 6%, transparent)", + border: `1px solid ${status === "missing" + ? "#ef4444" + : "color-mix(in srgb, var(--foreground) 15%, transparent)"}`, + color: "var(--foreground)", + }} + /> + {status === "missing" ? ( +

+ Not found on PATH. New terminals will fall back to your login shell. +

+ ) : ( +

+ Name or path of a shell binary (e.g. + {" "} + /bin/bash + ). Leave empty to use your login shell. +

+ )} +
+ ); +} + function MacTerminalPane() { const [mode, setMode] = useState("sidecar"); @@ -411,6 +496,8 @@ function MacTerminalPane() {

+ +

Terminal backend