Skip to content

Commit 372f54a

Browse files
committed
🤖 feat: add SSH config hosts dropdown for runtime selection
When selecting SSH runtime, the host input now shows a dropdown with hosts from the user's ~/.ssh/config file. Features: - Parses Host directives from SSH config (skipping wildcards and negation patterns) - Shows dropdown above the input when focused - Supports keyboard navigation (arrow keys, Enter, Escape) - Filters hosts as user types - Works in both browser and desktop modes Implemented using the new ORPC architecture: - Added ssh.getConfigHosts endpoint to ORPC router - Created SSHService to parse SSH config files - Created SSHHostInput component with autocomplete dropdown _Generated with `mux`_
1 parent aaf26a8 commit 372f54a

File tree

14 files changed

+232
-5
lines changed

14 files changed

+232
-5
lines changed

.storybook/mocks/orpc.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,5 +237,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
237237
await new Promise(() => {});
238238
},
239239
},
240+
ssh: {
241+
getConfigHosts: async () => ["dev-server", "prod-server", "staging"],
242+
},
243+
voice: {
244+
transcribe: async () => ({ success: false, error: "Not implemented in mock" }),
245+
},
240246
} as unknown as APIClient;
241247
}

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Loader2, Wand2 } from "lucide-react";
66
import { cn } from "@/common/lib/utils";
77
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
88
import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName";
9+
import { SSHHostInput } from "./SSHHostInput";
910

1011
interface CreationControlsProps {
1112
branches: string[];
@@ -146,13 +147,10 @@ export function CreationControls(props: CreationControlsProps) {
146147

147148
{/* SSH Host Input - after From selector */}
148149
{props.runtimeMode === RUNTIME_MODE.SSH && (
149-
<input
150-
type="text"
150+
<SSHHostInput
151151
value={props.sshHost}
152-
onChange={(e) => props.onSshHostChange(e.target.value)}
153-
placeholder="user@host"
152+
onChange={props.onSshHostChange}
154153
disabled={props.disabled}
155-
className="bg-separator text-foreground border-border-medium focus:border-accent h-6 w-32 rounded border px-1 text-xs focus:outline-none disabled:opacity-50"
156154
/>
157155
)}
158156
</div>
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import React, { useState, useEffect, useRef, useCallback } from "react";
2+
import { useAPI } from "@/browser/contexts/API";
3+
4+
interface SSHHostInputProps {
5+
value: string;
6+
onChange: (value: string) => void;
7+
disabled: boolean;
8+
}
9+
10+
/**
11+
* SSH host input with dropdown of hosts from SSH config.
12+
* Shows dropdown above the input when focused and there are matching hosts.
13+
*/
14+
export function SSHHostInput(props: SSHHostInputProps) {
15+
const { api } = useAPI();
16+
const [hosts, setHosts] = useState<string[]>([]);
17+
const [showDropdown, setShowDropdown] = useState(false);
18+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
19+
const inputRef = useRef<HTMLInputElement>(null);
20+
const containerRef = useRef<HTMLDivElement>(null);
21+
const itemRefs = useRef<Array<HTMLDivElement | null>>([]);
22+
23+
// Fetch SSH config hosts on mount
24+
useEffect(() => {
25+
if (!api) return;
26+
api.ssh
27+
.getConfigHosts()
28+
.then(setHosts)
29+
.catch(() => setHosts([]));
30+
}, [api]);
31+
32+
// Filter hosts based on current input
33+
const filteredHosts = hosts.filter((host) =>
34+
host.toLowerCase().includes(props.value.toLowerCase())
35+
);
36+
37+
// Handle clicking outside to close dropdown
38+
useEffect(() => {
39+
function handleClickOutside(e: MouseEvent) {
40+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
41+
setShowDropdown(false);
42+
}
43+
}
44+
document.addEventListener("mousedown", handleClickOutside);
45+
return () => document.removeEventListener("mousedown", handleClickOutside);
46+
}, []);
47+
48+
const { onChange } = props;
49+
const selectHost = useCallback(
50+
(host: string) => {
51+
onChange(host);
52+
setShowDropdown(false);
53+
setHighlightedIndex(-1);
54+
inputRef.current?.focus();
55+
},
56+
[onChange]
57+
);
58+
59+
const handleKeyDown = useCallback(
60+
(e: React.KeyboardEvent) => {
61+
if (!showDropdown || filteredHosts.length === 0) {
62+
return;
63+
}
64+
65+
switch (e.key) {
66+
case "ArrowDown":
67+
e.preventDefault();
68+
setHighlightedIndex((prev) => (prev < filteredHosts.length - 1 ? prev + 1 : 0));
69+
break;
70+
case "ArrowUp":
71+
e.preventDefault();
72+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredHosts.length - 1));
73+
break;
74+
case "Enter":
75+
if (highlightedIndex >= 0) {
76+
e.preventDefault();
77+
selectHost(filteredHosts[highlightedIndex]);
78+
}
79+
break;
80+
case "Escape":
81+
e.preventDefault();
82+
setShowDropdown(false);
83+
setHighlightedIndex(-1);
84+
break;
85+
}
86+
},
87+
[showDropdown, filteredHosts, highlightedIndex, selectHost]
88+
);
89+
90+
const handleFocus = () => {
91+
if (filteredHosts.length > 0) {
92+
setShowDropdown(true);
93+
}
94+
};
95+
96+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
97+
props.onChange(e.target.value);
98+
// Show dropdown when typing if there are matches
99+
if (hosts.length > 0) {
100+
setShowDropdown(true);
101+
}
102+
setHighlightedIndex(-1);
103+
};
104+
105+
// Scroll highlighted item into view
106+
useEffect(() => {
107+
if (highlightedIndex >= 0 && itemRefs.current[highlightedIndex]) {
108+
itemRefs.current[highlightedIndex]?.scrollIntoView({
109+
block: "nearest",
110+
behavior: "smooth",
111+
});
112+
}
113+
}, [highlightedIndex]);
114+
115+
// Show dropdown when there are filtered hosts
116+
const shouldShowDropdown = showDropdown && filteredHosts.length > 0 && !props.disabled;
117+
118+
return (
119+
<div ref={containerRef} className="relative">
120+
<input
121+
ref={inputRef}
122+
type="text"
123+
value={props.value}
124+
onChange={handleChange}
125+
onFocus={handleFocus}
126+
onKeyDown={handleKeyDown}
127+
placeholder="user@host"
128+
disabled={props.disabled}
129+
className="bg-separator text-foreground border-border-medium focus:border-accent w-32 rounded border px-1 py-0.5 text-xs focus:outline-none disabled:opacity-50"
130+
autoComplete="off"
131+
/>
132+
{shouldShowDropdown && (
133+
<div className="bg-separator border-border-light absolute bottom-full left-0 z-[1000] mb-1 max-h-[150px] min-w-32 overflow-y-auto rounded border shadow-[0_4px_12px_rgba(0,0,0,0.3)]">
134+
{filteredHosts.map((host, index) => (
135+
<div
136+
key={host}
137+
ref={(el) => (itemRefs.current[index] = el)}
138+
onClick={() => selectHost(host)}
139+
onMouseEnter={() => setHighlightedIndex(index)}
140+
className={`cursor-pointer px-2 py-1 text-xs ${
141+
index === highlightedIndex
142+
? "bg-accent text-white"
143+
: "text-foreground hover:bg-border-medium"
144+
}`}
145+
>
146+
{host}
147+
</div>
148+
))}
149+
</div>
150+
)}
151+
</div>
152+
);
153+
}

src/cli/cli.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ async function createTestServer(authToken?: string): Promise<TestServerHandle> {
7373
menuEventService: services.menuEventService,
7474
voiceService: services.voiceService,
7575
telemetryService: services.telemetryService,
76+
sshService: services.sshService,
7677
};
7778

7879
// Use the actual createOrpcServer function

src/cli/server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ async function createTestServer(): Promise<TestServerHandle> {
7676
menuEventService: services.menuEventService,
7777
voiceService: services.voiceService,
7878
telemetryService: services.telemetryService,
79+
sshService: services.sshService,
7980
};
8081

8182
// Use the actual createOrpcServer function

src/cli/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ const mockWindow: BrowserWindow = {
8484
mcpServerManager: serviceContainer.mcpServerManager,
8585
voiceService: serviceContainer.voiceService,
8686
telemetryService: serviceContainer.telemetryService,
87+
sshService: serviceContainer.sshService,
8788
};
8889

8990
const server = await createOrpcServer({

src/common/orpc/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export {
108108
providers,
109109
ProvidersConfigMapSchema,
110110
server,
111+
ssh,
111112
telemetry,
112113
TelemetryEventSchema,
113114
terminal,

src/common/orpc/schemas/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,18 @@ export const voice = {
524524
},
525525
};
526526

527+
// SSH utilities
528+
export const ssh = {
529+
/**
530+
* Get list of hosts from user's SSH config file (~/.ssh/config).
531+
* Returns hosts sorted alphabetically, excluding wildcards and negation patterns.
532+
*/
533+
getConfigHosts: {
534+
input: z.void(),
535+
output: z.array(z.string()),
536+
},
537+
};
538+
527539
// Debug endpoints (test-only, not for production use)
528540
export const debug = {
529541
/**

src/desktop/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ async function loadServices(): Promise<void> {
338338
menuEventService: services.menuEventService,
339339
voiceService: services.voiceService,
340340
telemetryService: services.telemetryService,
341+
sshService: services.sshService,
341342
};
342343

343344
electronIpcMain.on("start-orpc-server", (event) => {

src/node/orpc/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { VoiceService } from "@/node/services/voiceService";
1515
import type { MCPConfigService } from "@/node/services/mcpConfigService";
1616
import type { MCPServerManager } from "@/node/services/mcpServerManager";
1717
import type { TelemetryService } from "@/node/services/telemetryService";
18+
import type { SSHService } from "@/node/services/sshService";
1819

1920
export interface ORPCContext {
2021
config: Config;
@@ -33,5 +34,6 @@ export interface ORPCContext {
3334
mcpConfigService: MCPConfigService;
3435
mcpServerManager: MCPServerManager;
3536
telemetryService: TelemetryService;
37+
sshService: SSHService;
3638
headers?: IncomingHttpHeaders;
3739
}

0 commit comments

Comments
 (0)