Skip to content

Commit bf38a38

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 3e25841 commit bf38a38

File tree

13 files changed

+219
-5
lines changed

13 files changed

+219
-5
lines changed

.storybook/mocks/orpc.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,5 +202,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
202202
await new Promise(() => {});
203203
},
204204
},
205+
ssh: {
206+
getConfigHosts: async () => ["dev-server", "prod-server", "staging"],
207+
},
208+
voice: {
209+
transcribe: async () => ({ success: false, error: "Not implemented in mock" }),
210+
},
205211
} as unknown as APIClient;
206212
}

src/browser/components/ChatInput/CreationControls.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from "react";
22
import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime";
33
import { TooltipWrapper, Tooltip } from "../Tooltip";
44
import { Select } from "../Select";
5+
import { SSHHostInput } from "./SSHHostInput";
56

67
interface CreationControlsProps {
78
branches: string[];
@@ -83,13 +84,10 @@ export function CreationControls(props: CreationControlsProps) {
8384

8485
{/* SSH Host Input - after From selector */}
8586
{props.runtimeMode === RUNTIME_MODE.SSH && (
86-
<input
87-
type="text"
87+
<SSHHostInput
8888
value={props.sshHost}
89-
onChange={(e) => props.onRuntimeChange(RUNTIME_MODE.SSH, e.target.value)}
90-
placeholder="user@host"
89+
onChange={(value) => props.onRuntimeChange(RUNTIME_MODE.SSH, value)}
9190
disabled={props.disabled}
92-
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"
9391
/>
9492
)}
9593
</div>
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
22+
// Fetch SSH config hosts on mount
23+
useEffect(() => {
24+
if (!api) return;
25+
api.ssh
26+
.getConfigHosts()
27+
.then(setHosts)
28+
.catch(() => setHosts([]));
29+
}, [api]);
30+
31+
// Filter hosts based on current input
32+
const filteredHosts = hosts.filter((host) =>
33+
host.toLowerCase().includes(props.value.toLowerCase())
34+
);
35+
36+
// Handle clicking outside to close dropdown
37+
useEffect(() => {
38+
function handleClickOutside(e: MouseEvent) {
39+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
40+
setShowDropdown(false);
41+
}
42+
}
43+
document.addEventListener("mousedown", handleClickOutside);
44+
return () => document.removeEventListener("mousedown", handleClickOutside);
45+
}, []);
46+
47+
const { onChange } = props;
48+
const selectHost = useCallback(
49+
(host: string) => {
50+
onChange(host);
51+
setShowDropdown(false);
52+
setHighlightedIndex(-1);
53+
inputRef.current?.focus();
54+
},
55+
[onChange]
56+
);
57+
58+
const handleKeyDown = useCallback(
59+
(e: React.KeyboardEvent) => {
60+
if (!showDropdown || filteredHosts.length === 0) {
61+
return;
62+
}
63+
64+
switch (e.key) {
65+
case "ArrowDown":
66+
e.preventDefault();
67+
setHighlightedIndex((prev) => (prev < filteredHosts.length - 1 ? prev + 1 : 0));
68+
break;
69+
case "ArrowUp":
70+
e.preventDefault();
71+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredHosts.length - 1));
72+
break;
73+
case "Enter":
74+
if (highlightedIndex >= 0) {
75+
e.preventDefault();
76+
selectHost(filteredHosts[highlightedIndex]);
77+
}
78+
break;
79+
case "Escape":
80+
e.preventDefault();
81+
setShowDropdown(false);
82+
setHighlightedIndex(-1);
83+
break;
84+
}
85+
},
86+
[showDropdown, filteredHosts, highlightedIndex, selectHost]
87+
);
88+
89+
const handleFocus = () => {
90+
if (filteredHosts.length > 0) {
91+
setShowDropdown(true);
92+
}
93+
};
94+
95+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
96+
props.onChange(e.target.value);
97+
// Show dropdown when typing if there are matches
98+
if (hosts.length > 0) {
99+
setShowDropdown(true);
100+
}
101+
setHighlightedIndex(-1);
102+
};
103+
104+
// Show dropdown when there are filtered hosts
105+
const shouldShowDropdown = showDropdown && filteredHosts.length > 0 && !props.disabled;
106+
107+
return (
108+
<div ref={containerRef} className="relative">
109+
<input
110+
ref={inputRef}
111+
type="text"
112+
value={props.value}
113+
onChange={handleChange}
114+
onFocus={handleFocus}
115+
onKeyDown={handleKeyDown}
116+
placeholder="user@host"
117+
disabled={props.disabled}
118+
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"
119+
autoComplete="off"
120+
/>
121+
{shouldShowDropdown && (
122+
<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)]">
123+
{filteredHosts.map((host, index) => (
124+
<div
125+
key={host}
126+
onClick={() => selectHost(host)}
127+
onMouseEnter={() => setHighlightedIndex(index)}
128+
className={`cursor-pointer px-2 py-1 text-xs ${
129+
index === highlightedIndex
130+
? "bg-accent text-white"
131+
: "text-foreground hover:bg-border-medium"
132+
}`}
133+
>
134+
{host}
135+
</div>
136+
))}
137+
</div>
138+
)}
139+
</div>
140+
);
141+
}

src/cli/server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ async function createTestServer(): Promise<TestServerHandle> {
7070
serverService: services.serverService,
7171
menuEventService: services.menuEventService,
7272
voiceService: services.voiceService,
73+
sshService: services.sshService,
7374
};
7475

7576
// Use the actual createOrpcServer function

src/cli/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const mockWindow: BrowserWindow = {
6868
serverService: serviceContainer.serverService,
6969
menuEventService: serviceContainer.menuEventService,
7070
voiceService: serviceContainer.voiceService,
71+
sshService: serviceContainer.sshService,
7172
};
7273

7374
const server = await createOrpcServer({

src/common/orpc/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export {
9898
providers,
9999
ProvidersConfigMapSchema,
100100
server,
101+
ssh,
101102
terminal,
102103
tokenizer,
103104
update,

src/common/orpc/schemas/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,18 @@ export const voice = {
399399
},
400400
};
401401

402+
// SSH utilities
403+
export const ssh = {
404+
/**
405+
* Get list of hosts from user's SSH config file (~/.ssh/config).
406+
* Returns hosts sorted alphabetically, excluding wildcards and negation patterns.
407+
*/
408+
getConfigHosts: {
409+
input: z.void(),
410+
output: z.array(z.string()),
411+
},
412+
};
413+
402414
// Debug endpoints (test-only, not for production use)
403415
export const debug = {
404416
/**

src/desktop/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ async function loadServices(): Promise<void> {
323323
serverService: services!.serverService,
324324
menuEventService: services!.menuEventService,
325325
voiceService: services!.voiceService,
326+
sshService: services!.sshService,
326327
},
327328
});
328329
serverPort.start();

src/node/orpc/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { TokenizerService } from "@/node/services/tokenizerService";
99
import type { ServerService } from "@/node/services/serverService";
1010
import type { MenuEventService } from "@/node/services/menuEventService";
1111
import type { VoiceService } from "@/node/services/voiceService";
12+
import type { SSHService } from "@/node/services/sshService";
1213

1314
export interface ORPCContext {
1415
projectService: ProjectService;
@@ -21,5 +22,6 @@ export interface ORPCContext {
2122
serverService: ServerService;
2223
menuEventService: MenuEventService;
2324
voiceService: VoiceService;
25+
sshService: SSHService;
2426
headers?: IncomingHttpHeaders;
2527
}

src/node/orpc/router.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,14 @@ export const router = (authToken?: string) => {
731731
return context.voiceService.transcribe(input.audioBase64);
732732
}),
733733
},
734+
ssh: {
735+
getConfigHosts: t
736+
.input(schemas.ssh.getConfigHosts.input)
737+
.output(schemas.ssh.getConfigHosts.output)
738+
.handler(async ({ context }) => {
739+
return context.sshService.getConfigHosts();
740+
}),
741+
},
734742
debug: {
735743
triggerStreamError: t
736744
.input(schemas.debug.triggerStreamError.input)

0 commit comments

Comments
 (0)