Skip to content

Commit e9efc53

Browse files
committed
🤖 refactor: unify SSH/Docker runtimes and runtime selection
- Extract RemoteRuntime base class (shared exec + file ops)\n- Switch runtime selection UI/state to discriminated union (SSH host / Docker image)\n- Add Docker option to CreationControls and persist last-used host/image\n- Fix remote stdin.close idempotence and SSH tilde expansion edge cases\n\n_Generated with mux_
1 parent 2680451 commit e9efc53

File tree

16 files changed

+1098
-897
lines changed

16 files changed

+1098
-897
lines changed

docs/system-prompt.mdx

Lines changed: 47 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -47,59 +47,59 @@ When the user asks you to remember something:
4747
/**
4848
* Build environment context XML block describing the workspace.
4949
* @param workspacePath - Workspace directory path
50-
* @param runtimeType - Runtime type: "local", "worktree", "ssh", or "docker"
50+
* @param runtimeType - Runtime type (local, worktree, ssh, docker)
5151
*/
52-
function buildEnvironmentContext(
53-
workspacePath: string,
54-
runtimeType: "local" | "worktree" | "ssh" | "docker"
55-
): string {
56-
if (runtimeType === "local") {
57-
// Local runtime works directly in project directory - may or may not be git
58-
return `
59-
<environment>
60-
You are working in a directory at ${workspacePath}
61-
62-
- Tools run here automatically
63-
- You are meant to do your work isolated from the user and other agents
64-
</environment>
65-
`;
66-
}
67-
68-
if (runtimeType === "ssh") {
69-
// SSH runtime clones the repository on a remote host
70-
return `
71-
<environment>
72-
You are in a clone of a git repository at ${workspacePath}
73-
74-
- This IS a git repository - run git commands directly (no cd needed)
75-
- Tools run here automatically
76-
- You are meant to do your work isolated from the user and other agents
77-
</environment>
78-
`;
79-
}
80-
81-
if (runtimeType === "docker") {
82-
// Docker runtime runs in an isolated container
83-
return `
84-
<environment>
85-
You are in a clone of a git repository at ${workspacePath} inside a Docker container
86-
87-
- This IS a git repository - run git commands directly (no cd needed)
88-
- Tools run here automatically inside the container
89-
- You are meant to do your work isolated from the user and other agents
90-
</environment>
91-
`;
52+
function buildEnvironmentContext(workspacePath: string, runtimeType: RuntimeMode): string {
53+
// Common lines shared across git-based runtimes
54+
const gitCommonLines = [
55+
"- This IS a git repository - run git commands directly (no cd needed)",
56+
"- Tools run here automatically",
57+
"- You are meant to do your work isolated from the user and other agents",
58+
];
59+
60+
let description: string;
61+
let lines: string[];
62+
63+
switch (runtimeType) {
64+
case RUNTIME_MODE.LOCAL:
65+
// Local runtime works directly in project directory - may or may not be git
66+
description = `You are working in a directory at ${workspacePath}`;
67+
lines = [
68+
"- Tools run here automatically",
69+
"- You are meant to do your work isolated from the user and other agents",
70+
];
71+
break;
72+
73+
case RUNTIME_MODE.WORKTREE:
74+
// Worktree runtime creates a git worktree locally
75+
description = `You are in a git worktree at ${workspacePath}`;
76+
lines = [
77+
...gitCommonLines,
78+
"- Do not modify or visit other worktrees (especially the main project) without explicit user intent",
79+
];
80+
break;
81+
82+
case RUNTIME_MODE.SSH:
83+
// SSH runtime clones the repository on a remote host
84+
description = `You are in a clone of a git repository at ${workspacePath}`;
85+
lines = gitCommonLines;
86+
break;
87+
88+
case RUNTIME_MODE.DOCKER:
89+
// Docker runtime runs in an isolated container
90+
description = `You are in a clone of a git repository at ${workspacePath} inside a Docker container`;
91+
lines = gitCommonLines;
92+
break;
93+
94+
default:
95+
assertNever(runtimeType, `Unknown runtime type: ${String(runtimeType)}`);
9296
}
9397

94-
// Worktree runtime creates a git worktree locally
9598
return `
9699
<environment>
97-
You are in a git worktree at ${workspacePath}
100+
${description}
98101
99-
- This IS a git repository - run git commands directly (no cd needed)
100-
- Tools run here automatically
101-
- Do not modify or visit other worktrees (especially the main project) without explicit user intent
102-
- You are meant to do your work isolated from the user and other agents
102+
${lines.join("\n")}
103103
</environment>
104104
`;
105105
}

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import React, { useCallback, useEffect } from "react";
2-
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
2+
import { RUNTIME_MODE, type RuntimeMode, type ParsedRuntime } from "@/common/types/runtime";
33
import { Select } from "../Select";
44
import { Loader2, Wand2 } from "lucide-react";
55
import { cn } from "@/common/lib/utils";
66
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
7-
import { SSHIcon, WorktreeIcon, LocalIcon } from "../icons/RuntimeIcons";
7+
import { SSHIcon, WorktreeIcon, LocalIcon, DockerIcon } from "../icons/RuntimeIcons";
88
import { DocsLink } from "../DocsLink";
99
import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName";
1010

@@ -14,12 +14,12 @@ interface CreationControlsProps {
1414
branchesLoaded: boolean;
1515
trunkBranch: string;
1616
onTrunkBranchChange: (branch: string) => void;
17-
runtimeMode: RuntimeMode;
17+
/** Currently selected runtime (discriminated union: SSH has host, Docker has image) */
18+
selectedRuntime: ParsedRuntime;
1819
defaultRuntimeMode: RuntimeMode;
19-
sshHost: string;
20-
onRuntimeModeChange: (mode: RuntimeMode) => void;
20+
/** Set the currently selected runtime (discriminated union) */
21+
onSelectedRuntimeChange: (runtime: ParsedRuntime) => void;
2122
onSetDefaultRuntime: (mode: RuntimeMode) => void;
22-
onSshHostChange: (host: string) => void;
2323
disabled: boolean;
2424
/** Project name to display as header */
2525
projectName: string;
@@ -80,6 +80,17 @@ const RUNTIME_OPTIONS: Array<{
8080
idleClass:
8181
"bg-transparent text-muted border-transparent hover:border-[var(--color-runtime-ssh)]/40",
8282
},
83+
{
84+
value: RUNTIME_MODE.DOCKER,
85+
label: "Docker",
86+
description: "Run in Docker container",
87+
docsPath: "/runtime/docker",
88+
Icon: DockerIcon,
89+
activeClass:
90+
"bg-[var(--color-runtime-docker)]/20 text-[var(--color-runtime-docker-text)] border-[var(--color-runtime-docker)]/60",
91+
idleClass:
92+
"bg-transparent text-muted border-transparent hover:border-[var(--color-runtime-docker)]/40",
93+
},
8394
];
8495

8596
function RuntimeButtonGroup(props: RuntimeButtonGroupProps) {
@@ -153,18 +164,20 @@ export function CreationControls(props: CreationControlsProps) {
153164
// Don't check until branches have loaded to avoid prematurely switching runtime
154165
const isNonGitRepo = props.branchesLoaded && props.branches.length === 0;
155166

167+
// Extract mode from discriminated union for convenience
168+
const runtimeMode = props.selectedRuntime.mode;
169+
156170
// Local runtime doesn't need a trunk branch selector (uses project dir as-is)
157-
const showTrunkBranchSelector =
158-
props.branches.length > 0 && props.runtimeMode !== RUNTIME_MODE.LOCAL;
171+
const showTrunkBranchSelector = props.branches.length > 0 && runtimeMode !== RUNTIME_MODE.LOCAL;
159172

160-
const { runtimeMode, onRuntimeModeChange } = props;
173+
const { selectedRuntime, onSelectedRuntimeChange } = props;
161174

162175
// Force local runtime for non-git directories (only after branches loaded)
163176
useEffect(() => {
164-
if (isNonGitRepo && runtimeMode !== RUNTIME_MODE.LOCAL) {
165-
onRuntimeModeChange(RUNTIME_MODE.LOCAL);
177+
if (isNonGitRepo && selectedRuntime.mode !== RUNTIME_MODE.LOCAL) {
178+
onSelectedRuntimeChange({ mode: "local" });
166179
}
167-
}, [isNonGitRepo, runtimeMode, onRuntimeModeChange]);
180+
}, [isNonGitRepo, selectedRuntime.mode, onSelectedRuntimeChange]);
168181

169182
const handleNameChange = useCallback(
170183
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -263,12 +276,39 @@ export function CreationControls(props: CreationControlsProps) {
263276
<label className="text-muted-foreground text-xs font-medium">Workspace Type</label>
264277
<div className="flex flex-wrap items-center gap-3">
265278
<RuntimeButtonGroup
266-
value={props.runtimeMode}
267-
onChange={props.onRuntimeModeChange}
279+
value={runtimeMode}
280+
onChange={(mode) => {
281+
// Convert mode to ParsedRuntime with appropriate defaults
282+
switch (mode) {
283+
case RUNTIME_MODE.SSH:
284+
onSelectedRuntimeChange({
285+
mode: "ssh",
286+
host: selectedRuntime.mode === "ssh" ? selectedRuntime.host : "",
287+
});
288+
break;
289+
case RUNTIME_MODE.DOCKER:
290+
onSelectedRuntimeChange({
291+
mode: "docker",
292+
image: selectedRuntime.mode === "docker" ? selectedRuntime.image : "",
293+
});
294+
break;
295+
case RUNTIME_MODE.LOCAL:
296+
onSelectedRuntimeChange({ mode: "local" });
297+
break;
298+
case RUNTIME_MODE.WORKTREE:
299+
default:
300+
onSelectedRuntimeChange({ mode: "worktree" });
301+
break;
302+
}
303+
}}
268304
defaultMode={props.defaultRuntimeMode}
269305
onSetDefault={props.onSetDefaultRuntime}
270306
disabled={props.disabled}
271-
disabledModes={isNonGitRepo ? [RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH] : undefined}
307+
disabledModes={
308+
isNonGitRepo
309+
? [RUNTIME_MODE.WORKTREE, RUNTIME_MODE.SSH, RUNTIME_MODE.DOCKER]
310+
: undefined
311+
}
272312
/>
273313

274314
{/* Branch selector - shown for worktree/SSH */}
@@ -293,19 +333,34 @@ export function CreationControls(props: CreationControlsProps) {
293333
)}
294334

295335
{/* SSH Host Input */}
296-
{props.runtimeMode === RUNTIME_MODE.SSH && (
336+
{selectedRuntime.mode === "ssh" && (
297337
<div className="flex items-center gap-2">
298338
<label className="text-muted-foreground text-xs">host</label>
299339
<input
300340
type="text"
301-
value={props.sshHost}
302-
onChange={(e) => props.onSshHostChange(e.target.value)}
341+
value={selectedRuntime.host}
342+
onChange={(e) => onSelectedRuntimeChange({ mode: "ssh", host: e.target.value })}
303343
placeholder="user@host"
304344
disabled={props.disabled}
305345
className="bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50"
306346
/>
307347
</div>
308348
)}
349+
350+
{/* Docker Image Input */}
351+
{selectedRuntime.mode === "docker" && (
352+
<div className="flex items-center gap-2">
353+
<label className="text-muted-foreground text-xs">image</label>
354+
<input
355+
type="text"
356+
value={selectedRuntime.image}
357+
onChange={(e) => onSelectedRuntimeChange({ mode: "docker", image: e.target.value })}
358+
placeholder="ubuntu:22.04"
359+
disabled={props.disabled}
360+
className="bg-bg-dark text-foreground border-border-medium focus:border-accent h-7 w-36 rounded-md border px-2 text-sm focus:outline-none disabled:opacity-50"
361+
/>
362+
</div>
363+
)}
309364
</div>
310365
</div>
311366
</div>

src/browser/components/ChatInput/index.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,12 +1446,10 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
14461446
branchesLoaded={creationState.branchesLoaded}
14471447
trunkBranch={creationState.trunkBranch}
14481448
onTrunkBranchChange={creationState.setTrunkBranch}
1449-
runtimeMode={creationState.runtimeMode}
1449+
selectedRuntime={creationState.selectedRuntime}
14501450
defaultRuntimeMode={creationState.defaultRuntimeMode}
1451-
sshHost={creationState.sshHost}
1452-
onRuntimeModeChange={creationState.setRuntimeMode}
1451+
onSelectedRuntimeChange={creationState.setSelectedRuntime}
14531452
onSetDefaultRuntime={creationState.setDefaultRuntimeMode}
1454-
onSshHostChange={creationState.setSshHost}
14551453
disabled={isSendInFlight}
14561454
projectName={props.projectName}
14571455
nameState={creationState.nameState}

0 commit comments

Comments
 (0)