Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .storybook/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 3 additions & 5 deletions src/browser/components/ChatInput/CreationControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -83,13 +84,10 @@ export function CreationControls(props: CreationControlsProps) {

{/* SSH Host Input - after From selector */}
{props.runtimeMode === RUNTIME_MODE.SSH && (
<input
type="text"
<SSHHostInput
value={props.sshHost}
onChange={(e) => 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"
/>
)}
</div>
Expand Down
153 changes: 153 additions & 0 deletions src/browser/components/ChatInput/SSHHostInput.tsx
Original file line number Diff line number Diff line change
@@ -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<string[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Array<HTMLDivElement | null>>([]);

// 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<HTMLInputElement>) => {
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 (
<div ref={containerRef} className="relative">
<input
ref={inputRef}
type="text"
value={props.value}
onChange={handleChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="user@host"
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"
autoComplete="off"
/>
{shouldShowDropdown && (
<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)]">
{filteredHosts.map((host, index) => (
<div
key={host}
ref={(el) => (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}
</div>
))}
</div>
)}
</div>
);
}
1 change: 1 addition & 0 deletions src/cli/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ async function createTestServer(): Promise<TestServerHandle> {
serverService: services.serverService,
menuEventService: services.menuEventService,
voiceService: services.voiceService,
sshService: services.sshService,
};

// Use the actual createOrpcServer function
Expand Down
1 change: 1 addition & 0 deletions src/cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const mockWindow: BrowserWindow = {
serverService: serviceContainer.serverService,
menuEventService: serviceContainer.menuEventService,
voiceService: serviceContainer.voiceService,
sshService: serviceContainer.sshService,
};

const server = await createOrpcServer({
Expand Down
1 change: 1 addition & 0 deletions src/common/orpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export {
providers,
ProvidersConfigMapSchema,
server,
ssh,
terminal,
tokenizer,
update,
Expand Down
12 changes: 12 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down
1 change: 1 addition & 0 deletions src/desktop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ async function loadServices(): Promise<void> {
serverService: services!.serverService,
menuEventService: services!.menuEventService,
voiceService: services!.voiceService,
sshService: services!.sshService,
},
});
serverPort.start();
Expand Down
2 changes: 2 additions & 0 deletions src/node/orpc/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,5 +22,6 @@ export interface ORPCContext {
serverService: ServerService;
menuEventService: MenuEventService;
voiceService: VoiceService;
sshService: SSHService;
headers?: IncomingHttpHeaders;
}
8 changes: 8 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/node/services/serviceContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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<void> {
Expand Down
39 changes: 39 additions & 0 deletions src/node/services/sshService.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
const sshConfigPath = path.join(process.env.HOME ?? "", ".ssh", "config");
try {
const content = await fsPromises.readFile(sshConfigPath, "utf-8");
const hosts = new Set<string>();

// 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 [];
}
}
}
1 change: 1 addition & 0 deletions tests/integration/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export async function createTestEnvironment(): Promise<TestEnvironment> {
serverService: services.serverService,
menuEventService: services.menuEventService,
voiceService: services.voiceService,
sshService: services.sshService,
};
const orpc = createOrpcTestClient(orpcContext);

Expand Down