From 372f54a41c500f5e0db385fbc21265cdd4549fd5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:16:43 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20SSH=20config=20hos?= =?UTF-8?q?ts=20dropdown=20for=20runtime=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`_ --- .storybook/mocks/orpc.ts | 6 + .../components/ChatInput/CreationControls.tsx | 8 +- .../components/ChatInput/SSHHostInput.tsx | 153 ++++++++++++++++++ src/cli/cli.test.ts | 1 + src/cli/server.test.ts | 1 + src/cli/server.ts | 1 + src/common/orpc/schemas.ts | 1 + src/common/orpc/schemas/api.ts | 12 ++ src/desktop/main.ts | 1 + src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 8 + src/node/services/serviceContainer.ts | 3 + src/node/services/sshService.ts | 39 +++++ tests/ipc/setup.ts | 1 + 14 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 src/browser/components/ChatInput/SSHHostInput.tsx create mode 100644 src/node/services/sshService.ts diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index a5184edd3f..1a84e417f8 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -237,5 +237,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl await new Promise(() => {}); }, }, + ssh: { + getConfigHosts: async () => ["dev-server", "prod-server", "staging"], + }, + voice: { + transcribe: async () => ({ success: false, error: "Not implemented in mock" }), + }, } as unknown as APIClient; } diff --git a/src/browser/components/ChatInput/CreationControls.tsx b/src/browser/components/ChatInput/CreationControls.tsx index e9f4532c69..6a2fbd1658 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -6,6 +6,7 @@ import { Loader2, Wand2 } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; import type { WorkspaceNameState } from "@/browser/hooks/useWorkspaceName"; +import { SSHHostInput } from "./SSHHostInput"; interface CreationControlsProps { branches: string[]; @@ -146,13 +147,10 @@ export function CreationControls(props: CreationControlsProps) { {/* SSH Host Input - after From selector */} {props.runtimeMode === RUNTIME_MODE.SSH && ( - props.onSshHostChange(e.target.value)} - placeholder="user@host" + onChange={props.onSshHostChange} disabled={props.disabled} - 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" /> )} diff --git a/src/browser/components/ChatInput/SSHHostInput.tsx b/src/browser/components/ChatInput/SSHHostInput.tsx new file mode 100644 index 0000000000..437f1c24cd --- /dev/null +++ b/src/browser/components/ChatInput/SSHHostInput.tsx @@ -0,0 +1,153 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { useAPI } from "@/browser/contexts/API"; + +interface SSHHostInputProps { + value: string; + onChange: (value: string) => void; + disabled: boolean; +} + +/** + * SSH host input with dropdown of hosts from SSH config. + * Shows dropdown above the input when focused and there are matching hosts. + */ +export function SSHHostInput(props: SSHHostInputProps) { + const { api } = useAPI(); + const [hosts, setHosts] = useState([]); + const [showDropdown, setShowDropdown] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef(null); + const containerRef = useRef(null); + const itemRefs = useRef>([]); + + // Fetch SSH config hosts on mount + useEffect(() => { + if (!api) return; + api.ssh + .getConfigHosts() + .then(setHosts) + .catch(() => setHosts([])); + }, [api]); + + // Filter hosts based on current input + const filteredHosts = hosts.filter((host) => + host.toLowerCase().includes(props.value.toLowerCase()) + ); + + // Handle clicking outside to close dropdown + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const { onChange } = props; + const selectHost = useCallback( + (host: string) => { + onChange(host); + setShowDropdown(false); + setHighlightedIndex(-1); + inputRef.current?.focus(); + }, + [onChange] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!showDropdown || filteredHosts.length === 0) { + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setHighlightedIndex((prev) => (prev < filteredHosts.length - 1 ? prev + 1 : 0)); + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredHosts.length - 1)); + break; + case "Enter": + if (highlightedIndex >= 0) { + e.preventDefault(); + selectHost(filteredHosts[highlightedIndex]); + } + break; + case "Escape": + e.preventDefault(); + setShowDropdown(false); + setHighlightedIndex(-1); + break; + } + }, + [showDropdown, filteredHosts, highlightedIndex, selectHost] + ); + + const handleFocus = () => { + if (filteredHosts.length > 0) { + setShowDropdown(true); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + props.onChange(e.target.value); + // Show dropdown when typing if there are matches + if (hosts.length > 0) { + setShowDropdown(true); + } + setHighlightedIndex(-1); + }; + + // Scroll highlighted item into view + useEffect(() => { + if (highlightedIndex >= 0 && itemRefs.current[highlightedIndex]) { + itemRefs.current[highlightedIndex]?.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [highlightedIndex]); + + // Show dropdown when there are filtered hosts + const shouldShowDropdown = showDropdown && filteredHosts.length > 0 && !props.disabled; + + return ( +
+ + {shouldShowDropdown && ( +
+ {filteredHosts.map((host, index) => ( +
(itemRefs.current[index] = el)} + onClick={() => selectHost(host)} + onMouseEnter={() => setHighlightedIndex(index)} + className={`cursor-pointer px-2 py-1 text-xs ${ + index === highlightedIndex + ? "bg-accent text-white" + : "text-foreground hover:bg-border-medium" + }`} + > + {host} +
+ ))} +
+ )} +
+ ); +} diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 578f18899b..9b8cb85108 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -73,6 +73,7 @@ async function createTestServer(authToken?: string): Promise { menuEventService: services.menuEventService, voiceService: services.voiceService, telemetryService: services.telemetryService, + sshService: services.sshService, }; // Use the actual createOrpcServer function diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index ce3be52355..c490c48d2d 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -76,6 +76,7 @@ async function createTestServer(): Promise { menuEventService: services.menuEventService, voiceService: services.voiceService, telemetryService: services.telemetryService, + sshService: services.sshService, }; // Use the actual createOrpcServer function diff --git a/src/cli/server.ts b/src/cli/server.ts index 01677bbd9a..e5024914b7 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -84,6 +84,7 @@ const mockWindow: BrowserWindow = { mcpServerManager: serviceContainer.mcpServerManager, voiceService: serviceContainer.voiceService, telemetryService: serviceContainer.telemetryService, + sshService: serviceContainer.sshService, }; const server = await createOrpcServer({ diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index 849d2a1e91..6f62d9081e 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -108,6 +108,7 @@ export { providers, ProvidersConfigMapSchema, server, + ssh, telemetry, TelemetryEventSchema, terminal, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 445dda6eb5..25354c8886 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -524,6 +524,18 @@ export const voice = { }, }; +// SSH utilities +export const ssh = { + /** + * Get list of hosts from user's SSH config file (~/.ssh/config). + * Returns hosts sorted alphabetically, excluding wildcards and negation patterns. + */ + getConfigHosts: { + input: z.void(), + output: z.array(z.string()), + }, +}; + // Debug endpoints (test-only, not for production use) export const debug = { /** diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 1a4311cf9c..76cf33c7e1 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -338,6 +338,7 @@ async function loadServices(): Promise { menuEventService: services.menuEventService, voiceService: services.voiceService, telemetryService: services.telemetryService, + sshService: services.sshService, }; electronIpcMain.on("start-orpc-server", (event) => { diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 9d22148ab0..72eabe39f4 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -15,6 +15,7 @@ import type { VoiceService } from "@/node/services/voiceService"; import type { MCPConfigService } from "@/node/services/mcpConfigService"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import type { TelemetryService } from "@/node/services/telemetryService"; +import type { SSHService } from "@/node/services/sshService"; export interface ORPCContext { config: Config; @@ -33,5 +34,6 @@ export interface ORPCContext { mcpConfigService: MCPConfigService; mcpServerManager: MCPServerManager; telemetryService: TelemetryService; + sshService: SSHService; headers?: IncomingHttpHeaders; } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c07a506a7c..1a40f69a69 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -829,6 +829,14 @@ export const router = (authToken?: string) => { return context.voiceService.transcribe(input.audioBase64); }), }, + ssh: { + getConfigHosts: t + .input(schemas.ssh.getConfigHosts.input) + .output(schemas.ssh.getConfigHosts.output) + .handler(async ({ context }) => { + return context.sshService.getConfigHosts(); + }), + }, debug: { triggerStreamError: t .input(schemas.debug.triggerStreamError.input) diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 8807c754e7..53e61402e2 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -23,6 +23,7 @@ import { TelemetryService } from "@/node/services/telemetryService"; import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import { MCPConfigService } from "@/node/services/mcpConfigService"; import { MCPServerManager } from "@/node/services/mcpServerManager"; +import { SSHService } from "@/node/services/sshService"; /** * ServiceContainer - Central dependency container for all backend services. @@ -49,6 +50,7 @@ export class ServiceContainer { public readonly mcpConfigService: MCPConfigService; public readonly mcpServerManager: MCPServerManager; public readonly telemetryService: TelemetryService; + public readonly sshService: SSHService; private readonly initStateManager: InitStateManager; private readonly extensionMetadata: ExtensionMetadataService; private readonly ptyService: PTYService; @@ -101,6 +103,7 @@ export class ServiceContainer { this.menuEventService = new MenuEventService(); this.voiceService = new VoiceService(config); this.telemetryService = new TelemetryService(config.rootDir); + this.sshService = new SSHService(); } async initialize(): Promise { diff --git a/src/node/services/sshService.ts b/src/node/services/sshService.ts new file mode 100644 index 0000000000..cf473555af --- /dev/null +++ b/src/node/services/sshService.ts @@ -0,0 +1,39 @@ +import * as fsPromises from "fs/promises"; +import * as path from "path"; + +/** + * SSH utilities service. + */ +export class SSHService { + /** + * Parse SSH config file and extract host definitions. + * Returns list of configured hosts sorted alphabetically. + */ + async getConfigHosts(): Promise { + const sshConfigPath = path.join(process.env.HOME ?? "", ".ssh", "config"); + try { + const content = await fsPromises.readFile(sshConfigPath, "utf-8"); + const hosts = new Set(); + + // Parse Host directives - each can have multiple patterns separated by whitespace + // Skip wildcards (*) and negation patterns (!) + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (trimmed.toLowerCase().startsWith("host ")) { + const patterns = trimmed.slice(5).trim().split(/\s+/); + for (const pattern of patterns) { + // Skip wildcards and negation patterns + if (!pattern.includes("*") && !pattern.includes("?") && !pattern.startsWith("!")) { + hosts.add(pattern); + } + } + } + } + + return Array.from(hosts).sort((a, b) => a.localeCompare(b)); + } catch { + // File doesn't exist or can't be read - return empty list + return []; + } + } +} diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 8cd1908c1d..85b2797751 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -85,6 +85,7 @@ export async function createTestEnvironment(): Promise { menuEventService: services.menuEventService, voiceService: services.voiceService, telemetryService: services.telemetryService, + sshService: services.sshService, }; const orpc = createOrpcTestClient(orpcContext);