Skip to content
Merged
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
32 changes: 32 additions & 0 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ import {
import type { AgentChatPermissionMode, TerminalSessionSummary } from "../../desktop/src/shared/types";
import type { AdeRuntime } from "./bootstrap";
import { JsonRpcError, JsonRpcErrorCode, type JsonRpcHandler, type JsonRpcRequest } from "./jsonrpc";
import { getSharedModelPickerStore } from "./services/modelPickerStore";

// Cross-surface (desktop + TUI + iOS) model picker favorites & recents.
// Backed by a process-wide singleton (see services/modelPickerStore.ts) so the
// JSON-RPC server and the sync host share the same in-memory state.
// Persistence path is ~/.ade/modelPicker.json — see modelPickerStore.ts for schema.

type ToolSpec = {
name: string;
Expand Down Expand Up @@ -7763,6 +7769,32 @@ export function createAdeRpcRequestHandler(args: {
}
}

if (method.startsWith("modelPicker.")) {
const store = getSharedModelPickerStore();
if (method === "modelPicker.getFavorites") {
return { favorites: store.getFavorites() };
}
if (method === "modelPicker.setFavorites") {
const rawFavorites = (params as { favorites?: unknown }).favorites;
const favoritesInput = Array.isArray(rawFavorites)
? rawFavorites.filter((entry): entry is string => typeof entry === "string")
: [];
return { favorites: store.setFavorites(favoritesInput) };
}
if (method === "modelPicker.toggleFavorite") {
const modelId = typeof params.modelId === "string" ? params.modelId : "";
return store.toggleFavorite(modelId);
}
if (method === "modelPicker.getRecents") {
return { recents: store.getRecents() };
}
if (method === "modelPicker.pushRecent") {
const modelId = typeof params.modelId === "string" ? params.modelId : "";
return { recents: store.pushRecent(modelId) };
}
throw new JsonRpcError(JsonRpcErrorCode.methodNotFound, `Unknown modelPicker method: ${method}`);
}

if (method === "ade/actions/list") {
return await listActions();
}
Expand Down
2 changes: 2 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { initApiKeyStore } from "../../desktop/src/main/services/ai/apiKeyStore"
import { createMissionBudgetService } from "../../desktop/src/main/services/orchestrator/missionBudgetService";
import type { createSyncService } from "./services/sync/syncService";
import type { createSyncHostService, SyncRuntimeKind } from "./services/sync/syncHostService";
import { getSharedModelPickerStore } from "./services/modelPickerStore";
import type { createAutomationIngressService } from "../../desktop/src/main/services/automations/automationIngressService";
import type { createGithubService } from "../../desktop/src/main/services/github/githubService";
import { createFeedbackReporterService } from "../../desktop/src/main/services/feedback/feedbackReporterService";
Expand Down Expand Up @@ -1088,6 +1089,7 @@ export async function createAdeRuntime(args: {
forceHostRole: resolvedArgs.syncRuntime.forceHostRole ?? true,
projectCatalogProvider: resolvedArgs.syncRuntime.projectCatalogProvider,
remoteCommandExecutor: resolvedArgs.syncRuntime.remoteCommandExecutor,
getModelPickerStore: () => getSharedModelPickerStore(),
onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }),
});
}
Expand Down
85 changes: 85 additions & 0 deletions apps/ade-cli/src/services/modelPickerStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createModelPickerStore, MODEL_PICKER_MAX_RECENTS } from "./modelPickerStore";

function tempFile(name: string): string {
return path.join(os.tmpdir(), `ade-model-picker-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${name}`);
}

describe("modelPickerStore", () => {
it("starts empty when the persistence file is missing", () => {
const filePath = tempFile("missing.json");
const store = createModelPickerStore({ filePath });
expect(store.getFavorites()).toEqual([]);
expect(store.getRecents()).toEqual([]);
});

it("toggleFavorite adds and removes entries and reports state", () => {
const filePath = tempFile("toggle.json");
const store = createModelPickerStore({ filePath });
const first = store.toggleFavorite("claude-opus-4-7");
expect(first).toEqual({ favorites: ["claude-opus-4-7"], isFavorite: true });
const second = store.toggleFavorite("gpt-5");
expect(second.favorites).toEqual(["claude-opus-4-7", "gpt-5"]);
expect(second.isFavorite).toBe(true);
const third = store.toggleFavorite("claude-opus-4-7");
expect(third).toEqual({ favorites: ["gpt-5"], isFavorite: false });
});

it("setFavorites dedupes, trims, and persists", () => {
const filePath = tempFile("set-favorites.json");
const store = createModelPickerStore({ filePath });
const result = store.setFavorites(["a", "a", " b ", "", "c"]);
expect(result).toEqual(["a", "b", "c"]);
const reloaded = createModelPickerStore({ filePath });
expect(reloaded.getFavorites()).toEqual(["a", "b", "c"]);
});

it("pushRecent prepends, dedupes, and caps at MAX_RECENTS", () => {
const filePath = tempFile("recents.json");
const store = createModelPickerStore({ filePath });
for (let i = 0; i < MODEL_PICKER_MAX_RECENTS + 5; i += 1) {
store.pushRecent(`model-${i}`);
}
const recents = store.getRecents();
expect(recents).toHaveLength(MODEL_PICKER_MAX_RECENTS);
expect(recents[0]).toBe(`model-${MODEL_PICKER_MAX_RECENTS + 4}`);
// Re-pushing an existing entry should move it to head without growing the list.
const before = store.getRecents();
const head = before[before.length - 1];
expect(head).toBeDefined();
if (!head) throw new Error("unreachable");
const moved = store.pushRecent(head);
expect(moved[0]).toBe(head);
expect(moved).toHaveLength(MODEL_PICKER_MAX_RECENTS);
expect(new Set(moved).size).toBe(MODEL_PICKER_MAX_RECENTS);
});

it("ignores empty/whitespace modelId in toggleFavorite and pushRecent", () => {
const filePath = tempFile("empty.json");
const store = createModelPickerStore({ filePath });
expect(store.toggleFavorite("")).toEqual({ favorites: [], isFavorite: false });
expect(store.toggleFavorite(" ")).toEqual({ favorites: [], isFavorite: false });
expect(store.pushRecent("")).toEqual([]);
});

it("tolerates a malformed persistence file", () => {
const filePath = tempFile("malformed.json");
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "this is not json", "utf8");
const store = createModelPickerStore({ filePath });
expect(store.getFavorites()).toEqual([]);
expect(store.getRecents()).toEqual([]);
});

it("flushes pending recents so reload sees them", () => {
const filePath = tempFile("flush.json");
const store = createModelPickerStore({ filePath });
store.pushRecent("alpha");
store.flush();
const reloaded = createModelPickerStore({ filePath });
expect(reloaded.getRecents()).toEqual(["alpha"]);
});
});
162 changes: 162 additions & 0 deletions apps/ade-cli/src/services/modelPickerStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const MAX_RECENTS = 10;
const STORE_VERSION = 1;
const PERSIST_DEBOUNCE_MS = 250;

type PersistedShape = {
version: number;
favorites: string[];
recents: string[];
};

export type ModelPickerStore = {
getFavorites: () => string[];
setFavorites: (favorites: string[]) => string[];
toggleFavorite: (modelId: string) => { favorites: string[]; isFavorite: boolean };
getRecents: () => string[];
pushRecent: (modelId: string) => string[];
/** Flush any pending debounced write. Exposed for tests/teardown. */
flush: () => void;
};

export type CreateModelPickerStoreOptions = {
filePath?: string;
};

function defaultFilePath(): string {
return path.join(os.homedir(), ".ade", "modelPicker.json");
}

function sanitizeIdList(values: unknown): string[] {
if (!Array.isArray(values)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const entry of values) {
if (typeof entry !== "string") continue;
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
out.push(trimmed);
}
return out;
}

function readPersisted(filePath: string): PersistedShape {
try {
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") {
return { version: STORE_VERSION, favorites: [], recents: [] };
}
const record = parsed as Record<string, unknown>;
return {
version: typeof record.version === "number" ? record.version : STORE_VERSION,
favorites: sanitizeIdList(record.favorites),
recents: sanitizeIdList(record.recents).slice(0, MAX_RECENTS),
};
} catch {
return { version: STORE_VERSION, favorites: [], recents: [] };
}
}

function writePersisted(filePath: string, state: PersistedShape): void {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const body = JSON.stringify(state, null, 2);
fs.writeFileSync(filePath, body, "utf8");
} catch {
// best-effort — favorites/recents are convenience state, not load-bearing.
}
}

export function createModelPickerStore(options: CreateModelPickerStoreOptions = {}): ModelPickerStore {
const filePath = options.filePath ?? defaultFilePath();
const state = readPersisted(filePath);

let persistTimer: ReturnType<typeof setTimeout> | null = null;
const persistSoon = () => {
if (persistTimer != null) return;
persistTimer = setTimeout(() => {
persistTimer = null;
writePersisted(filePath, { ...state, version: STORE_VERSION });
}, PERSIST_DEBOUNCE_MS);
};
const flush = () => {
if (persistTimer != null) {
clearTimeout(persistTimer);
persistTimer = null;
}
writePersisted(filePath, { ...state, version: STORE_VERSION });
};

// Flush pending debounced pushRecent on process exit so the latest model
// selection isn't lost when the user closes the TUI within the debounce window.
// exit handler runs synchronously and flush uses writeFileSync.
if (!options.filePath) {
process.once("exit", () => {
if (persistTimer != null) flush();
});
}

return {
getFavorites: () => state.favorites.slice(),
setFavorites: (favorites) => {
state.favorites = sanitizeIdList(favorites);
flush();
return state.favorites.slice();
},
toggleFavorite: (modelId) => {
const id = typeof modelId === "string" ? modelId.trim() : "";
if (!id) return { favorites: state.favorites.slice(), isFavorite: false };
const idx = state.favorites.indexOf(id);
let isFavorite: boolean;
if (idx >= 0) {
state.favorites = [...state.favorites.slice(0, idx), ...state.favorites.slice(idx + 1)];
isFavorite = false;
} else {
state.favorites = [...state.favorites, id];
isFavorite = true;
}
flush();
return { favorites: state.favorites.slice(), isFavorite };
},
getRecents: () => state.recents.slice(),
pushRecent: (modelId) => {
const id = typeof modelId === "string" ? modelId.trim() : "";
if (!id) return state.recents.slice();
const filtered = state.recents.filter((entry) => entry !== id);
state.recents = [id, ...filtered].slice(0, MAX_RECENTS);
persistSoon();
return state.recents.slice();
},
flush,
};
}

export const MODEL_PICKER_MAX_RECENTS = MAX_RECENTS;

// Process-wide singleton shared across the JSON-RPC server (adeRpcServer) and
// the sync host (syncRemoteCommandService) so favorites + recents stay
// consistent for desktop/TUI/iOS without coordination races. Lazy-init so
// tests can avoid touching the user's real ~/.ade/modelPicker.json by
// supplying their own store instance via `createModelPickerStore`.
let sharedStoreInstance: ModelPickerStore | null = null;
export function getSharedModelPickerStore(): ModelPickerStore {
if (!sharedStoreInstance) {
sharedStoreInstance = createModelPickerStore();
}
return sharedStoreInstance;
}
export function resetSharedModelPickerStoreForTests(): void {
if (sharedStoreInstance) {
try {
sharedStoreInstance.flush();
} catch {
// best-effort flush during teardown
}
}
sharedStoreInstance = null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading