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
8 changes: 8 additions & 0 deletions packages/app/src/electron/windows/main-window.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { inDevelopment } from "@/shared/constants/dev";
import {
createRendererStartupProviderArgument,
parseStartupProvider,
} from "@/shared/startup-provider";
import {
BrowserWindow,
BrowserWindowConstructorOptions,
Expand Down Expand Up @@ -29,6 +33,7 @@ function createPlatformSpecificConfig(): BrowserWindowConstructorOptions {
const height = Math.round(screenHeight * 0.8);
const x = Math.round((screenWidth - width) / 2);
const y = Math.round((screenHeight - height) / 2);
const startupProvider = parseStartupProvider(process.argv);

const baseConfig: BrowserWindowConstructorOptions = {
width,
Expand All @@ -47,6 +52,9 @@ function createPlatformSpecificConfig(): BrowserWindowConstructorOptions {
// inside Electron's restricted sandbox where those imports fail.
sandbox: false,
preload: path.join(__dirname, "preload.js"),
additionalArguments: startupProvider
? [createRendererStartupProviderArgument(startupProvider)]
: undefined,
},
show: false,
titleBarStyle: "hiddenInset",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import { exposeLoggerContext } from "./electro-bridge/ipc/logger-context";
import { exposeLocalAIContext } from "./electro-bridge/ipc/local-ai-context";
import { exposeMCPContext } from "./electro-bridge/ipc/mcp-context";
import { createAgentHostAPI } from "./electro-bridge/ipc/agent-host-api";
import { parseStartupProvider } from "./shared/startup-provider";

// SOURCE(Sma1lboy): https://www.electronjs.org/docs/latest/tutorial/process-model
// expose electronAPI to renderer process
const electronAPI = createElectronAPI(ipcRenderer);
const startupProvider = parseStartupProvider(process.argv);

// Add navigate-to-settings listener
const extendedAPI = {
...electronAPI,
getStartupProvider: () => startupProvider,
onNavigateToSettings: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on("navigate-to-settings", handler);
Expand Down
201 changes: 151 additions & 50 deletions packages/app/src/renderer/components/settings/pages/general-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describeProviderStatus } from "@/renderer/libs/provider-status";
import {
DEFAULT_LOCAL_AI_MODEL_ID,
isLocalAIProviderId,
type LocalAIProviderId,
} from "@/renderer/libs/local-ai";
import { useModelConfigStore } from "@/renderer/libs/stores/model-config-store";
import { useSettingsStore } from "@/renderer/libs/stores/settings-store";
Expand Down Expand Up @@ -41,6 +42,29 @@ export const MEMORY_PROVIDER_OPTIONS = LOCAL_AI_MEMORY_PROVIDERS.map(
}),
);

export const CLI_RUNTIME_OPTIONS = [
{ value: "codex-cli", label: "Codex" },
{ value: "claude-code", label: "Claude Code" },
] as const satisfies ReadonlyArray<{
value: LocalAIProviderId;
label: string;
}>;

export function isCliRuntimeProvider(
value: string,
): value is (typeof CLI_RUNTIME_OPTIONS)[number]["value"] {
return CLI_RUNTIME_OPTIONS.some((option) => option.value === value);
}

export function selectDefaultCliRuntime(
value: string,
setDefaultModel: (providerId: string, modelId: string) => void,
): boolean {
if (!isCliRuntimeProvider(value)) return false;
setDefaultModel(value, DEFAULT_LOCAL_AI_MODEL_ID);
return true;
}

export function createMemoryProviderUpdate(
value: string,
): LocalAIMemorySettingsUpdate | null {
Expand Down Expand Up @@ -450,65 +474,142 @@ export function GeneralSettingsPage() {
</button>
</div>

<div className="border border-border rounded-lg divide-y divide-border">
{providers.map((provider) => {
const isSelected = provider.id === defaultConfigId;
const status = describeProviderStatus(provider, providersLoading);
const canSelect =
status.ready && isLocalAIProviderId(provider.id);

return (
<button
key={provider.id}
type="button"
data-testid={`local-ai-provider-${provider.id}`}
aria-pressed={isSelected}
disabled={!canSelect}
onClick={() => {
if (isLocalAIProviderId(provider.id)) {
setDefaultModel(provider.id, DEFAULT_LOCAL_AI_MODEL_ID);
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Command-line runtime
</p>
<div
role="group"
aria-label="Default command-line runtime"
className="grid grid-cols-1 gap-2 sm:grid-cols-2"
>
{CLI_RUNTIME_OPTIONS.map((option) => {
const provider = providers.find(
(candidate) => candidate.id === option.value,
);
const status = provider
? describeProviderStatus(provider, providersLoading)
: {
label: providersLoading ? "Checking" : "Unavailable",
ready: false,
};
const isSelected = option.value === defaultConfigId;

return (
<button
key={option.value}
type="button"
data-testid={`cli-runtime-${option.value}`}
aria-pressed={isSelected}
disabled={!status.ready}
onClick={() =>
selectDefaultCliRuntime(option.value, setDefaultModel)
}
}}
className="flex w-full items-center justify-between p-4 text-left transition-opacity disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center">
<Terminal className="h-4 w-4 text-foreground" />
</div>
<div>
<h4 className="font-medium text-foreground">
{provider.name}
</h4>
{/* The hint is the whole point of this row: an agent
pinned to a provider fails for exactly this reason,
and this is where you find out which. */}
<p className="text-xs text-muted-foreground">
{status.hint ?? status.label}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<span
className={`text-xs px-2 py-1 rounded ${
status.ready
? "text-primary bg-primary/10"
: "text-muted-foreground bg-muted"
}`}
>
{status.label}
className={`flex min-h-20 items-center justify-between rounded-lg border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
isSelected
? "border-primary text-foreground"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
}`}
>
<span className="flex items-center gap-3">
<Terminal className="h-4 w-4" />
<span>
<span className="block font-medium text-foreground">
{option.label}
</span>
<span className="mt-0.5 block text-xs">
{status.hint ?? status.label}
</span>
</span>
</span>
<span
className={`flex min-w-16 items-center justify-end gap-1 text-xs ${
isSelected ? "text-primary" : "text-muted-foreground"
}`}
>
{isSelected && <Check className="h-3.5 w-3.5" />}
{isSelected ? "Selected" : "Select"}
{isSelected ? "Selected" : status.label}
</span>
</div>
</button>
);
})}
</button>
);
})}
</div>
</div>

<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
API providers
</p>
<div className="border border-border rounded-lg divide-y divide-border">
{providers
.filter((provider) => !isCliRuntimeProvider(provider.id))
.map((provider) => {
const isSelected = provider.id === defaultConfigId;
const status = describeProviderStatus(
provider,
providersLoading,
);
const canSelect =
status.ready && isLocalAIProviderId(provider.id);

return (
<button
key={provider.id}
type="button"
data-testid={`local-ai-provider-${provider.id}`}
aria-pressed={isSelected}
disabled={!canSelect}
onClick={() => {
if (isLocalAIProviderId(provider.id)) {
setDefaultModel(
provider.id,
DEFAULT_LOCAL_AI_MODEL_ID,
);
}
}}
className="flex w-full items-center justify-between p-4 text-left transition-opacity disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center">
<Terminal className="h-4 w-4 text-foreground" />
</div>
<div>
<h4 className="font-medium text-foreground">
{provider.name}
</h4>
{/* The hint is the whole point of this row: an agent
pinned to a provider fails for exactly this reason,
and this is where you find out which. */}
<p className="text-xs text-muted-foreground">
{status.hint ?? status.label}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<span
className={`text-xs px-2 py-1 rounded ${
status.ready
? "text-primary bg-primary/10"
: "text-muted-foreground bg-muted"
}`}
>
{status.label}
</span>
<span
className={`flex min-w-16 items-center justify-end gap-1 text-xs ${
isSelected
? "text-primary"
: "text-muted-foreground"
}`}
>
{isSelected && <Check className="h-3.5 w-3.5" />}
{isSelected ? "Selected" : "Select"}
</span>
</div>
</button>
);
})}
</div>
</div>
</div>

Expand Down
24 changes: 21 additions & 3 deletions packages/app/src/renderer/libs/db/ui-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ const CURRENT_CONVERSATION_KEY = "current-conversation-id";
const storage: Pick<Storage, "getItem" | "setItem" | "removeItem"> | undefined =
typeof localStorage === "undefined" ? undefined : localStorage;

const startupProviderId =
typeof window === "undefined"
? null
: (window.electronAPI?.getStartupProvider?.() ?? null);
const initialProviderId = startupProviderId ?? DEFAULT_LOCAL_AI_PROVIDER_ID;

interface SelectionState {
// Currently selected items
currentConversationId: string | null;
Expand All @@ -61,9 +67,9 @@ export const useSelectionStore = create<SelectionState>((set, get) => ({
currentConversationId: storage?.getItem(CURRENT_CONVERSATION_KEY) ?? null,
conversationSelectionVersion: 0,
selectedAgentId: null,
selectedConfigId: DEFAULT_LOCAL_AI_PROVIDER_ID,
selectedConfigId: initialProviderId,
selectedModelId: DEFAULT_LOCAL_AI_MODEL_ID,
defaultConfigId: DEFAULT_LOCAL_AI_PROVIDER_ID,
defaultConfigId: initialProviderId,
defaultModelId: DEFAULT_LOCAL_AI_MODEL_ID,

setCurrentConversation: (id) => {
Expand Down Expand Up @@ -143,7 +149,19 @@ export const useSelectionStore = create<SelectionState>((set, get) => ({
void Promise.all([
db.settings.get("local-ai-default-selection"),
db.settings.get("local-ai-selection"),
]).then(([currentRecord, legacyRecord]) => {
]).then(async ([currentRecord, legacyRecord]) => {
if (startupProviderId) {
await db.settings.put({
key: "local-ai-default-selection",
value: {
configId: startupProviderId,
modelId: DEFAULT_LOCAL_AI_MODEL_ID,
},
updatedAt: new Date(),
});
return;
}

const record = currentRecord ?? legacyRecord;
const value = record?.value;
if (
Expand Down
38 changes: 38 additions & 0 deletions packages/app/src/shared/startup-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import {
createRendererStartupProviderArgument,
parseStartupProvider,
} from "./startup-provider";

describe("startup provider arguments", () => {
it.each([
["cc", "claude-code"],
["--cc", "claude-code"],
["codex", "codex-cli"],
["--codex", "codex-cli"],
] as const)("maps %s to %s", (argument, expected) => {
expect(parseStartupProvider(["electron", ".", argument])).toBe(expected);
});

it("ignores unrelated and lookalike arguments", () => {
expect(parseStartupProvider(["electron", ".", "CC", "codex.json"])).toBe(
null,
);
});

it("uses the last recognized provider argument", () => {
expect(parseStartupProvider(["cc", "codex"])).toBe("codex-cli");
expect(parseStartupProvider(["codex", "cc"])).toBe("claude-code");
});

it("round-trips the internal renderer argument", () => {
const argument = createRendererStartupProviderArgument("claude-code");
expect(parseStartupProvider([argument])).toBe("claude-code");
});

it("rejects an unsupported internal provider", () => {
expect(
parseStartupProvider(["--convera-startup-provider=openai-api"]),
).toBe(null);
});
});
Loading
Loading