diff --git a/.storybook/mocks/orpc.ts b/.storybook/mocks/orpc.ts index db46ca56f..29fda0158 100644 --- a/.storybook/mocks/orpc.ts +++ b/.storybook/mocks/orpc.ts @@ -202,5 +202,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 3ad806fe4..c206d5eb2 100644 --- a/src/browser/components/ChatInput/CreationControls.tsx +++ b/src/browser/components/ChatInput/CreationControls.tsx @@ -2,6 +2,7 @@ import React from "react"; import { RUNTIME_MODE, type RuntimeMode } from "@/common/types/runtime"; import { TooltipWrapper, Tooltip } from "../Tooltip"; import { Select } from "../Select"; +import { SSHHostInput } from "./SSHHostInput"; interface CreationControlsProps { branches: string[]; @@ -83,13 +84,10 @@ export function CreationControls(props: CreationControlsProps) { {/* SSH Host Input - after From selector */} {props.runtimeMode === RUNTIME_MODE.SSH && ( - props.onRuntimeChange(RUNTIME_MODE.SSH, e.target.value)} - placeholder="user@host" + onChange={(value) => props.onRuntimeChange(RUNTIME_MODE.SSH, value)} disabled={props.disabled} - 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" /> )} diff --git a/src/browser/components/ChatInput/SSHHostInput.tsx b/src/browser/components/ChatInput/SSHHostInput.tsx new file mode 100644 index 000000000..437f1c24c --- /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/server.test.ts b/src/cli/server.test.ts index 904048e38..5a05d102a 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -70,6 +70,7 @@ async function createTestServer(): Promise { serverService: services.serverService, menuEventService: services.menuEventService, voiceService: services.voiceService, + sshService: services.sshService, }; // Use the actual createOrpcServer function diff --git a/src/cli/server.ts b/src/cli/server.ts index 743ef1fc0..46ae032e8 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -68,6 +68,7 @@ const mockWindow: BrowserWindow = { serverService: serviceContainer.serverService, menuEventService: serviceContainer.menuEventService, voiceService: serviceContainer.voiceService, + sshService: serviceContainer.sshService, }; const server = await createOrpcServer({ diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index d9fec88dc..160810ac7 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -98,6 +98,7 @@ export { providers, ProvidersConfigMapSchema, server, + ssh, terminal, tokenizer, update, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index e1fd2d860..e3731bca6 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -399,6 +399,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 f12dceabe..1b07081d3 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -323,6 +323,7 @@ async function loadServices(): Promise { serverService: services!.serverService, menuEventService: services!.menuEventService, voiceService: services!.voiceService, + sshService: services!.sshService, }, }); serverPort.start(); diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 996db5d1f..175c5b284 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -9,6 +9,7 @@ import type { TokenizerService } from "@/node/services/tokenizerService"; import type { ServerService } from "@/node/services/serverService"; import type { MenuEventService } from "@/node/services/menuEventService"; import type { VoiceService } from "@/node/services/voiceService"; +import type { SSHService } from "@/node/services/sshService"; export interface ORPCContext { projectService: ProjectService; @@ -21,5 +22,6 @@ export interface ORPCContext { serverService: ServerService; menuEventService: MenuEventService; voiceService: VoiceService; + sshService: SSHService; headers?: IncomingHttpHeaders; } diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 87a774751..a2d5a538c 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -731,6 +731,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 d1bcc1537..5341f1edb 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -17,6 +17,7 @@ import { TokenizerService } from "@/node/services/tokenizerService"; import { ServerService } from "@/node/services/serverService"; import { MenuEventService } from "@/node/services/menuEventService"; import { VoiceService } from "@/node/services/voiceService"; +import { SSHService } from "@/node/services/sshService"; /** * ServiceContainer - Central dependency container for all backend services. @@ -39,6 +40,7 @@ export class ServiceContainer { public readonly serverService: ServerService; public readonly menuEventService: MenuEventService; public readonly voiceService: VoiceService; + public readonly sshService: SSHService; private readonly initStateManager: InitStateManager; private readonly extensionMetadata: ExtensionMetadataService; private readonly ptyService: PTYService; @@ -78,6 +80,7 @@ export class ServiceContainer { this.serverService = new ServerService(); this.menuEventService = new MenuEventService(); this.voiceService = new VoiceService(config); + 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 000000000..cf473555a --- /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/integration/setup.ts b/tests/integration/setup.ts index 180ba8e1d..27f19e5af 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -79,6 +79,7 @@ export async function createTestEnvironment(): Promise { serverService: services.serverService, menuEventService: services.menuEventService, voiceService: services.voiceService, + sshService: services.sshService, }; const orpc = createOrpcTestClient(orpcContext);