Skip to content
Open
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
8 changes: 8 additions & 0 deletions collab-electron/src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
11 changes: 10 additions & 1 deletion collab-electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down
5 changes: 3 additions & 2 deletions collab-electron/src/main/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -520,6 +520,7 @@ export async function createSession(
"-c", resolvedCwd,
"-x", String(c),
"-y", String(r),
shell,
);

if (zshIntegrated) {
Expand Down
8 changes: 5 additions & 3 deletions collab-electron/src/main/terminal-target.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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";
}
Expand Down
2 changes: 2 additions & 0 deletions collab-electron/src/preload/universal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
87 changes: 87 additions & 0 deletions collab-electron/src/windows/settings/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface SettingsApi {
label: string;
isDefault?: boolean;
}>>;
checkShellCommand: (command: string) => Promise<boolean>;
setTheme: (mode: string) => Promise<void>;
getAppVersion: () => Promise<string>;
getAgents: () => Promise<AgentStatus[]>;
Expand Down Expand Up @@ -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 (
<div className="space-y-2">
<p className="text-sm font-medium">Shell command</p>
<input
type="text"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
disabled={!loaded}
value={value}
onChange={(e) => {
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" ? (
<p className="text-xs" style={{ color: "#ef4444" }}>
Not found on PATH. New terminals will fall back to your login shell.
</p>
) : (
<p className="text-xs text-muted-foreground">
Name or path of a shell binary (e.g.
{" "}
<span className="font-mono">/bin/bash</span>
). Leave empty to use your login shell.
</p>
)}
</div>
);
}

function MacTerminalPane() {
const [mode, setMode] = useState<TerminalMode>("sidecar");

Expand All @@ -411,6 +496,8 @@ function MacTerminalPane() {
</p>
</div>

<ShellCommandField />

<div className="space-y-2">
<p className="text-sm font-medium">Terminal backend</p>
<div className="space-y-1.5">
Expand Down
Loading