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: 3 additions & 3 deletions src/main/db.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
export const projects = sqliteTable("projects", {
id: text("id").primaryKey(),
name: text("name").notNull(),
locationKind: text("location_kind").notNull(), // "windows" | "wsl" | "posix"
locationPath: text("location_path"), // for windows/posix
locationDistro: text("location_distro"), // for wsl
locationKind: text("location_kind").notNull(), // "windows" | "wsl" | "posix" | "ssh"
locationPath: text("location_path"), // for windows/posix/ssh
locationDistro: text("location_distro"), // for wsl distro / ssh host
locationLinuxPath: text("location_linux_path"), // for wsl
locationUncPath: text("location_unc_path"), // for wsl
lastDraftConfig: text("last_draft_config"), // JSON
Expand Down
8 changes: 6 additions & 2 deletions src/main/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,9 @@ export function closeDatabase() {
function locationToRow(loc: ProjectLocation) {
return {
locationKind: loc.kind,
locationPath: loc.kind !== "wsl" ? loc.path : null,
locationDistro: loc.kind === "wsl" ? loc.distro : null,
locationPath:
loc.kind === "windows" || loc.kind === "posix" || loc.kind === "ssh" ? loc.path : null,
locationDistro: loc.kind === "wsl" ? loc.distro : loc.kind === "ssh" ? loc.host : null,
locationLinuxPath: loc.kind === "wsl" ? loc.linuxPath : null,
locationUncPath: loc.kind === "wsl" ? loc.uncPath : null,
};
Expand All @@ -328,6 +329,9 @@ function rowToLocation(row: {
if (row.locationKind === "posix") {
return { kind: "posix", path: row.locationPath! };
}
if (row.locationKind === "ssh") {
return { kind: "ssh", host: row.locationDistro!, path: row.locationPath! };
}
return { kind: "windows", path: row.locationPath! };
}

Expand Down
10 changes: 5 additions & 5 deletions src/renderer/actions/agentLoginActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,22 +59,22 @@ export function runAgentLoginCommand(input: {
}

const shellId = `login:${crypto.randomUUID()}`;
// On WSL the agent CLI can't reach the Windows browser on its own, so we
// In remote shells the agent CLI can't reach the Windows browser on its own, so we
// suppress its opener (BROWSER=/bin/true) and watch stdout for auth URLs to
// hand off via the Windows shell. Native macOS / Windows CLIs already open
// their own browser, so a renderer-side watcher would just double-launch.
const suppressWslBrowser = project.location.kind === "wsl";
const interceptWslUrls = suppressWslBrowser && !isGeminiLoginCommand(input);
const suppressRemoteBrowser = project.location.kind === "wsl" || project.location.kind === "ssh";
const interceptRemoteUrls = suppressRemoteBrowser && !isGeminiLoginCommand(input);
// Wipe the bash prompt + echoed script line that briefly appear before the
// TUI takes over. `clear` (POSIX) / `Clear-Host` (PowerShell) gives the
// overlay a clean canvas so the user only sees the agent's own UI.
const loginCommand = buildTerminalCommand({
command: input.command,
env: suppressWslBrowser ? { BROWSER: "/bin/true", ...(input.env ?? {}) } : input.env,
env: suppressRemoteBrowser ? { BROWSER: "/bin/true", ...(input.env ?? {}) } : input.env,
});
const command =
project.location.kind === "windows" ? `Clear-Host; ${loginCommand}` : `clear; ${loginCommand}`;
const stopOpeningUrls = interceptWslUrls ? watchUrlsInNativeBrowser(shellId) : undefined;
const stopOpeningUrls = interceptRemoteUrls ? watchUrlsInNativeBrowser(shellId) : undefined;
const completionToken = createCompletionToken();
const script = appendCompletionSignal(command, project, completionToken);

Expand Down
9 changes: 6 additions & 3 deletions src/renderer/actions/panelActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readBridge } from "@/renderer/bridge";
import { useAppStore } from "@/renderer/state/appStore";
import { useDevTerminalStore } from "@/renderer/state/devTerminalStore";
import { useFileEditorStore } from "@/renderer/state/fileEditorStore";
import { usePanelStore } from "@/renderer/state/panelStore";
import { usePanelStore, type ProjectSettingsSectionId } from "@/renderer/state/panelStore";
import { useSharedSettings } from "@/renderer/state/sharedSettingsStore";
import { buildFileEditorContext, resolveWorktreeBranch } from "@/renderer/utils/gitHelpers";
import { closeThreads } from "@/renderer/utils/shellUtils";
Expand Down Expand Up @@ -83,8 +83,11 @@ export function openUsagePanel(): void {
panelStore.openUsagePanel();
}

export function openProjectSettings(projectId: string): void {
usePanelStore.getState().openProjectSettings(projectId);
export function openProjectSettings(
projectId: string,
initialSection?: ProjectSettingsSectionId,
): void {
usePanelStore.getState().openProjectSettings(projectId, initialSection);
}

/** Closes git/files side and right-panel content only. Does not hide the dev terminal (bottom or right). */
Expand Down
15 changes: 15 additions & 0 deletions src/renderer/actions/projectActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,24 @@ export function setProjectDisabled(projectId: string, disabled: boolean): void {
if (!project) return;
if ((project.disabled ?? false) === disabled) return;

const projectThreadIds = disabled
? store.threads.filter((t) => t.projectId === projectId).map((t) => t.id)
: [];

store.setProjectDisabled(projectId, disabled);

if (disabled) {
if (project.location.kind === "ssh" && projectThreadIds.length > 0) {
void Promise.allSettled(
projectThreadIds.map((threadId) => readBridge().closeThread({ threadId })),
).then(() => {
const nextStore = useAppStore.getState();
for (const threadId of projectThreadIds) {
nextStore.markThreadExited(threadId);
}
});
}

void readBridge()
.gitUnwatchProject({ projectId })
.catch(() => undefined);
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ const { bridge } = vi.hoisted(() => ({
pickFolder: vi.fn<() => Promise<null>>().mockResolvedValue(null),
listWslDistros: vi.fn<() => Promise<string[]>>().mockResolvedValue([]),
getAgentStatuses: vi
.fn<() => Promise<{ windows: unknown[]; wsl: unknown[]; fromCache: boolean }>>()
.mockResolvedValue({ windows: [], wsl: [], fromCache: false }),
.fn<
() => Promise<{ windows: unknown[]; wsl: unknown[]; ssh: unknown[]; fromCache: boolean }>
>()
.mockResolvedValue({ windows: [], wsl: [], ssh: [], fromCache: false }),
getThreadSnapshots: vi.fn<() => Promise<unknown[]>>().mockResolvedValue([]),
getHomeScopeLocation: vi
.fn<() => Promise<{ kind: "windows"; path: string }>>()
Expand Down
12 changes: 12 additions & 0 deletions src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,18 @@ const unsubSupervisor = readBridge().onSupervisorEvent((event) => {
store.setWslAgentStatuses(event.statuses);
}
}
if (event.type === "ssh-agent-statuses") {
console.log(`[renderer] event: ssh-agent-statuses (${event.statuses.length} agents)`);
const store = useAgentStatusesStore.getState();
if (store.inFirstLaunchDiscovery && store.discoveryScope?.kind === "ssh") {
const statuses = event.statuses;
setTimeout(() => {
useAgentStatusesStore.getState().setSshAgentStatuses(statuses);
}, 1000);
} else {
store.setSshAgentStatuses(event.statuses);
}
}
});

const unsubUpdate = readBridge().onUpdateStatus((status) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ describe("readConflictResolverSettingsForProject", () => {
});
});

it("falls back to Windows settings for SSH projects when WSL conflict resolver is unset", () => {
expect(readConflictResolverSettingsForProject("ssh", settings)).toEqual({
provider: "cursor",
model: "composer-2.5",
effort: "",
presentationMode: "terminal",
});
});

it("uses WSL settings when WSL conflict resolver is configured", () => {
expect(
readConflictResolverSettingsForProject("wsl", {
Expand All @@ -70,6 +79,22 @@ describe("readConflictResolverSettingsForProject", () => {
presentationMode: "terminal",
});
});

it("uses WSL settings for SSH projects when WSL conflict resolver is configured", () => {
expect(
readConflictResolverSettingsForProject("ssh", {
...settings,
wslConflictResolverProvider: "cursor",
wslConflictResolverModel: "composer-2.5-fast",
wslConflictResolverPresentationMode: "terminal",
}),
).toEqual({
provider: "cursor",
model: "composer-2.5-fast",
effort: "",
presentationMode: "terminal",
});
});
});

describe("resolveConflictResolverLaunchConfig", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/providers/conflictResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function readConflictResolverSettingsForProject(
locationKind: ProjectLocation["kind"],
settings: ConflictResolverSettingsSource,
): ConflictResolverSettings {
if (locationKind !== "wsl") {
if (locationKind !== "wsl" && locationKind !== "ssh") {
return {
provider: settings.conflictResolverProvider,
model: settings.conflictResolverModel,
Expand Down
70 changes: 50 additions & 20 deletions src/renderer/components/thread/AgentDiscoveryScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ function readyBadge(status: AgentStatus): { label: string; toneClass: string } |
return { label: "Ready", toneClass: "text-success" };
}

function statusLine(scopedCount: number, installedCount: number, wslDistro: string | undefined) {
function statusLine(scopedCount: number, installedCount: number, remoteKind: string | undefined) {
if (scopedCount === 0) {
return wslDistro ? "Warming up WSL shell environment…" : "Warming up shell environment…";
return remoteKind
? `Warming up ${remoteKind} shell environment…`
: "Warming up shell environment…";
}
if (installedCount === 0) return "No agents installed yet";
if (installedCount === 1) return "1 agent ready";
Expand Down Expand Up @@ -62,6 +64,7 @@ function statusLabel(status: AgentStatus | undefined): { label: string; toneClas
export function AgentDiscoveryScreen(props: {
location?: ProjectLocation;
onCancel?: () => void;
sshHosts?: string[];
wslDistros?: string[];
}) {
// `discoveredAgents` is already scoped by `pushDiscoveredAgent` to the active
Expand All @@ -79,6 +82,7 @@ export function AgentDiscoveryScreen(props: {
}
const installedCount = discovered.reduce((n, s) => n + (s.installed ? 1 : 0), 0);
const wslDistro = props.location?.kind === "wsl" ? props.location.distro : undefined;
const sshHost = props.location?.kind === "ssh" ? props.location.host : undefined;
const scanTargets: ScanTarget[] =
wslDistro !== undefined
? [
Expand All @@ -88,35 +92,55 @@ export function AgentDiscoveryScreen(props: {
matches: (status) => status.envKind === "wsl" && status.envDistro === wslDistro,
},
]
: props.wslDistros !== undefined
: sshHost !== undefined
? [
{
key: "native",
label: "Windows",
matches: (status) => status.envKind !== "wsl",
key: `ssh:${sshHost}`,
label: `SSH: ${sshHost}`,
matches: (status) => status.envKind === "ssh" && status.envHost === sshHost,
},
...props.wslDistros.map((distro) => ({
key: `wsl:${distro}`,
label: `WSL: ${distro}`,
matches: (status: AgentStatus) =>
status.envKind === "wsl" && status.envDistro === distro,
})),
]
: discoveryScope?.kind === "all"
: props.wslDistros !== undefined || props.sshHosts !== undefined
? [
{
key: "native",
label: "Windows",
matches: (status) => status.envKind !== "wsl",
matches: (status) => status.envKind !== "wsl" && status.envKind !== "ssh",
},
...discoveryScope.wslDistros.map((distro) => ({
...(props.wslDistros ?? []).map((distro) => ({
key: `wsl:${distro}`,
label: `WSL: ${distro}`,
matches: (status: AgentStatus) =>
status.envKind === "wsl" && status.envDistro === distro,
})),
...(props.sshHosts ?? []).map((host) => ({
key: `ssh:${host}`,
label: `SSH: ${host}`,
matches: (status: AgentStatus) =>
status.envKind === "ssh" && status.envHost === host,
})),
]
: [];
: discoveryScope?.kind === "all"
? [
{
key: "native",
label: "Windows",
matches: (status) => status.envKind !== "wsl" && status.envKind !== "ssh",
},
...discoveryScope.wslDistros.map((distro) => ({
key: `wsl:${distro}`,
label: `WSL: ${distro}`,
matches: (status: AgentStatus) =>
status.envKind === "wsl" && status.envDistro === distro,
})),
...(discoveryScope.sshHosts ?? []).map((host) => ({
key: `ssh:${host}`,
label: `SSH: ${host}`,
matches: (status: AgentStatus) =>
status.envKind === "ssh" && status.envHost === host,
})),
]
: [];
// Provider plugins self-register at module-load time; reading the registry
// each render keeps this screen in sync as new agent kinds are added.
const providers = getRegisteredProviders();
Expand All @@ -143,9 +167,11 @@ export function AgentDiscoveryScreen(props: {
<p className="max-w-sm text-sm text-muted">
{wslDistro
? `Scanning ${wslDistro} for installed CLIs. This usually takes a couple of seconds.`
: scanTargets.length > 1
? "Scanning Windows and WSL for installed CLIs. This usually takes a couple of seconds."
: "Scanning your system for installed CLIs. This usually takes a couple of seconds."}
: sshHost
? `Scanning ${sshHost} for installed CLIs. This usually takes a couple of seconds.`
: scanTargets.length > 1
? "Scanning Windows and WSL for installed CLIs. This usually takes a couple of seconds."
: "Scanning your system for installed CLIs. This usually takes a couple of seconds."}
</p>
{scanTargets.length > 1 ? (
<div className="flex flex-wrap items-center justify-center gap-1.5">
Expand Down Expand Up @@ -207,7 +233,11 @@ export function AgentDiscoveryScreen(props: {
<div className="shrink-0 text-xs text-muted/70" aria-live="polite">
{useMatrixLayout
? combinedStatusLine(discovered)
: statusLine(discovered.length, installedCount, wslDistro)}
: statusLine(
discovered.length,
installedCount,
wslDistro ? "WSL" : sshHost ? "SSH" : undefined,
)}
</div>

{props.onCancel ? (
Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/thread/ChatPane/chatPathUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function getProjectRoots(projectLocation: ProjectLocation): string[] {
return [projectLocation.path];
case "wsl":
return [projectLocation.linuxPath, projectLocation.uncPath];
case "ssh":
case "posix":
return [projectLocation.path];
}
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/components/thread/ProjectSwitchMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { startTransition } from "react";
import { ChevronDown, FolderOpen, House, Monitor } from "lucide-react";
import { ChevronDown, FolderOpen, House, Monitor, Server } from "lucide-react";
import { Dropdown, Label } from "@heroui/react";
import { useShallow } from "zustand/shallow";
import type { Project } from "@/shared/contracts";
Expand All @@ -20,6 +20,9 @@ function LocationIcon(props: { kind: Project["location"]["kind"]; className?: st
if (props.kind === "windows") {
return <Monitor className={className} />;
}
if (props.kind === "ssh") {
return <Server className={className} />;
}
return <FolderOpen className={className} />;
}

Expand Down
14 changes: 10 additions & 4 deletions src/renderer/components/thread/ThreadAgentUpdateDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ export function ThreadAgentUpdateDock(props: {
>(undefined);
const [pending, setPending] = useState(false);

const isSshStatus = agentStatus.envKind === "ssh";
const registryAgentId = acpGenericInstanceId(agentStatus.kind);

useEffect(() => {
if (isSshStatus) return;
if (registryAgentId) return;
if (!agentStatus.installed || !agentStatus.version) return;
let cancelled = false;
Expand All @@ -65,7 +67,7 @@ export function ThreadAgentUpdateDock(props: {
return () => {
cancelled = true;
};
}, [agentStatus.kind, agentStatus.installed, agentStatus.version, registryAgentId]);
}, [agentStatus.kind, agentStatus.installed, agentStatus.version, isSshStatus, registryAgentId]);

// Identity-gated read so a stale entry from a previous agent never matches
// the current one (avoids one-frame flash on agent switch).
Expand All @@ -82,15 +84,19 @@ export function ThreadAgentUpdateDock(props: {
installedVersion !== undefined &&
isNewerVersion(resolvedLatest, installedVersion);

if (!isOutdated || !resolvedLatest || !installedVersion) {
if (isSshStatus || !isOutdated || !resolvedLatest || !installedVersion) {
return null;
}

const scope = statusUpdateScope(agentStatus);
if (scope.envKind === "ssh") {
return null;
}
const updateEnvKind = scope.envKind;
const previewCommand = resolveSharedUpdateCommand({
update: agentStatus.update,
executablePath: agentStatus.executablePath,
envKind: scope.envKind,
envKind: updateEnvKind,
});
const previewCommandLine = previewCommand ? formatUpdateCommandLine(previewCommand) : undefined;

Expand All @@ -103,7 +109,7 @@ export function ThreadAgentUpdateDock(props: {
try {
const result = await readBridge().updateAgentBinary({
agentKind: agentStatus.kind,
envKind: scope.envKind,
envKind: updateEnvKind,
...(scope.wslDistro ? { wslDistro: scope.wslDistro } : {}),
});
if (result.ok) {
Expand Down
Loading