Skip to content

Commit 76a731f

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 _Generated with `mux`_
1 parent 8860056 commit 76a731f

File tree

8 files changed

+199
-5
lines changed

8 files changed

+199
-5
lines changed

src/browser/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,9 @@ const webApi: IPCApi = {
364364
voice: {
365365
transcribe: (audioBase64) => invokeIPC(IPC_CHANNELS.VOICE_TRANSCRIBE, audioBase64),
366366
},
367+
ssh: {
368+
getConfigHosts: () => invokeIPC(IPC_CHANNELS.SSH_CONFIG_HOSTS),
369+
},
367370
update: {
368371
check: () => invokeIPC(IPC_CHANNELS.UPDATE_CHECK),
369372
download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD),

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[];
@@ -69,13 +70,10 @@ export function CreationControls(props: CreationControlsProps) {
6970
aria-label="Runtime mode"
7071
/>
7172
{props.runtimeMode === RUNTIME_MODE.SSH && (
72-
<input
73-
type="text"
73+
<SSHHostInput
7474
value={props.sshHost}
75-
onChange={(e) => props.onRuntimeChange(RUNTIME_MODE.SSH, e.target.value)}
76-
placeholder="user@host"
75+
onChange={(value) => props.onRuntimeChange(RUNTIME_MODE.SSH, value)}
7776
disabled={props.disabled}
78-
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"
7977
/>
8078
)}
8179
<TooltipWrapper inline>
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React, { useState, useEffect, useRef, useCallback } from "react";
2+
3+
interface SSHHostInputProps {
4+
value: string;
5+
onChange: (value: string) => void;
6+
disabled: boolean;
7+
}
8+
9+
/**
10+
* SSH host input with dropdown of hosts from SSH config.
11+
* Shows dropdown above the input when focused and there are matching hosts.
12+
*/
13+
export function SSHHostInput(props: SSHHostInputProps) {
14+
const [hosts, setHosts] = useState<string[]>([]);
15+
const [showDropdown, setShowDropdown] = useState(false);
16+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
17+
const inputRef = useRef<HTMLInputElement>(null);
18+
const containerRef = useRef<HTMLDivElement>(null);
19+
20+
// Fetch SSH config hosts on mount
21+
useEffect(() => {
22+
window.api.ssh
23+
.getConfigHosts()
24+
.then(setHosts)
25+
.catch(() => setHosts([]));
26+
}, []);
27+
28+
// Filter hosts based on current input
29+
const filteredHosts = hosts.filter((host) =>
30+
host.toLowerCase().includes(props.value.toLowerCase())
31+
);
32+
33+
// Handle clicking outside to close dropdown
34+
useEffect(() => {
35+
function handleClickOutside(e: MouseEvent) {
36+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
37+
setShowDropdown(false);
38+
}
39+
}
40+
document.addEventListener("mousedown", handleClickOutside);
41+
return () => document.removeEventListener("mousedown", handleClickOutside);
42+
}, []);
43+
44+
const { onChange } = props;
45+
const selectHost = useCallback(
46+
(host: string) => {
47+
onChange(host);
48+
setShowDropdown(false);
49+
setHighlightedIndex(-1);
50+
inputRef.current?.focus();
51+
},
52+
[onChange]
53+
);
54+
55+
const handleKeyDown = useCallback(
56+
(e: React.KeyboardEvent) => {
57+
if (!showDropdown || filteredHosts.length === 0) {
58+
return;
59+
}
60+
61+
switch (e.key) {
62+
case "ArrowDown":
63+
e.preventDefault();
64+
setHighlightedIndex((prev) => (prev < filteredHosts.length - 1 ? prev + 1 : 0));
65+
break;
66+
case "ArrowUp":
67+
e.preventDefault();
68+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredHosts.length - 1));
69+
break;
70+
case "Enter":
71+
if (highlightedIndex >= 0) {
72+
e.preventDefault();
73+
selectHost(filteredHosts[highlightedIndex]);
74+
}
75+
break;
76+
case "Escape":
77+
e.preventDefault();
78+
setShowDropdown(false);
79+
setHighlightedIndex(-1);
80+
break;
81+
}
82+
},
83+
[showDropdown, filteredHosts, highlightedIndex, selectHost]
84+
);
85+
86+
const handleFocus = () => {
87+
if (filteredHosts.length > 0) {
88+
setShowDropdown(true);
89+
}
90+
};
91+
92+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
93+
props.onChange(e.target.value);
94+
// Show dropdown when typing if there are matches
95+
if (hosts.length > 0) {
96+
setShowDropdown(true);
97+
}
98+
setHighlightedIndex(-1);
99+
};
100+
101+
// Show dropdown when there are filtered hosts
102+
const shouldShowDropdown = showDropdown && filteredHosts.length > 0 && !props.disabled;
103+
104+
return (
105+
<div ref={containerRef} className="relative">
106+
<input
107+
ref={inputRef}
108+
type="text"
109+
value={props.value}
110+
onChange={handleChange}
111+
onFocus={handleFocus}
112+
onKeyDown={handleKeyDown}
113+
placeholder="user@host"
114+
disabled={props.disabled}
115+
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"
116+
autoComplete="off"
117+
/>
118+
{shouldShowDropdown && (
119+
<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)]">
120+
{filteredHosts.map((host, index) => (
121+
<div
122+
key={host}
123+
onClick={() => selectHost(host)}
124+
onMouseEnter={() => setHighlightedIndex(index)}
125+
className={`cursor-pointer px-2 py-1 text-xs ${
126+
index === highlightedIndex
127+
? "bg-accent text-white"
128+
: "text-foreground hover:bg-border-medium"
129+
}`}
130+
>
131+
{host}
132+
</div>
133+
))}
134+
</div>
135+
)}
136+
</div>
137+
);
138+
}

src/browser/stories/mockFactory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,9 @@ export function createMockAPI(options: MockAPIOptions): IPCApi {
459459
voice: {
460460
transcribe: () => Promise.resolve({ success: false, error: "Not implemented in mock" }),
461461
},
462+
ssh: {
463+
getConfigHosts: () => Promise.resolve(["dev-server", "prod-server", "staging"]),
464+
},
462465
update: {
463466
check: () => Promise.resolve(undefined),
464467
download: () => Promise.resolve(undefined),

src/common/constants/ipc-constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export const IPC_CHANNELS = {
7171
// Voice channels
7272
VOICE_TRANSCRIBE: "voice:transcribe",
7373

74+
// SSH channels
75+
SSH_CONFIG_HOSTS: "ssh:configHosts",
76+
7477
// Dynamic channel prefixes
7578
WORKSPACE_CHAT_PREFIX: "workspace:chat:",
7679
WORKSPACE_METADATA: "workspace:metadata",

src/common/types/ipc.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,10 @@ export interface IPCApi {
375375
/** Transcribe audio using OpenAI Whisper. Audio should be base64-encoded webm/opus. */
376376
transcribe(audioBase64: string): Promise<Result<string, string>>;
377377
};
378+
ssh: {
379+
/** Get list of hosts from user's SSH config file */
380+
getConfigHosts(): Promise<string[]>;
381+
};
378382
update: {
379383
check(): Promise<void>;
380384
download(): Promise<void>;

src/desktop/preload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ const api: IPCApi = {
164164
transcribe: (audioBase64: string) =>
165165
ipcRenderer.invoke(IPC_CHANNELS.VOICE_TRANSCRIBE, audioBase64),
166166
},
167+
ssh: {
168+
getConfigHosts: () => ipcRenderer.invoke(IPC_CHANNELS.SSH_CONFIG_HOSTS),
169+
},
167170
update: {
168171
check: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CHECK),
169172
download: () => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_DOWNLOAD),

src/node/services/ipcMain.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1896,6 +1896,48 @@ export class IpcMain {
18961896
}
18971897
}
18981898
);
1899+
1900+
// SSH config hosts handler
1901+
ipcMain.handle(IPC_CHANNELS.SSH_CONFIG_HOSTS, async () => {
1902+
try {
1903+
return await this.parseSSHConfigHosts();
1904+
} catch (error) {
1905+
log.error("Failed to parse SSH config hosts:", error);
1906+
return [];
1907+
}
1908+
});
1909+
}
1910+
1911+
/**
1912+
* Parse SSH config file and extract host definitions.
1913+
* Returns list of configured hosts sorted alphabetically.
1914+
*/
1915+
private async parseSSHConfigHosts(): Promise<string[]> {
1916+
const sshConfigPath = path.join(process.env.HOME ?? "", ".ssh", "config");
1917+
try {
1918+
const content = await fsPromises.readFile(sshConfigPath, "utf-8");
1919+
const hosts = new Set<string>();
1920+
1921+
// Parse Host directives - each can have multiple patterns separated by whitespace
1922+
// Skip wildcards (*) and negation patterns (!)
1923+
for (const line of content.split("\n")) {
1924+
const trimmed = line.trim();
1925+
if (trimmed.toLowerCase().startsWith("host ")) {
1926+
const patterns = trimmed.slice(5).trim().split(/\s+/);
1927+
for (const pattern of patterns) {
1928+
// Skip wildcards and negation patterns
1929+
if (!pattern.includes("*") && !pattern.includes("?") && !pattern.startsWith("!")) {
1930+
hosts.add(pattern);
1931+
}
1932+
}
1933+
}
1934+
}
1935+
1936+
return Array.from(hosts).sort((a, b) => a.localeCompare(b));
1937+
} catch {
1938+
// File doesn't exist or can't be read - return empty list
1939+
return [];
1940+
}
18991941
}
19001942

19011943
private registerTerminalHandlers(ipcMain: ElectronIpcMain, mainWindow: BrowserWindow): void {

0 commit comments

Comments
 (0)