diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 0d05042e9..88af99d00 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -66,9 +66,11 @@ import { normalizeAdeRuntimeRole } from "./runtimeRoles"; 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. +// Backed by the per-project cr-sqlite CRR DB (runtime.db) so the three surfaces +// converge for a given project via sync. The store is a per-db singleton (see +// services/modelPickerStore.ts) shared by the JSON-RPC server and the sync +// host. A one-time best-effort import of the legacy ~/.ade/modelPicker.json +// runs on first DB-backed init — see modelPickerStore.ts for schema + migration. type ToolSpec = { name: string; @@ -5456,7 +5458,9 @@ export function createAdeRpcRequestHandler(args: { } if (method.startsWith("modelPicker.")) { - const store = getSharedModelPickerStore(); + // Backed by the per-project cr-sqlite DB (runtime.db) so favorites + + // recents converge across desktop/TUI/iOS for a project via CRR sync. + const store = getSharedModelPickerStore(runtime.db); if (method === "modelPicker.getFavorites") { return { favorites: store.getFavorites() }; } diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 28ae86a3b..b3cda0d3a 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1117,7 +1117,7 @@ export async function createAdeRuntime(args: { forceHostRole: resolvedArgs.syncRuntime.forceHostRole ?? true, projectCatalogProvider: resolvedArgs.syncRuntime.projectCatalogProvider, remoteCommandExecutor: resolvedArgs.syncRuntime.remoteCommandExecutor, - getModelPickerStore: () => getSharedModelPickerStore(), + getModelPickerStore: () => getSharedModelPickerStore(db), onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }), }); } diff --git a/apps/ade-cli/src/services/modelPickerStore.test.ts b/apps/ade-cli/src/services/modelPickerStore.test.ts index a302a4126..6e40bbcac 100644 --- a/apps/ade-cli/src/services/modelPickerStore.test.ts +++ b/apps/ade-cli/src/services/modelPickerStore.test.ts @@ -1,85 +1,180 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openKvDb, type AdeDb } from "../../../desktop/src/main/services/state/kvDb"; 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}`); +function createLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; } -describe("modelPickerStore", () => { - it("starts empty when the persistence file is missing", () => { - const filePath = tempFile("missing.json"); - const store = createModelPickerStore({ filePath }); +describe("modelPickerStore (db-backed)", () => { + const cleanupRoots: string[] = []; + const openDbs: AdeDb[] = []; + + afterEach(() => { + vi.useRealTimers(); + for (const db of openDbs.splice(0)) { + try { + db.close(); + } catch { + // best-effort teardown + } + } + for (const root of cleanupRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + async function makeDb(): Promise<{ db: AdeDb; root: string }> { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-model-picker-db-")); + cleanupRoots.push(root); + const db = await openKvDb(path.join(root, ".ade", "ade.db"), createLogger() as any); + openDbs.push(db); + return { db, root }; + } + + // Point migration at a guaranteed-missing path so the real + // ~/.ade/modelPicker.json never bleeds into these specs. + function noMigration(root: string): string { + return path.join(root, "no-such-legacy.json"); + } + + it("starts empty on a fresh db", async () => { + const { db, root } = await makeDb(); + const store = createModelPickerStore({ db, legacyFilePath: noMigration(root) }); 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 }); + it("toggleFavorite adds and removes entries and reports state", async () => { + const { db, root } = await makeDb(); + const store = createModelPickerStore({ db, legacyFilePath: noMigration(root) }); + 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("setFavorites reconciles to the desired set (adds, drops, dedupes, trims)", async () => { + const { db, root } = await makeDb(); + const store = createModelPickerStore({ db, legacyFilePath: noMigration(root) }); + + expect(store.setFavorites(["a", "a", " b ", "", "c"])).toEqual(["a", "b", "c"]); + // Existing rows are restamped so setFavorites is also a reorder API. + expect(store.setFavorites(["c", "a", "b"])).toEqual(["c", "a", "b"]); + // Drop "a", keep "b", add "d". + expect(store.setFavorites(["b", "d"])).toEqual(["b", "d"]); + // Clearing removes everything. + expect(store.setFavorites([])).toEqual([]); + }); + + it("favorites persist across store re-creation on the same db", async () => { + const { db, root } = await makeDb(); + const store = createModelPickerStore({ db, legacyFilePath: noMigration(root) }); + store.setFavorites(["a", "b", "c"]); + + const reopened = createModelPickerStore({ db, legacyFilePath: noMigration(root) }); + expect(reopened.getFavorites()).toEqual(["a", "b", "c"]); }); - it("pushRecent prepends, dedupes, and caps at MAX_RECENTS", () => { - const filePath = tempFile("recents.json"); - const store = createModelPickerStore({ filePath }); + it("pushRecent caps at MAX_RECENTS and orders by recency", async () => { + const { db, root } = await makeDb(); + const store = createModelPickerStore({ db, legacyFilePath: noMigration(root) }); + 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); + // Newest push is first; the oldest 5 were pruned off the tail. 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(recents).not.toContain("model-0"); + + // Re-pushing an existing entry moves it to the head without growing the list. + const tail = recents[recents.length - 1]; + expect(tail).toBeDefined(); + if (!tail) throw new Error("unreachable"); + const moved = store.pushRecent(tail); + expect(moved[0]).toBe(tail); 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 }); + it("preserves recency when multiple models are pushed in the same millisecond", async () => { + const { db, root } = await makeDb(); + const store = createModelPickerStore({ db, legacyFilePath: noMigration(root) }); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-31T23:30:00.000Z")); + + store.pushRecent("z-model"); + expect(store.pushRecent("a-model")).toEqual(["a-model", "z-model"]); + }); + + it("ignores empty/whitespace modelId for toggleFavorite and pushRecent", async () => { + const { db, root } = await makeDb(); + const store = createModelPickerStore({ db, legacyFilePath: noMigration(root) }); 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("imports the legacy modelPicker.json only once, even if imported rows are later cleared", async () => { + const { db, root } = await makeDb(); + const legacyFilePath = path.join(root, "modelPicker.json"); + fs.writeFileSync( + legacyFilePath, + JSON.stringify({ + version: 1, + favorites: ["fav-a", "fav-b"], + // Newest-first, as the legacy file stored them. + recents: ["recent-1", "recent-2", "recent-3"], + }), + "utf8", + ); + + const store = createModelPickerStore({ db, legacyFilePath }); + expect(store.getFavorites()).toEqual(["fav-a", "fav-b"]); + expect(store.getRecents()).toEqual(["recent-1", "recent-2", "recent-3"]); + + // Clear every imported row, then re-open: the durable migration marker must + // prevent stale JSON state from being resurrected just because tables are + // empty again. + store.setFavorites([]); + for (const recent of store.getRecents()) { + db.run("delete from model_picker_recents where model_id = ?", [recent]); + } + const reopened = createModelPickerStore({ db, legacyFilePath }); + expect(reopened.getFavorites()).toEqual([]); + expect(reopened.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"]); + it("does not import the legacy file when the db already has data", async () => { + const { db, root } = await makeDb(); + const seed = createModelPickerStore({ db, legacyFilePath: noMigration(root) }); + seed.setFavorites(["existing"]); + + const legacyFilePath = path.join(root, "modelPicker.json"); + fs.writeFileSync( + legacyFilePath, + JSON.stringify({ version: 1, favorites: ["imported"], recents: ["imported-recent"] }), + "utf8", + ); + + const store = createModelPickerStore({ db, legacyFilePath }); + expect(store.getFavorites()).toEqual(["existing"]); + expect(store.getRecents()).toEqual([]); }); }); diff --git a/apps/ade-cli/src/services/modelPickerStore.ts b/apps/ade-cli/src/services/modelPickerStore.ts index 41bc2beac..76f058d2b 100644 --- a/apps/ade-cli/src/services/modelPickerStore.ts +++ b/apps/ade-cli/src/services/modelPickerStore.ts @@ -1,16 +1,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { AdeDb } from "../../../desktop/src/main/services/state/kvDb"; const MAX_RECENTS = 10; -const STORE_VERSION = 1; -const PERSIST_DEBOUNCE_MS = 250; - -type PersistedShape = { - version: number; - favorites: string[]; - recents: string[]; -}; +const LEGACY_IMPORT_MARKER_KEY = "model-picker:legacy-import:v1"; export type ModelPickerStore = { getFavorites: () => string[]; @@ -18,15 +12,37 @@ export type ModelPickerStore = { toggleFavorite: (modelId: string) => { favorites: string[]; isFavorite: boolean }; getRecents: () => string[]; pushRecent: (modelId: string) => string[]; - /** Flush any pending debounced write. Exposed for tests/teardown. */ + /** No-op retained for API compatibility (DB writes are synchronous). */ flush: () => void; }; export type CreateModelPickerStoreOptions = { - filePath?: string; + db: AdeDb; + /** + * Path to the legacy `~/.ade/modelPicker.json` file imported once on first + * DB-backed init when the DB tables are still empty. Defaults to the real + * per-user file; tests can point this at a fixture (or a missing path to + * disable migration). + */ + legacyFilePath?: string; }; -function defaultFilePath(): string { +function nowIso(): string { + return new Date().toISOString(); +} + +let stampSequence = 0; + +function indexedStamp(base: string, index: number): string { + return `${base}#${String(index).padStart(4, "0")}`; +} + +function sequencedNowStamp(): string { + stampSequence = (stampSequence + 1) % 1_000_000; + return `${nowIso()}#${String(stampSequence).padStart(6, "0")}`; +} + +function defaultLegacyFilePath(): string { return path.join(os.homedir(), ".ade", "modelPicker.json"); } @@ -44,95 +60,193 @@ function sanitizeIdList(values: unknown): string[] { return out; } -function readPersisted(filePath: string): PersistedShape { +function normalizeId(modelId: unknown): string { + return typeof modelId === "string" ? modelId.trim() : ""; +} + +type LegacyShape = { favorites: string[]; recents: string[] }; + +function readLegacyFile(filePath: string): LegacyShape { 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: [] }; + return { favorites: [], recents: [] }; } const record = parsed as Record; 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: [] }; + return { favorites: [], recents: [] }; } } -function writePersisted(filePath: string, state: PersistedShape): void { +function readFavoritesFromDb(db: AdeDb): string[] { + // Favorites have no intrinsic order in the schema; created_at gives a stable, + // insertion-ordered read so the UI list doesn't reshuffle between calls. + const rows = db.all<{ model_id: string }>( + "select model_id from model_picker_favorites order by created_at asc, model_id asc", + ); + return sanitizeIdList(rows.map((row) => row.model_id)); +} + +function readRecentsFromDb(db: AdeDb): string[] { + const rows = db.all<{ model_id: string }>( + "select model_id from model_picker_recents order by used_at desc, model_id asc limit ?", + [MAX_RECENTS], + ); + return sanitizeIdList(rows.map((row) => row.model_id)); +} + +/** + * One-time best-effort import of the legacy `~/.ade/modelPicker.json` into the + * per-project DB. A durable kv marker records the attempted import, so clearing + * every favorite/recent later does not resurrect stale JSON state on next boot. + * Never throws into the caller — favorites/recents are convenience state, not + * load-bearing. + */ +function migrateLegacyFileIfNeeded(db: AdeDb, legacyFilePath: string): void { try { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const body = JSON.stringify(state, null, 2); - fs.writeFileSync(filePath, body, "utf8"); + if (db.getJson<{ importedAt: string }>(LEGACY_IMPORT_MARKER_KEY)) return; + const favCount = db.get<{ count: number }>( + "select count(1) as count from model_picker_favorites", + ); + const recentCount = db.get<{ count: number }>( + "select count(1) as count from model_picker_recents", + ); + if (Number(favCount?.count ?? 0) > 0 || Number(recentCount?.count ?? 0) > 0) { + db.setJson(LEGACY_IMPORT_MARKER_KEY, { importedAt: nowIso(), skipped: "existing-db-state" }); + return; + } + if (!fs.existsSync(legacyFilePath)) { + db.setJson(LEGACY_IMPORT_MARKER_KEY, { importedAt: nowIso(), skipped: "missing-file" }); + return; + } + + const legacy = readLegacyFile(legacyFilePath); + if (legacy.favorites.length === 0 && legacy.recents.length === 0) { + db.setJson(LEGACY_IMPORT_MARKER_KEY, { importedAt: nowIso(), skipped: "empty-or-invalid-file" }); + return; + } + + const base = nowIso(); + db.run("begin"); + try { + legacy.favorites.forEach((id, index) => { + // Stagger created_at by index so the imported order is preserved by the + // `order by created_at` read above without colliding on identical stamps. + const createdAt = indexedStamp(base, index); + db.run( + "insert into model_picker_favorites (model_id, created_at) values (?, ?) on conflict(model_id) do nothing", + [id, createdAt], + ); + }); + // Legacy recents are newest-first; map index to descending used_at so the + // DB read (order by used_at desc) reproduces the original ordering. + legacy.recents.forEach((id, index) => { + const usedAt = indexedStamp(base, legacy.recents.length - index); + db.run( + "insert into model_picker_recents (model_id, used_at) values (?, ?) on conflict(model_id) do update set used_at = excluded.used_at", + [id, usedAt], + ); + }); + db.setJson(LEGACY_IMPORT_MARKER_KEY, { importedAt: nowIso(), favorites: legacy.favorites.length, recents: legacy.recents.length }); + db.run("commit"); + } catch (error) { + db.run("rollback"); + throw error; + } } catch { - // best-effort — favorites/recents are convenience state, not load-bearing. + // best-effort migration — leave the file alone and continue. } } -export function createModelPickerStore(options: CreateModelPickerStoreOptions = {}): ModelPickerStore { - const filePath = options.filePath ?? defaultFilePath(); - const state = readPersisted(filePath); - - let persistTimer: ReturnType | 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 }); - }; +function pruneRecents(db: AdeDb): void { + // Keep only the newest MAX_RECENTS rows; delete the older tail. PK-only table + // (no UNIQUE index beyond model_id) keeps it CRR-eligible, so the cap is + // enforced here in app code rather than via a constraint. + db.run( + `delete from model_picker_recents + where model_id not in ( + select model_id from model_picker_recents + order by used_at desc, model_id asc + limit ? + )`, + [MAX_RECENTS], + ); +} - // 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(); - }); - } +export function createModelPickerStore(options: CreateModelPickerStoreOptions): ModelPickerStore { + const { db } = options; + const legacyFilePath = options.legacyFilePath ?? defaultLegacyFilePath(); + migrateLegacyFileIfNeeded(db, legacyFilePath); return { - getFavorites: () => state.favorites.slice(), + getFavorites: () => readFavoritesFromDb(db), setFavorites: (favorites) => { - state.favorites = sanitizeIdList(favorites); - flush(); - return state.favorites.slice(); + const desired = sanitizeIdList(favorites); + const desiredSet = new Set(desired); + const existing = new Set(readFavoritesFromDb(db)); + // Reconcile to the desired set: delete dropped and restamp every desired + // row so the caller's order is preserved even for existing favorites. + db.run("begin"); + try { + for (const id of existing) { + if (!desiredSet.has(id)) { + db.run("delete from model_picker_favorites where model_id = ?", [id]); + } + } + const base = nowIso(); + desired.forEach((id, index) => { + const createdAt = indexedStamp(base, index); + db.run( + "insert into model_picker_favorites (model_id, created_at) values (?, ?) on conflict(model_id) do update set created_at = excluded.created_at", + [id, createdAt], + ); + }); + db.run("commit"); + } catch (error) { + db.run("rollback"); + throw error; + } + return readFavoritesFromDb(db); }, toggleFavorite: (modelId) => { - const id = typeof modelId === "string" ? modelId.trim() : ""; - if (!id) return { favorites: state.favorites.slice(), isFavorite: false }; - const idx = state.favorites.indexOf(id); + const id = normalizeId(modelId); + if (!id) return { favorites: readFavoritesFromDb(db), isFavorite: false }; + const present = db.get<{ present: number }>( + "select 1 as present from model_picker_favorites where model_id = ? limit 1", + [id], + ); let isFavorite: boolean; - if (idx >= 0) { - state.favorites = [...state.favorites.slice(0, idx), ...state.favorites.slice(idx + 1)]; + if (present) { + db.run("delete from model_picker_favorites where model_id = ?", [id]); isFavorite = false; } else { - state.favorites = [...state.favorites, id]; + db.run( + "insert into model_picker_favorites (model_id, created_at) values (?, ?) on conflict(model_id) do nothing", + [id, sequencedNowStamp()], + ); isFavorite = true; } - flush(); - return { favorites: state.favorites.slice(), isFavorite }; + return { favorites: readFavoritesFromDb(db), isFavorite }; }, - getRecents: () => state.recents.slice(), + getRecents: () => readRecentsFromDb(db), 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(); + const id = normalizeId(modelId); + if (!id) return readRecentsFromDb(db); + db.run( + "insert into model_picker_recents (model_id, used_at) values (?, ?) on conflict(model_id) do update set used_at = excluded.used_at", + [id, sequencedNowStamp()], + ); + pruneRecents(db); + return readRecentsFromDb(db); + }, + flush: () => { + // DB writes are synchronous and already durable — nothing to flush. }, - flush, }; } @@ -140,23 +254,20 @@ 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`. +// consistent within a process. The per-project cr-sqlite DB is the source of +// truth (it converges desktop/TUI/iOS via CRR replication); the singleton just +// avoids re-running the legacy-file migration on every call. Keyed by the db +// handle so a runtime project switch (new db) rebuilds the store. let sharedStoreInstance: ModelPickerStore | null = null; -export function getSharedModelPickerStore(): ModelPickerStore { - if (!sharedStoreInstance) { - sharedStoreInstance = createModelPickerStore(); +let sharedStoreDb: AdeDb | null = null; +export function getSharedModelPickerStore(db: AdeDb): ModelPickerStore { + if (!sharedStoreInstance || sharedStoreDb !== db) { + sharedStoreInstance = createModelPickerStore({ db }); + sharedStoreDb = db; } return sharedStoreInstance; } export function resetSharedModelPickerStoreForTests(): void { - if (sharedStoreInstance) { - try { - sharedStoreInstance.flush(); - } catch { - // best-effort flush during teardown - } - } sharedStoreInstance = null; + sharedStoreDb = null; } diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index ae81e0669..46d8caf8b 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -743,6 +743,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { pinStore: args.pinStore, }); const remoteCommandService = args.remoteCommandService ?? createSyncRemoteCommandService({ + db: args.db, laneService: args.laneService, prService: args.prService, ptyService: args.ptyService, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 1aded64ac..fb4b36048 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -166,8 +166,17 @@ import type { createQueueLandingService } from "../../../../desktop/src/main/ser import type { createPtyService } from "../../../../desktop/src/main/services/pty/ptyService"; import type { createSessionService } from "../../../../desktop/src/main/services/sessions/sessionService"; import { getSharedModelPickerStore, type ModelPickerStore } from "../modelPickerStore"; +import type { AdeDb } from "../../../../desktop/src/main/services/state/kvDb"; type SyncRemoteCommandServiceArgs = { + /** + * Per-project cr-sqlite DB. Source of truth for the model-picker store + * (favorites + recents) when no explicit `getModelPickerStore` accessor is + * wired, so the sync host never falls back to an empty store in production. + * Optional only so unit tests that never touch `modelPicker.*` can omit it; + * production callers (bootstrap, syncHostService) always pass it. + */ + db?: AdeDb; laneService: ReturnType; prService: ReturnType; issueInventoryService?: ReturnType | null; @@ -208,11 +217,12 @@ type SyncRemoteCommandServiceArgs = { rebaseSuggestionService?: ReturnType | null; autoRebaseService?: ReturnType | null; /** - * Lazy accessor for the process-wide model picker store (favorites + recents - * persisted to `~/.ade/modelPicker.json`). iOS hits these via the - * `modelPicker.*` sync commands so favorites/recents stay in sync with - * desktop + TUI. Optional so older callers without the accessor wired keep - * compiling — handlers reject with a clear error when missing. + * Lazy accessor for the model picker store (favorites + recents, backed by + * the per-project cr-sqlite DB). iOS hits these via the `modelPicker.*` sync + * commands so favorites/recents stay in sync with desktop + TUI. Optional — + * when unset, handlers fall back to the per-db shared store built from + * `args.db`, so the sync host always reads/writes the real DB rather than an + * empty stub. */ getModelPickerStore?: () => ModelPickerStore | null; /** @@ -2305,39 +2315,44 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio function registerModelPickerRemoteCommands({ args, register }: RemoteCommandRegistrationDeps): void { // Cross-surface ModelPicker favorites + recents — see modelPickerStore.ts. // Mirrors the direct JSON-RPC `modelPicker.*` methods on adeRpcServer so iOS - // (which routes through the WebSocket sync command envelope) shares the - // same persisted store at ~/.ade/modelPicker.json. - // Falls back to the process-wide singleton when no accessor is wired — - // older bootstraps (tests, embedded uses) still get a working store without - // having to thread the accessor explicitly. - const requireModelPickerStore = (): ModelPickerStore => - args.getModelPickerStore?.() ?? getSharedModelPickerStore(); + // (which routes through the WebSocket sync command envelope) shares the same + // per-project cr-sqlite-backed store. Falls back to the per-db shared store + // built from `args.db` when no explicit accessor is wired — so the sync host + // always reads/writes the real DB rather than an empty stub. + const requireModelPickerStore = (): ModelPickerStore => { + const injected = args.getModelPickerStore?.(); + if (injected) return injected; + if (!args.db) { + throw new Error("Model picker store is not available: no DB wired for this runtime."); + } + return getSharedModelPickerStore(args.db); + }; register("modelPicker.getFavorites", { viewerAllowed: true }, async () => ({ favorites: requireModelPickerStore().getFavorites(), - }), "runtime"); + })); register("modelPicker.setFavorites", { viewerAllowed: true }, async (payload) => { const rawFavorites = (payload as { favorites?: unknown }).favorites; const favoritesInput = Array.isArray(rawFavorites) ? rawFavorites.filter((entry): entry is string => typeof entry === "string") : []; return { favorites: requireModelPickerStore().setFavorites(favoritesInput) }; - }, "runtime"); + }); register("modelPicker.toggleFavorite", { viewerAllowed: true }, async (payload) => { const modelId = typeof (payload as { modelId?: unknown }).modelId === "string" ? ((payload as { modelId?: string }).modelId as string) : ""; return requireModelPickerStore().toggleFavorite(modelId); - }, "runtime"); + }); register("modelPicker.getRecents", { viewerAllowed: true }, async () => ({ recents: requireModelPickerStore().getRecents(), - }), "runtime"); + })); register("modelPicker.pushRecent", { viewerAllowed: true }, async (payload) => { const modelId = typeof (payload as { modelId?: unknown }).modelId === "string" ? ((payload as { modelId?: string }).modelId as string) : ""; return { recents: requireModelPickerStore().pushRecent(modelId) }; - }, "runtime"); + }); } function registerCtoRemoteCommands({ args, register }: RemoteCommandRegistrationDeps): void { diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 161078722..b433dd1e0 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -141,10 +141,11 @@ type SyncServiceArgs = { }; remoteCommandExecutor?: Pick; /** - * Lazy accessor for the process-wide model picker store. iOS uses the - * `modelPicker.*` sync commands to share favorites + recents with desktop - * and the TUI; the store is a process singleton so all surfaces see the - * same in-memory state and persist to `~/.ade/modelPicker.json`. + * Lazy accessor for the model picker store. iOS uses the `modelPicker.*` + * sync commands to share favorites + recents with desktop and the TUI; the + * store is backed by the per-project cr-sqlite DB (`db`) so all surfaces + * converge for a project via CRR replication. Optional — the remote command + * service falls back to the per-db shared store built from `db` when unset. */ getModelPickerStore?: () => ModelPickerStore | null; /** @@ -541,6 +542,7 @@ export function createSyncService(args: SyncServiceArgs) { }); const remoteCommandService = createSyncRemoteCommandService({ + db: args.db, laneService: args.laneService, prService: args.prService, issueInventoryService: args.issueInventoryService, diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index c20c88527..c251e06ce 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -535,7 +535,6 @@ describe("RightPane setup panes", () => { surface: "chat", query: "", searchMode: false, - showAll: false, selection: { kind: "provider", provider: "claude" }, providerTabKey: null, focusedIndex: 0, diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index 8aae7ebb2..3a007d8bc 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -46,6 +46,10 @@ import { promptDisplayRows, promptDisplayRowsWithCursor, promptHitLine, + modelPickerPaneContentOrigin, + modelPickerProviderSwitchBlocked, + mergeNewChatModelPickerContext, + normalizeCatalogProvider, resolveContextDefault, resolveDrawerPaneWidth, resolveModelPickerEscape, @@ -371,6 +375,88 @@ describe("right pane context defaults", () => { laneId: "lane-1", laneLabel: "Lane one", selection: { kind: "provider", provider: "claude" }, + // Two-stage nav: the picker opens in the model list (Stage 1), NOT focused + // on the Confirm button — Enter is the gate into the settings. + footerFocus: null, + focusedIndex: 0, + }); + }); +}); + +describe("modelPickerPaneContentOrigin", () => { + it("offsets past RightPane's border + MODEL title + paddingX so hit-rects match the paint", () => { + // Outer pane top-left is (paneLeft, paneTop); ModelPickerPane's first painted + // cell is 2 rows down (border + title) and 2 cols right (border + paddingX), + // with 4 fewer usable columns. + expect(modelPickerPaneContentOrigin({ paneTop: 5, paneLeft: 100, paneWidth: 38 })) + .toEqual({ paneTop: 7, paneLeft: 102, paneWidth: 34 }); + }); + + it("clamps the content width to a floor for very narrow panes", () => { + expect(modelPickerPaneContentOrigin({ paneTop: 0, paneLeft: 0, paneWidth: 6 }).paneWidth).toBe(8); + }); +}); + +describe("model picker provider normalization and locking", () => { + it("normalizes catalog provider aliases before committing a model", () => { + expect(normalizeCatalogProvider("anthropic")).toBe("claude"); + expect(normalizeCatalogProvider("openai")).toBe("codex"); + expect(normalizeCatalogProvider("factory")).toBe("droid"); + }); + + it("blocks provider switches only for locked existing chats", () => { + expect(modelPickerProviderSwitchBlocked({ + providerLocked: true, + surface: "chat", + currentProvider: "codex", + nextProvider: "claude", + })).toBe(true); + expect(modelPickerProviderSwitchBlocked({ + providerLocked: true, + surface: "chat", + currentProvider: "codex", + nextProvider: "codex", + })).toBe(false); + expect(modelPickerProviderSwitchBlocked({ + providerLocked: true, + surface: "new-chat", + currentProvider: "codex", + nextProvider: "claude", + })).toBe(false); + }); + + it("preserves in-progress new-chat picker focus when context refreshes lane/settings rows", () => { + const prev = { + kind: "model-picker" as const, + surface: "new-chat" as const, + query: "sonnet", + searchMode: true, + selection: { kind: "provider" as const, provider: "claude" as const }, + focusedIndex: 4, + railFocused: false, + footerFocus: "reasoning" as const, + settingsRows: [{ kind: "reasoning" as const, label: "Reasoning", value: "high" }], + laneId: "lane-old", + laneLabel: "Old lane", + }; + const next = { + ...prev, + query: "", + searchMode: false, + selection: { kind: "provider" as const, provider: "codex" as const }, + focusedIndex: 0, + railFocused: true, + footerFocus: null, + settingsRows: [{ kind: "permission" as const, label: "Permissions", value: "auto" }], + laneId: "lane-new", + laneLabel: "New lane", + }; + + expect(mergeNewChatModelPickerContext(prev, next)).toEqual({ + ...prev, + laneId: "lane-new", + laneLabel: "New lane", + settingsRows: [{ kind: "permission", label: "Permissions", value: "auto" }], }); }); }); @@ -686,7 +772,6 @@ describe("model picker escape handling", () => { surface: "chat" as const, query: "", searchMode: false, - showAll: false, selection: { kind: "favorites" as const }, focusedIndex: 3, }; diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 3ef845b44..0e10af007 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -335,7 +335,15 @@ export async function getAvailableModels( ): Promise { return await connection.action("chat", "getAvailableModels", { provider, - activateRuntime: provider === "cursor", + // cursor + droid only report their live "fast" service tiers when their + // runtime is probed — loadAvailableModels gates serviceTiers on + // activateRuntime for these two (droid via discoverDroidSdkModelDescriptors' + // "probe" mode). Without probing droid, the TUI's model list carried no + // droid service tiers, so the fast toggle stayed disabled on droid models + // the desktop chat picker shows it on. codex is intentionally NOT here: its + // tiers come from the app-server, which loadAvailableModels always queries + // regardless of activateRuntime. + activateRuntime: provider === "cursor" || provider === "droid", }); } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 232c9ca90..177c6dc7f 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -7,6 +7,7 @@ import { Box, Text, useApp, useInput } from "ink"; import { getDefaultModelDescriptor, getModelById, + getRuntimeModelRefForDescriptor, listModelDescriptorsForProvider, modelSupportsFastMode, resolveModelDescriptor, @@ -221,7 +222,6 @@ const MODEL_CATALOG_LOCAL_CLIENT_REFRESH_TTL_MS = 30_000; const CLAUDE_PERMISSION_OPTIONS = ["default", "auto", "plan", "acceptEdits", "bypassPermissions"] as const; const OPENCODE_PERMISSION_OPTIONS = ["plan", "edit", "full-auto", "config-toml"] as const; const DROID_PERMISSION_OPTIONS = ["read-only", "auto-low", "auto-medium", "auto-high"] as const; -const SETTINGS_AI_ROUTE = "/settings?tab=ai#ai-providers"; type PaneFocus = "drawer" | "chat" | "details" | "addMode"; type AddModeState = { cursorLaneId: string; cursorChatId: string | null }; export type FooterControl = "drawer" | "details" | "agents"; @@ -332,6 +332,50 @@ export function resolveModelPickerEscape( return { kind: "close" }; } +// RightPane wraps the model picker in a single-line border (1) + a "MODEL" title +// row (1) and paddingX (1), so ModelPickerPane's first painted cell sits 2 rows +// below / 2 cols right of the pane's outer top-left and its usable width is 4 +// narrower. The click hit-test MUST feed modelPickerGeometry this CONTENT origin +// (not the outer box) or every rect drifts and hover/clicks land on the wrong +// row. Exported as a pure helper so the offset is unit-tested and stays in +// lockstep with RightPane's chrome. +export const MODEL_PICKER_PANE_CHROME_ROWS = 2; // top border + "MODEL" title row +export const MODEL_PICKER_PANE_CHROME_COLS = 2; // left border + paddingX +export function modelPickerPaneContentOrigin(args: { + paneTop: number; + paneLeft: number; + paneWidth: number; +}): { paneTop: number; paneLeft: number; paneWidth: number } { + return { + paneTop: args.paneTop + MODEL_PICKER_PANE_CHROME_ROWS, + paneLeft: args.paneLeft + MODEL_PICKER_PANE_CHROME_COLS, + paneWidth: Math.max(8, args.paneWidth - MODEL_PICKER_PANE_CHROME_COLS * 2), + }; +} + +export function modelPickerProviderSwitchBlocked(args: { + providerLocked: boolean; + surface: "chat" | "new-chat"; + currentProvider: AdeCodeProvider; + nextProvider: AdeCodeProvider; +}): boolean { + return args.surface === "chat" + && args.providerLocked + && args.currentProvider !== args.nextProvider; +} + +export function mergeNewChatModelPickerContext( + prev: Extract, + next: Extract, +): Extract { + return { + ...prev, + laneId: next.laneId, + laneLabel: next.laneLabel, + settingsRows: next.settingsRows, + }; +} + type ChatSessionActivity = Pick; type TerminalSessionActivity = Pick; @@ -637,10 +681,27 @@ function normalizeProvider(value: string | null | undefined): AdeCodeProvider { return PROVIDERS.has(value as AdeCodeProvider) ? value as AdeCodeProvider : "codex"; } +export function normalizeCatalogProvider(value: string | null | undefined): AdeCodeProvider { + const normalized = (value ?? "").trim().toLowerCase(); + if (normalized === "anthropic") return "claude"; + if (normalized === "openai") return "codex"; + if (normalized === "factory") return "droid"; + return normalizeProvider(normalized); +} + function runtimeProviderForUiProvider(provider: AdeCodeProvider): ModelProviderGroup { return provider === "ollama" || provider === "lmstudio" ? "opencode" : provider; } +function claudeModelCommandKey(state: AdeCodeModelState, terminalId: string | null | undefined): string { + return JSON.stringify([ + terminalId ?? null, + state.modelId ?? null, + state.model, + state.reasoningEffort?.trim() || null, + ]); +} + function modelCatalogClientRefreshTtlMs(provider?: AgentChatModelCatalogRefreshProvider): number { return provider === "lmstudio" || provider === "ollama" ? MODEL_CATALOG_LOCAL_CLIENT_REFRESH_TTL_MS @@ -662,9 +723,10 @@ function modelStatePatchForModel(provider: AdeCodeProvider, model: AgentChatMode const modelId = model.modelId ?? model.id; const descriptor = getModelById(modelId); const resolvedProvider = descriptor ? normalizeProvider(resolveProviderGroupForModel(descriptor)) : provider; + const runtimeProvider = runtimeProviderForUiProvider(resolvedProvider); return { provider: resolvedProvider, - model: model.id, + model: descriptor ? getRuntimeModelRefForDescriptor(descriptor, runtimeProvider) : model.id, modelId, displayName: model.displayName, reasoningEffort: firstReasoningEffortForModel(model, resolvedProvider), @@ -684,7 +746,7 @@ function fallbackModelStatePatch(provider: AdeCodeProvider): Pick AI Providers", - }); if (args.includeApply) { rows.push({ kind: "apply", @@ -1432,18 +1488,6 @@ function buildSetupRows(args: { return rows; } -function setupRowsForRuntime(rows: SetupPaneRow[], mode: RuntimeMode | "connecting"): SetupPaneRow[] { - if (mode === "attached") return rows; - return rows.map((row) => row.kind === "open-settings" - ? { - ...row, - value: "unavailable", - detail: "use /login for Claude, Codex, or OpenCode; open ADE desktop for full settings", - disabled: true, - } - : row); -} - function defaultSetupSelectionIndex(rows: SetupPaneRow[]): number { const applyIndex = rows.findIndex((row) => row.kind === "apply"); return applyIndex >= 0 ? applyIndex : 0; @@ -1701,7 +1745,7 @@ export function deletePromptForward(value: string, cursor: number): PromptEditRe // Apply a possibly-coalesced input chunk to the prompt, character by character. // Ink emits multiple fast keystrokes as ONE chunk and only recognizes a *lone* -// DEL/BS byte as backspace — so a burst like "x" (type then delete) arrives +// DEL/BS byte as backspace — so a burst like "x\x7f" (type then delete) arrives // as plain text with no backspace flag, and naive insertion would drop the // Like printableInput but keeps tabs (0x09) and newlines (0x0a), normalizing // CR/LF to "\n", so a pasted multi-line / tabbed block survives verbatim in the @@ -1710,7 +1754,7 @@ function printableMultilineInput(input: string): string { return input .replace(/\r\n/g, "\n") .replace(/\r/g, "\n") - .replace(/[- -]/g, ""); + .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ""); } // delete. Here we walk the chunk: printable runs are inserted, embedded @@ -2255,6 +2299,7 @@ const DRAWER_PANE_MAX_WIDTH = 48; const MIN_CENTER_PANE_WIDTH = 24; const MIN_RIGHT_PANE_WIDTH = 30; const RIGHT_PANE_MAX_WIDTH = 42; +const MODEL_PICKER_RIGHT_PANE_MAX_WIDTH = 64; const CLAUDE_TERMINAL_HIDDEN_INPUT_ROWS = 3; export const CLAUDE_TERMINAL_SUBMIT_CONFIRM_DELAY_MS = 1200; const CLAUDE_TERMINAL_SUBMIT_REFRESH_DELAY_MS = 150; @@ -2518,6 +2563,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [storedApiKeyProviders, setStoredApiKeyProviders] = useState([]); const [openCodeDiagnostics, setOpenCodeDiagnostics] = useState(null); const [rightPane, setRightPane] = useState({ kind: "empty" }); + // Measured (1-based) content origin of the model picker, reported by + // ModelPickerPane via Ink/Yoga so the click hit-test maps to where rows + // actually paint — robust to window size, no hardcoded offset. Null until the + // first measurement; the hit-test falls back to geometry math meanwhile. + const [pickerMeasuredOrigin, setPickerMeasuredOrigin] = useState<{ x: number; y: number; width: number } | null>(null); + const handlePickerMeasureOrigin = useCallback((origin: { x: number; y: number; width: number }) => { + setPickerMeasuredOrigin((prev) => + prev && prev.x === origin.x && prev.y === origin.y && prev.width === origin.width ? prev : origin, + ); + }, []); const [formValues, setFormValues] = useState>({}); const [formFieldIndex, setFormFieldIndex] = useState(0); const [rightSelectionIndex, setRightSelectionIndex] = useState(0); @@ -2670,6 +2725,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const terminalSessionsRef = useRef([]); const attachedTerminalIdRef = useRef(null); const claudeTerminalSubmitQueueRef = useRef>(Promise.resolve()); + const lastModelPickerClaudeSentKeyRef = useRef(null); const exitRequestedRef = useRef(false); const modelStateRef = useRef(initialModelState()); const chatMouseSelectionRef = useRef(null); @@ -3355,7 +3411,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } + (draftChatActive || (vimModeEnabled && !hideVimModeIndicator) || modelState.codexFastMode ? 1 : 0); const goalBannerRows = goalBannerText ? 1 : 0; const addModeRows = addMode ? 1 : 0; - const rightPaneMaxWidth = RIGHT_PANE_MAX_WIDTH; + const rightPaneMaxWidth = rightPane.kind === "model-picker" + ? MODEL_PICKER_RIGHT_PANE_MAX_WIDTH + : RIGHT_PANE_MAX_WIDTH; const rightPaneWidth = resolveRightPaneWidth(columns, rightOpen, drawerOpen, rightPaneMaxWidth); const centerWidth = resolveCenterPaneWidth(columns, drawerOpen, rightPaneWidth); const promptPaneWidth = Math.max(MIN_CENTER_PANE_WIDTH, finiteFloor(columns, MIN_CENTER_PANE_WIDTH)); @@ -3704,26 +3762,26 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } [aiStatus, openCodeDiagnostics, storedApiKeyProviders], ); const newChatSetupRows = useMemo( - () => setupRowsForRuntime(buildSetupRows({ + () => buildSetupRows({ modelState, models, includeRefresh: false, includeApply: true, outputStyle: "default", outputStyleEditable: false, - }), mode), - [mode, modelState, models], + }), + [modelState, models], ); const modelSetupRows = useMemo( - () => setupRowsForRuntime(buildSetupRows({ + () => buildSetupRows({ modelState, models, includeRefresh: true, includeApply: true, outputStyle: activeSession?.claudeOutputStyle ?? "default", outputStyleEditable: Boolean(activeSession?.sessionId && activeSession.provider === "claude"), - }), mode), - [activeSession?.claudeOutputStyle, activeSession?.provider, activeSession?.sessionId, mode, modelState, models], + }), + [activeSession?.claudeOutputStyle, activeSession?.provider, activeSession?.sessionId, modelState, models], ); const modelPickerRows = useMemo(() => { if (!providerLocked) return modelSetupRows; @@ -4015,7 +4073,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return next; } if (prev.kind === "model-picker" && prev.surface === "new-chat" && next.kind === "model-picker" && next.surface === "new-chat") { - return next; + return mergeNewChatModelPickerContext(prev, next); } // Avoid stomping on lane-details that has been hydrated with git data; // only refresh when the lane reference itself changed. @@ -5042,11 +5100,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } surface: "new-chat", query: "", searchMode: false, - showAll: true, selection: { kind: "provider", provider: modelState.provider }, providerTabKey: null, focusedIndex: 0, - footerFocus: "apply", + footerFocus: null, + railFocused: true, settingsRows: newChatSetupRows, laneId, laneLabel: lane.name, @@ -5055,12 +5113,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setPaneFocus("details"); void refreshAiSetupStatus().catch(() => undefined); void loadProviderModels(modelState.provider, { applyDefault: false }).catch(() => undefined); - }, [activeLane, addNotice, focusDetails, lanes, loadProviderModels, modelState.provider, newChatSetupRows, refreshAiSetupStatus, selectActiveSessionId, setDraftChatMode, setGridView, setPaneFocus, stashActiveInput]); + // Load the model catalog so the new-chat picker has provider rails + models + // even in a fresh runtime — without this it opens on the empty favorites rail + // ("0 models") until the catalog is loaded by some other path (/model, etc.). + void refreshModelCatalog().catch(() => undefined); + }, [activeLane, addNotice, focusDetails, lanes, loadProviderModels, modelState.provider, newChatSetupRows, refreshAiSetupStatus, refreshModelCatalog, selectActiveSessionId, setDraftChatMode, setGridView, setPaneFocus, stashActiveInput]); // Hydrate favorites/recents from the ade-cli RPC once the connection is up. useEffect(() => { const conn = connectionRef.current; if (!conn) return; + // Warm the model catalog on connect so every picker entry point (including + // the new-chat picker) has provider rails + models ready, even on a fresh + // runtime where nothing else has loaded the catalog yet. + void refreshModelCatalog().catch(() => undefined); let cancelled = false; void (async () => { try { @@ -5080,6 +5146,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }; }, [socketPath]); + // Load the model catalog whenever the picker opens without one. This is the + // reliable trigger: it fires for EVERY entry path (new-chat draft via + // resolveContextDefault, /model, drawer) once the connection is live — unlike + // the connect-time warm above, which can run before the socket is ready. Until + // the catalog lands, the picker falls back to the single active-provider list + // (which looked like "only the codex group" in the rail). + useEffect(() => { + if (rightPane.kind !== "model-picker") return; + if (modelCatalogRef.current) return; + void refreshModelCatalog().catch(() => undefined); + }, [rightPane.kind, refreshModelCatalog]); + // Right-pane model picker — replaces the inline-row focus path when launched // via /model or new-chat. Reuses the same data the inline row uses (models) // plus favorites/recents sourced from ade-cli for cross-surface sync. @@ -5098,8 +5176,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeModelId: modelState.modelId, activeReasoningEffort: modelState.reasoningEffort, aiStatus, - showAll: true, - query: "", + query: "", selection: { kind: "provider", provider }, providerTabKey: null, focusedIndex: 0, @@ -5115,16 +5192,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } modelPickerRecents, layoutSeed.railEntries, ); + // Open Stage 1 (model list) positioned on the active model for an existing + // chat; new chats start at the top of the list. + const activeFocusIndex = surface === "chat" && selection.kind === "provider" + ? Math.max(0, layoutSeed.entries.findIndex((entry) => entry.modelId === modelState.modelId)) + : 0; setRightPane({ kind: "model-picker", surface, query: "", - showAll: true, - searchMode: false, + searchMode: false, selection, providerTabKey: null, - focusedIndex: 0, + focusedIndex: activeFocusIndex, footerFocus: options.focusKind ?? null, + railFocused: surface === "new-chat", settingsRows: surface === "new-chat" ? newChatSetupRows : (providerLockedRef.current ? modelPickerRows : modelSetupRows), ...(surface === "new-chat" ? { @@ -7466,7 +7548,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const laneId = activeLaneIdRef.current; const sessionId = activeSessionIdRef.current; if (name === "/login") { - const provider = normalizeProvider(activeSession?.provider ?? modelState.provider); + const requestedProvider = args.trim().split(/\s+/)[0]?.toLowerCase() ?? ""; + if (requestedProvider && !PROVIDERS.has(requestedProvider as AdeCodeProvider)) { + addNotice(`Unknown provider "${requestedProvider}". Try one of: ${PROVIDER_OPTIONS.map((entry) => entry.value).join(", ")}.`, "error"); + return; + } + const provider = requestedProvider + ? requestedProvider as AdeCodeProvider + : normalizeProvider(activeSession?.provider ?? modelState.provider); const loginCommands = loginCommandsForProvider(provider); if (!loginCommands.length) { addNotice(`/login is not available for ${providerLabel(provider)}. ${loginUnavailableHint(provider)}`, "error"); @@ -8426,18 +8515,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } for (const provider of group.providers) { for (const subsection of provider.subsections) { const found = subsection.models.find((entry) => entry.id === modelId || entry.modelId === modelId); - if (found) { - catalogModel = found; - catalogProvider = normalizeProvider(group.key as AdeCodeProvider); - break; - } + if (found) { + catalogModel = found; + catalogProvider = normalizeCatalogProvider(group.key); + break; + } } if (catalogModel) break; } if (catalogModel) break; } const target = models.find((entry) => (entry.modelId ?? entry.id) === modelId) - ?? (catalogModel?.isAvailable === true ? catalogModel as AgentChatModelInfo : null); + ?? (catalogModel?.isAvailable === true ? catalogModel as AgentChatModelInfo : null) + ?? modelInfoFromDescriptor(modelId); if (!target) { addNotice(`Model ${modelId} is not available right now.`, "error"); return; @@ -8446,6 +8536,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const provider: AdeCodeProvider = descriptor ? normalizeProvider(resolveProviderGroupForModel(descriptor)) : catalogProvider ?? modelStateRef.current.provider; + if (modelPickerProviderSwitchBlocked({ + providerLocked: providerLockedRef.current, + surface: rightPane.kind === "model-picker" ? rightPane.surface : "chat", + currentProvider: modelStateRef.current.provider, + nextProvider: provider, + })) { + addNotice("Provider is locked for this chat. /new chat to switch.", "info"); + return; + } const previousModelState = modelStateRef.current; const nextModelState: AdeCodeModelState = { ...previousModelState, @@ -8457,6 +8556,23 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } modelStateRef.current = nextModelState; setModelState(nextModelState); scheduleModelStateCommit(nextModelState); + if (activeTerminalSessionRef.current && provider === "claude") { + const terminalId = activeTerminalSessionRef.current.terminalId; + const commandKey = claudeModelCommandKey(nextModelState, terminalId); + lastModelPickerClaudeSentKeyRef.current = commandKey; + void sendClaudeModelCommandToTerminal(nextModelState.modelId ?? nextModelState.model) + .then((sent) => { + if (!sent && lastModelPickerClaudeSentKeyRef.current === commandKey) { + lastModelPickerClaudeSentKeyRef.current = null; + } + }) + .catch((err) => { + if (lastModelPickerClaudeSentKeyRef.current === commandKey) { + lastModelPickerClaudeSentKeyRef.current = null; + } + addNotice(err instanceof Error ? err.message : String(err), "error"); + }); + } setModelPickerRecents((prev) => { const filtered = prev.filter((entry) => entry !== modelId); return [modelId, ...filtered].slice(0, 10); @@ -8468,32 +8584,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } .catch(() => undefined); } setRightPane((prev) => { - if (prev.kind === "model-picker" && prev.surface === "new-chat") { - // Picking a model drops focus DOWN into the settings (reasoning first) - // per the "pick → settings → Confirm" flow — the picker stays open. - const firstSetting = (prev.settingsRows ?? []).find( - (row) => row.kind !== "provider" && row.kind !== "model", - )?.kind ?? "apply"; - return { - ...prev, - selection: { kind: "provider", provider }, - focusedIndex: 0, - footerFocus: firstSetting, - }; - } - return { kind: "empty" }; + if (prev.kind !== "model-picker") return prev; + // "pick → settings → Confirm" for BOTH surfaces: selecting a model drops + // focus DOWN into the settings (reasoning first) and keeps the picker + // open. Confirm (the apply row) is the only thing that closes the pane + // and pushes the model to a running session — selection never closes it. + // focusedIndex is preserved so the just-picked row stays highlighted; ↑ + // out of the settings re-homes onto the active model (see key handler). + const firstSetting = (prev.settingsRows ?? []).find( + (row) => row.kind !== "provider" && row.kind !== "model", + )?.kind ?? "apply"; + return { + ...prev, + selection: { kind: "provider", provider }, + footerFocus: firstSetting, + }; }); - if (rightPane.kind === "model-picker" && rightPane.surface === "new-chat") { - setRightOpen(true); - setPaneFocus("details"); - } else { - setRightOpen(false); - setPaneFocus("chat"); - } - if (rightPane.kind === "model-picker" && rightPane.surface === "chat" && activeTerminalSessionRef.current && provider === "claude") { - void sendClaudeModelCommandToTerminal(modelId) - .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); - } + setRightOpen(true); + setPaneFocus("details"); addNotice(`Model set to ${target.displayName}.`, "success"); }, [addNotice, models, modelCatalog, rightPane, scheduleModelStateCommit, sendClaudeModelCommandToTerminal, setPaneFocus], @@ -8666,19 +8774,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } - if (row.kind === "open-settings") { - if (!conn) return; - void navigateDesktop(conn, { source: "ade-code", target: { kind: "route", route: SETTINGS_AI_ROUTE } }) - .then((result) => { - addNotice(result.ok ? "Opened ADE Settings > AI Providers." : result.message ?? "Desktop settings are unavailable.", result.ok ? "success" : "error"); - }) - .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); - return; - } if (row.kind === "apply") { if (activeTerminalSessionRef.current && modelStateRef.current.provider === "claude") { - void sendClaudeModelCommandToTerminal() - .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + const commandKey = claudeModelCommandKey(modelStateRef.current, activeTerminalSessionRef.current.terminalId); + if (lastModelPickerClaudeSentKeyRef.current !== commandKey) { + lastModelPickerClaudeSentKeyRef.current = commandKey; + void sendClaudeModelCommandToTerminal() + .then((sent) => { + if (!sent && lastModelPickerClaudeSentKeyRef.current === commandKey) { + lastModelPickerClaudeSentKeyRef.current = null; + } + }) + .catch((err) => { + if (lastModelPickerClaudeSentKeyRef.current === commandKey) { + lastModelPickerClaudeSentKeyRef.current = null; + } + addNotice(err instanceof Error ? err.message : String(err), "error"); + }); + } } setRightOpen(false); setRightPane({ kind: "empty" }); @@ -10348,7 +10461,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeModelId: modelState.modelId, activeReasoningEffort: modelState.reasoningEffort, aiStatus, - showAll: picker.showAll, settingsRows: picker.settingsRows ?? [], footerFocus: picker.footerFocus ?? null, laneLabel: picker.laneLabel ?? null, @@ -10360,22 +10472,31 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); const pickerSettingsRows = (picker.settingsRows ?? []).filter((row) => row.kind !== "provider" && row.kind !== "model"); const lastModelIndex = Math.max(0, layout.entries.length - 1); - // Unified, single vertical flow: ↑/↓ runs the model list → the settings - // rows → the Confirm button. footerFocus !== null means focus is in the - // settings region; otherwise it's in the model list. + // Navigation has two stages. Stage 1 is the SELECTION AREA — two columns: + // the category rail (favorites/recents/providers) and the model list. ←/→ + // move focus between the columns; ↑/↓ navigate within the focused column + // (rail = change category, list = move the model cursor). Enter on a rail + // entry steps right into its models; Enter on a model picks it and drops + // into Stage 2 = the settings (footerFocus !== null), where ↑/↓ walk the + // rows, ←/→ cycle a row's value, and ↑ off the first row returns to the + // model list. Down never spills the list into the settings. const inSettings = picker.footerFocus != null && pickerSettingsRows.length > 0; const settingIndex = inSettings ? Math.max(0, pickerSettingsRows.findIndex((row) => row.kind === picker.footerFocus)) : -1; - - // Switch the provider/category rail. Tab does this from anywhere; ←/→ does - // it while focus is in the model list (where there's no value to cycle). - const switchRail = (delta: -1 | 1) => { + // Search hides the rail (results are cross-provider), so treat focus as + // the list there — ↑/↓ move results, not rail categories. + const railFocused = !inSettings && !picker.searchMode && picker.railFocused === true; + + // Move the rail selection by one (clamped), refreshing dynamic providers + // and switching the model list to the newly selected category. Keeps focus + // on the rail column. + const moveRail = (delta: -1 | 1) => { const total = layout.railEntries.length; if (total === 0) return; - const nextIndex = (layout.railIndex + delta + total) % total; + const nextIndex = Math.max(0, Math.min(total - 1, layout.railIndex + delta)); const nextEntry = layout.railEntries[nextIndex]; - if (!nextEntry) return; + if (!nextEntry || nextIndex === layout.railIndex) return; const nextSelection = nextEntry.kind === "favorites" ? ({ kind: "favorites" } as const) @@ -10390,20 +10511,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } : null; if (refreshProvider) void refreshModelCatalog({ refreshProvider }); } - setRightPane({ ...picker, selection: nextSelection, providerTabKey: null, focusedIndex: 0, footerFocus: null, query: "", searchMode: false }); + setRightPane({ ...picker, selection: nextSelection, providerTabKey: null, focusedIndex: 0, footerFocus: null, railFocused: true, query: "", searchMode: false }); }; - if (key.tab) { - switchRail(key.shift ? -1 : 1); - return; - } - if (key.upArrow) { if (inSettings) { - if (settingIndex <= 0) setRightPane({ ...picker, footerFocus: null }); - else setRightPane({ ...picker, footerFocus: pickerSettingsRows[settingIndex - 1]?.kind ?? null }); + if (settingIndex <= 0) { + // Off the top of the settings → back to Stage 1's model list, re-homed + // onto the active model so focus lands where the user expects. + const activeIdx = Math.max(0, layout.entries.findIndex((entry) => entry.modelId === modelState.modelId)); + setRightPane({ ...picker, footerFocus: null, railFocused: false, focusedIndex: activeIdx }); + } else { + setRightPane({ ...picker, footerFocus: pickerSettingsRows[settingIndex - 1]?.kind ?? null }); + } return; } + if (railFocused) { moveRail(-1); return; } const next = Math.max(0, layout.focusedIndex - 1); if (next !== picker.focusedIndex) setRightPane({ ...picker, focusedIndex: next }); return; @@ -10415,45 +10538,66 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } return; } - // Past the last model, drop focus down into the settings rows. - if (layout.focusedIndex >= lastModelIndex && pickerSettingsRows.length > 0) { - setRightPane({ ...picker, footerFocus: pickerSettingsRows[0]?.kind ?? null }); - return; - } + if (railFocused) { moveRail(1); return; } + // Model list: clamp at the last model. Down does NOT spill into the + // settings — Enter on a model is the only gate into Stage 2. const next = Math.min(lastModelIndex, layout.focusedIndex + 1); if (next !== picker.focusedIndex) setRightPane({ ...picker, focusedIndex: next }); return; } - if (key.leftArrow || key.rightArrow) { + if (key.leftArrow) { if (inSettings) { const row = pickerSettingsRows[settingIndex]; - if (row) handleSetupRow(row, key.leftArrow ? -1 : 1); + if (row) handleSetupRow(row, -1); return; } - switchRail(key.leftArrow ? -1 : 1); + // Move focus left, onto the category rail (no-op if already there, or + // while searching where the rail is hidden). + if (!railFocused && !picker.searchMode) setRightPane({ ...picker, railFocused: true }); return; } - if (key.return) { + if (key.rightArrow) { if (inSettings) { const row = pickerSettingsRows[settingIndex]; if (row) handleSetupRow(row, 1); return; } - const target = layout.entries[layout.focusedIndex]; - if (target?.isAvailable) commitModelPickerSelection(target.modelId); + // Move focus right, onto the model list (no-op if already there). + if (railFocused) setRightPane({ ...picker, railFocused: false }); return; } - if (input === "s" && !picker.searchMode && !key.ctrl && !key.meta) { - setRightPane({ ...picker, showAll: !picker.showAll, focusedIndex: 0 }); + if (key.tab) { + if (inSettings) return; + // Tab toggles between the rail and the model list (same axis as ←/→). + setRightPane({ ...picker, railFocused: !railFocused }); + return; + } + if (key.return) { + if (inSettings) { + const row = pickerSettingsRows[settingIndex]; + if (row) handleSetupRow(row, 1); + return; + } + if (railFocused) { + // Step from the rail into its model list. + setRightPane({ ...picker, railFocused: false, focusedIndex: 0 }); + return; + } + const target = layout.entries[layout.focusedIndex]; + if (target?.isAvailable) { + commitModelPickerSelection(target.modelId); + } else if (target) { + void runInlineCommand("/login", target.family); + } return; } if ((input === "[" || input === "]") && !picker.searchMode && layout.providerTabs.length > 1) { const delta = input === "[" ? -1 : 1; const nextIndex = (layout.providerTabIndex + delta + layout.providerTabs.length) % layout.providerTabs.length; - const nextTab = layout.providerTabs[nextIndex]; - if (nextTab) { - setRightPane({ ...picker, providerTabKey: nextTab.key, focusedIndex: 0 }); - } + const nextTab = layout.providerTabs[nextIndex]; + if (nextTab) { + setRightPane({ ...picker, providerTabKey: nextTab.key, focusedIndex: 0, footerFocus: null, railFocused: false }); + } return; } // 'f' toggles favorite on focused row when not actively editing a search. @@ -11456,7 +11600,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeModelId: modelState.modelId, activeReasoningEffort: modelState.reasoningEffort, aiStatus, - showAll: picker.showAll, settingsRows: picker.settingsRows ?? [], footerFocus: picker.footerFocus ?? null, laneLabel: picker.laneLabel ?? null, @@ -11468,11 +11611,28 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); // Single geometry source: derive every clickable rect from the SAME // constants + windowing the render uses (modelPickerGeometry), so a - // click always lands on the row the user sees — even when scrolled. + // click lands on the row the user sees. Prefer the pane's MEASURED + // content origin (reported by ModelPickerPane via Ink/Yoga) — that's + // where rows actually paint at any window size, no hardcoded offset. + // Fall back to geometry math when no/implausible measurement exists. + const measured = pickerMeasuredOrigin; + const measuredOk = Boolean( + measured + && measured.y >= 1 && measured.y <= rows + && measured.x >= 1 && measured.x <= columns + && measured.width >= 8, + ); + const paneOrigin = measuredOk && measured + ? { paneLeft: measured.x, paneTop: measured.y, paneWidth: measured.width } + : modelPickerPaneContentOrigin({ + paneTop: rightBodyTop, + paneLeft: rightStartColumn, + paneWidth: rightPaneWidth, + }); const geometry = modelPickerGeometry({ - paneLeft: rightStartColumn, - paneTop: rightBodyTop, - paneWidth: rightPaneWidth, + paneLeft: paneOrigin.paneLeft, + paneTop: paneOrigin.paneTop, + paneWidth: paneOrigin.paneWidth, state: layout, rows, }); @@ -11482,12 +11642,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } onClick: () => setRightPane({ ...picker, searchMode: true, query: picker.query, focusedIndex: 0 }), zIndex: 4, }); - addTarget({ - id: "right:model-picker:show-all", - rect: geometry.showAll, - onClick: () => setRightPane({ ...picker, showAll: !picker.showAll, focusedIndex: 0 }), - zIndex: 3, - }); geometry.rail.forEach(({ id, rect }, index) => { const entry = layout.railEntries[index]; if (!entry) return; @@ -11509,6 +11663,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } selection: nextSelection, providerTabKey: null, focusedIndex: 0, + footerFocus: null, + railFocused: true, query: "", searchMode: false, }); @@ -11530,8 +11686,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } id, rect, onClick: () => { - setRightPane({ ...picker, focusedIndex: index }); - if (entry?.isAvailable) commitModelPickerSelection(modelId); + setRightPane({ ...picker, focusedIndex: index, railFocused: false }); + if (entry?.isAvailable) { + commitModelPickerSelection(modelId); + } else if (entry) { + void runInlineCommand("/login", entry.family); + } }, zIndex: 5, }); @@ -11770,6 +11930,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } mentionSuggestions, modelState, modelCatalog, + pickerMeasuredOrigin, modelPickerFavorites, modelPickerRecents, modelStatusOverlayRows, @@ -11999,6 +12160,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeReasoningEffort: modelState.reasoningEffort, aiStatus, }} + onModelPickerMeasureOrigin={handlePickerMeasureOrigin} /> ) : null} diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx index 2895198ec..3d3011b57 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/ModelPickerPane.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Text } from "ink"; +import { Box, Text, type DOMElement } from "ink"; import { theme } from "../../theme"; import type { SetupPaneRow, SetupPaneRowKind } from "../../types"; import { useHoveredHitId } from "../../hitTestRegistry"; @@ -10,7 +10,17 @@ import type { ModelPickerAuthStatus, ModelPickerEntry, ModelPickerRailEntry, Mod // settings footer around. Settings stay stickied below. Geometry constants + // the windowing function live in modelPickerGeometry so the click hit-test in // app.tsx computes identical rects from a SINGLE source. -import { MODEL_LIST_ROWS, RAIL_WIDTH, rowWindow, modelPickerGeometry } from "./modelPickerGeometry"; +import { + headerLineCount, + hasSubProviderSelector, + isSearching, + modelEntryHeightForState, + modelListRowsForState, + RAIL_WIDTH, + RAIL_TO_LIST_GAP, + rowWindow, + usesCompactProviderRows, +} from "./modelPickerGeometry"; function endTruncate(value: string, max: number): string { if (max <= 1) return value.length ? "…" : ""; @@ -18,22 +28,205 @@ function endTruncate(value: string, max: number): string { return `${value.slice(0, Math.max(0, max - 1))}…`; } -// Brand glyph + color for a category/provider rail entry, reusing the canonical -// provider identity so the picker matches the rest of the TUI. -function railIcon(entry: ModelPickerRailEntry): { glyph: string; color: string } { - if (entry.kind === "favorites") return { glyph: "★", color: theme.color.warning }; - if (entry.kind === "recents") return { glyph: "◷", color: theme.color.t3 }; - const brand = theme.provider(entry.provider); - return { glyph: brand.glyph, color: brand.color }; -} - // Amber pip for a provider that needs sign-in; nothing for a ready provider // (green would read as idle chrome). function authPip(status: ModelPickerAuthStatus): { glyph: string; color: string } | null { - if (status === "unavailable") return { glyph: "○", color: theme.color.attention }; + if (status === "unavailable") return { glyph: "!", color: theme.color.danger }; return null; } +const RAIL_LABELS: Record = { + claude: "Anthropic", + codex: "OpenAI", + droid: "Droid", + cursor: "Cursor", + opencode: "OpenCode", + ollama: "Ollama", + lmstudio: "LM Studio", +}; + +function normalizeProviderToken(value: string | null | undefined): string { + return (value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ""); +} + +function titleCaseProvider(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + const normalized = normalizeProviderToken(trimmed); + const known = PROVIDER_MARKS[normalized]?.label; + if (known) return known; + return trimmed + .replace(/\b\w/g, (ch) => ch.toUpperCase()) + .replace(/\bAi\b/g, "AI"); +} + +function providerLabelFor(entry: Pick): string { + if (entry.subProvider?.trim()) return titleCaseProvider(entry.subProvider); + return RAIL_LABELS[entry.family] ?? theme.provider(entry.family).label; +} + +type ProviderMark = { + label: string; + short: string; + terminal?: string; + color: string; + iconFill?: string; + svgPath?: string; + svgPaths?: Array<{ d: string; fill: string }>; + viewBox?: string; + svg?: string; +}; + +// Path data is sourced from the same brand mark set the desktop picker uses +// (@lobehub/icons), with ADE's local Droid SVG embedded below for terminals +// that can render inline images. +const OPENAI_PATH = "M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"; +const ANTHROPIC_PATH = "M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"; +const CLAUDE_PATH = "M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"; +const CODEX_PATH = "M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"; +const GEMINI_PATH = "M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"; +const OPENCODE_PATH = "M16 6H8v12h8V6zm4 16H4V2h16v20z"; +const GOOGLE_PATHS = [ + "M23 12.245c0-.905-.075-1.565-.236-2.25h-10.54v4.083h6.186c-.124 1.014-.797 2.542-2.294 3.569l-.021.136 3.332 2.53.23.022C21.779 18.417 23 15.593 23 12.245z", + "M12.225 23c3.03 0 5.574-.978 7.433-2.665l-3.542-2.688c-.948.648-2.22 1.1-3.891 1.1a6.745 6.745 0 01-6.386-4.572l-.132.011-3.465 2.628-.045.124C4.043 20.531 7.835 23 12.225 23z", + "M5.84 14.175A6.65 6.65 0 015.463 12c0-.758.138-1.491.361-2.175l-.006-.147-3.508-2.67-.115.054A10.831 10.831 0 001 12c0 1.772.436 3.447 1.197 4.938l3.642-2.763z", + "M12.225 5.253c2.108 0 3.529.892 4.34 1.638l3.167-3.031C17.787 2.088 15.255 1 12.225 1 7.834 1 4.043 3.469 2.197 7.062l3.63 2.763a6.77 6.77 0 016.398-4.572z", +]; +const GOOGLE_FILLS = ["#4285F4", "#34A853", "#FBBC05", "#EB4335"]; +const MISTRAL_PATHS = [ + { d: "M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z", fill: "gold" }, + { d: "M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z", fill: "#FFAF00" }, + { d: "M3.428 10.258h17.144v3.428H3.428v-3.428z", fill: "#FF8205" }, + { d: "M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z", fill: "#FA500F" }, + { d: "M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z", fill: "#E10500" }, +]; +const DEEPSEEK_PATH = "M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"; +const XAI_PATH = "M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"; +const GROQ_PATH = "M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z"; +const OPENROUTER_PATH = "M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"; +const KIMI_PATHS = [ + { d: "M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z", fill: "#FFFFFF" }, + { d: "M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z", fill: "#FFFFFF" }, +]; +const OLLAMA_PATH = "M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"; +const LMSTUDIO_PATHS = [ + { d: "M2.84 2a1.273 1.273 0 100 2.547h14.107a1.273 1.273 0 100-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H22.04a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h14.106a1.274 1.274 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H15.38a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h14.106a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h9.698a1.273 1.273 0 100-2.547h-9.698z", fill: "rgba(255,255,255,.3)" }, + { d: "M2.84 2a1.273 1.273 0 100 2.547h10.287a1.274 1.274 0 000-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H18.22a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H11.56a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h5.78a1.273 1.273 0 100-2.547h-5.78z", fill: "#FFFFFF" }, +]; +const CURSOR_SVG = ``; +const DROID_SVG = ''; + +const PROVIDER_MARKS: Record = { + anthropic: { label: "Anthropic", short: "AI", terminal: "AI", color: "#F1F0E8", iconFill: "#141413", svgPath: ANTHROPIC_PATH }, + claude: { label: "Anthropic", short: "AI", terminal: "AI", color: "#F1F0E8", iconFill: "#141413", svgPath: ANTHROPIC_PATH }, + openai: { label: "OpenAI", short: "OA", terminal: "◎", color: "#F0F0F2", iconFill: "#050505", svgPath: OPENAI_PATH }, + codex: { label: "OpenAI", short: "OA", terminal: "◎", color: "#F0F0F2", iconFill: "#050505", svgPath: OPENAI_PATH }, + google: { label: "Google", short: "G", terminal: "G", color: "#4285F4", svgPaths: GOOGLE_PATHS.map((d, index) => ({ d, fill: GOOGLE_FILLS[index] ?? "#FFFFFF" })) }, + gemini: { label: "Google", short: "G", terminal: "✦", color: "#3186FF", svgPath: GEMINI_PATH, iconFill: "#3186FF" }, + deepseek: { label: "DeepSeek", short: "DS", terminal: "∿", color: "#4D6BFE", svgPath: DEEPSEEK_PATH }, + mistral: { label: "Mistral", short: "MI", terminal: "▥", color: "#FF8205", svgPaths: MISTRAL_PATHS }, + xai: { label: "xAI", short: "xA", terminal: "X", color: "#F0F0F2", iconFill: "#000000", svgPath: XAI_PATH }, + grok: { label: "xAI", short: "xA", terminal: "X", color: "#F0F0F2", iconFill: "#000000", svgPath: XAI_PATH }, + groq: { label: "Groq", short: "GQ", terminal: "Gq", color: "#F55036", svgPath: GROQ_PATH }, + together: { label: "Together", short: "TG", terminal: "T", color: "#22C55E" }, + openrouter: { label: "OpenRouter", short: "OR", terminal: "⇄", color: "#6566F1", svgPath: OPENROUTER_PATH }, + opencode: { label: "OpenCode", short: "OC", terminal: "▣", color: "#F0F0F2", svgPath: OPENCODE_PATH }, + droid: { label: "Droid", short: "DR", terminal: "✺", color: "#06B6D4", svg: DROID_SVG }, + factory: { label: "Droid", short: "DR", terminal: "✺", color: "#06B6D4", svg: DROID_SVG }, + cursor: { label: "Cursor", short: "CU", terminal: "⬢", color: "#0EA5E9", svg: CURSOR_SVG }, + kimi: { label: "Kimi", short: "Ki", terminal: "Ki", color: "#F0F0F2", svgPaths: KIMI_PATHS }, + moonshot: { label: "Kimi", short: "Ki", terminal: "Ki", color: "#F0F0F2", svgPaths: KIMI_PATHS }, + ollama: { label: "Ollama", short: "OL", terminal: "◕", color: "#F0F0F2", iconFill: "#000000", svgPath: OLLAMA_PATH }, + lmstudio: { label: "LM Studio", short: "LM", terminal: "≋", color: "#8B5CF6", svgPaths: LMSTUDIO_PATHS }, +}; + +const ROW_MARKS: Record = { + claude: { label: "Claude", short: "Cl", terminal: "✻", color: "#D97757", svgPath: CLAUDE_PATH }, + anthropic: { label: "Claude", short: "Cl", terminal: "✻", color: "#D97757", svgPath: CLAUDE_PATH }, + codex: { label: "Codex", short: "Cx", terminal: "✦", color: "#A78BFA", svg: `` }, + openai: { label: "OpenAI", short: "OA", color: "#050505", svgPath: OPENAI_PATH }, + google: { label: "Gemini", short: "Ge", color: "#FFFFFF", svgPath: GEMINI_PATH, iconFill: "#3186FF" }, + gemini: { label: "Gemini", short: "Ge", color: "#FFFFFF", svgPath: GEMINI_PATH, iconFill: "#3186FF" }, + deepseek: PROVIDER_MARKS.deepseek!, + mistral: PROVIDER_MARKS.mistral!, + xai: PROVIDER_MARKS.xai!, + grok: PROVIDER_MARKS.grok!, + groq: PROVIDER_MARKS.groq!, + kimi: PROVIDER_MARKS.kimi!, + moonshot: PROVIDER_MARKS.moonshot!, + openrouter: PROVIDER_MARKS.openrouter!, + opencode: PROVIDER_MARKS.opencode!, + droid: PROVIDER_MARKS.droid!, + factory: PROVIDER_MARKS.factory!, + cursor: PROVIDER_MARKS.cursor!, + ollama: PROVIDER_MARKS.ollama!, + lmstudio: PROVIDER_MARKS.lmstudio!, +}; + +function markSvg(mark: ProviderMark): string | null { + if (mark.svg) return mark.svg; + if (mark.svgPaths) { + return `${mark.svgPaths.map((path) => ``).join("")}`; + } + if (!mark.svgPath) return null; + return ``; +} + +function supportsInlineLogoImages(): boolean { + if (process.env.ADE_TUI_INLINE_LOGOS === "0") return false; + if (process.env.ADE_TUI_INLINE_LOGOS === "1") return true; + const termProgram = (process.env.TERM_PROGRAM ?? "").toLowerCase(); + return termProgram.includes("iterm") || termProgram.includes("wezterm"); +} + +function inlineLogoEscape(mark: ProviderMark): string | null { + if (!supportsInlineLogoImages()) return null; + const svg = markSvg(mark); + if (!svg) return null; + const encoded = Buffer.from(svg).toString("base64"); + return `\u001b]1337;File=inline=1;width=2;height=1;preserveAspectRatio=1;type=image/svg+xml:${encoded}\u0007`; +} + +function drawInlineLogo(mark: ProviderMark, x: number, y: number): void { + if (!process.stdout.isTTY) return; + const image = inlineLogoEscape(mark); + if (!image) return; + process.stdout.write(`\u001b7\u001b[${Math.max(1, Math.round(y))};${Math.max(1, Math.round(x))}H${image}\u001b8`); +} + +function markForProvider(provider: string | null | undefined): ProviderMark { + const normalized = normalizeProviderToken(provider); + return PROVIDER_MARKS[normalized] ?? { + label: titleCaseProvider(provider ?? "Provider") || "Provider", + short: endTruncate((provider ?? "?").trim().toUpperCase(), 2), + color: theme.color.t3, + }; +} + +function markForEntry(entry: ModelPickerEntry): ProviderMark { + const keyMark = ROW_MARKS[normalizeProviderToken(entry.subProviderKey)]; + if (keyMark) return keyMark; + const labelMark = ROW_MARKS[normalizeProviderToken(entry.subProvider)]; + if (labelMark) return labelMark; + return ROW_MARKS[normalizeProviderToken(entry.family)] ?? markForProvider(entry.family); +} + +function LogoCell({ mark, dim = false }: { mark: ProviderMark; dim?: boolean }) { + const glyph = endTruncate((mark.terminal ?? mark.short).padEnd(2), 2); + return ( + + {glyph} + + ); +} + +function RailLogoSlot({ mark, dim = false }: { mark: ProviderMark; dim?: boolean }) { + return ; +} + function settingIcon(kind: SetupPaneRowKind): string { switch (kind) { case "reasoning": return "✦"; @@ -41,7 +234,6 @@ function settingIcon(kind: SetupPaneRowKind): string { case "codex-fast": return "↯"; case "output-style": return "✎"; case "refresh-status": return "↻"; - case "open-settings": return "↗"; default: return "·"; } } @@ -52,23 +244,43 @@ function VerticalRail({ entries, selectedIndex, hoveredId, + focused, }: { entries: ModelPickerRailEntry[]; selectedIndex: number; hoveredId: string | null; + focused: boolean; }) { return ( {entries.map((entry, index) => { const selected = index === selectedIndex; const hovered = hoveredId === `right:model-picker:rail:${index}`; - const accent = selected || hovered; - const icon = railIcon(entry); + // The selected category stays violet even when focus moves to the model + // list; the small arrow appears only while the rail column owns focus. + const showCursor = selected && focused; const pip = entry.kind === "provider" ? authPip(entry.authStatus) : null; + const label = entry.kind === "provider" + ? (RAIL_LABELS[entry.provider] ?? entry.label) + : entry.kind === "favorites" + ? "Favs" + : "Recents"; + const labelWidth = RAIL_WIDTH - 5; + const color = selected || hovered ? theme.color.violet : theme.color.t3; + const mark = entry.kind === "provider" ? markForProvider(entry.provider) : null; + const labelText = endTruncate(label, Math.max(3, labelWidth)).padEnd(labelWidth); return ( - {selected ? "▶" : " "} - {icon.glyph} + {showCursor ? "›" : " "} + {mark ? ( + + ) : ( + + {entry.kind === "favorites" ? "★ " : "◷ "} + + )} + {" "} + {labelText} {pip ? {pip.glyph} : {" "}} ); @@ -77,10 +289,10 @@ function VerticalRail({ ); } -// ── Compact sub-provider selector ──────────────────────────────────────────── -// Replaces the old wrap-everything chip block: shows the ACTIVE group only, -// with a count and the [ ] switch hint. Bounded to a single row. -function SubProviderSelector({ +// ── Compact sub-provider tab strip ─────────────────────────────────────────── +// Terminal-width version of the desktop tab bar. It keeps the group names +// visible instead of reducing them to "3/136" bookkeeping. +function SubProviderTabs({ tabs, selectedIndex, width, @@ -91,74 +303,133 @@ function SubProviderSelector({ }) { if (tabs.length <= 1) return null; const safe = Math.max(0, Math.min(selectedIndex, tabs.length - 1)); - const active = tabs[safe]; - if (!active) return null; + if (!tabs[safe]) return null; + const tabMax = Math.max(8, Math.min(16, Math.floor(width / 2))); + const segments = tabs.map((tab, index) => { + const activeTab = index === safe; + const label = endTruncate(titleCaseProvider(tab.label), tabMax); + return activeTab ? `[${label}]` : ` ${label} `; + }); + let start = 0; + if (segments.join("").length > width) { + start = safe; + while (start > 0 && segments.slice(start - 1, safe + 1).join("").length < Math.floor(width * 0.7)) { + start -= 1; + } + } + let used = 0; + const visible: Array<{ text: string; active: boolean; index: number }> = []; + if (start > 0 && width > 2) { + visible.push({ text: "‹ ", active: false, index: -1 }); + used += 2; + } + for (let index = start; index < segments.length; index += 1) { + const text = segments[index] ?? ""; + if (!text) continue; + const nextUsed = used + text.length + (visible.length ? 1 : 0); + if (nextUsed > width - (index < segments.length - 1 ? 1 : 0)) { + if (used < width) visible.push({ text: "…", active: false, index: -2 }); + break; + } + if (visible.length && used < width) { + visible.push({ text: " ", active: false, index: -3 }); + used += 1; + } + visible.push({ text, active: index === safe, index }); + used += text.length; + } return ( - {"‹ "} - {endTruncate(active.label, Math.max(8, width - 18))} - {` ${safe + 1}/${tabs.length}`} - {" › "} - {"[ ]"} + {visible.map((segment, index) => ( + + {segment.text} + + ))} ); } -// ── Model list row (single line — no overlapping second line) ──────────────── - -// Fixed prefix drawn before every name: selection rail (1) + favorite star (1) -// + " glyph " (space + brand glyph + space = 3) = 5 cols. -const ROW_PREFIX_WIDTH = 5; -// Per-row suffixes, measured to the character so the name reservation is exact -// and a row can never wrap onto a second line (the geometry assumes 1 line each): -// active -> " ● now" (7 cols) -// unavailable-> " · sign in" (11 cols) -const ROW_SUFFIX_ACTIVE = " ● now".length; -const ROW_SUFFIX_UNAVAILABLE = " · sign in".length; +// ── Model list row (desktop-style title + provider subtitle) ───────────────── + +// Fixed prefix drawn before every name: cursor arrow (1) + favorite star (1) +// + " " + 2-cell logo + " " = 6 cols. +const ROW_PREFIX_WIDTH = 6; +// Per-row suffix, measured to the character so the name reservation is exact and +// a row can never wrap past its title line: +// unavailable -> " SIGN IN" (9 cols) +const ROW_SUFFIX_UNAVAILABLE = " SIGN IN".length; const ROW_NAME_MIN = 6; function ModelListRow({ entry, selected, - active, hovered, + listFocused, contentWidth, + showProviderMark, + showSubtitle, }: { entry: ModelPickerEntry; selected: boolean; - active: boolean; hovered: boolean; + listFocused: boolean; contentWidth: number; + showProviderMark: boolean; + showSubtitle: boolean; }) { + // The cursor is the SINGLE source of truth: a small purple "›" arrow + a purple + // name. It reads bold while the model column holds focus. Every other available + // model is plain white; unavailable models are dimmed. (No "now" badge — the + // picker opens with the cursor already on the active model.) const accent = selected || hovered; - const brand = theme.provider(entry.family); + const mark = markForEntry(entry); + const providerLabel = providerLabelFor(entry); const nameColor = !entry.isAvailable ? theme.color.t5 : accent ? theme.color.violet - : active - ? theme.color.t1 - : theme.color.t2; - // Reserve the EXACT suffix this row draws (active and unavailable are mutually - // exclusive — an active model is by definition available). The name column is - // whatever is left after the fixed prefix and this row's suffix. - const suffixWidth = active - ? ROW_SUFFIX_ACTIVE - : !entry.isAvailable - ? ROW_SUFFIX_UNAVAILABLE - : 0; - const nameWidth = Math.max(ROW_NAME_MIN, contentWidth - ROW_PREFIX_WIDTH - suffixWidth); + : theme.color.t1; + const subtitleColor = !entry.isAvailable + ? theme.color.t5 + : accent + ? theme.color.t2 + : theme.color.t3; + const suffixWidth = !entry.isAvailable ? ROW_SUFFIX_UNAVAILABLE : 0; + const prefixWidth = showProviderMark ? ROW_PREFIX_WIDTH : 3; + const nameWidth = Math.max(ROW_NAME_MIN, contentWidth - prefixWidth - suffixWidth); + const subtitleWidth = Math.max(ROW_NAME_MIN, contentWidth - prefixWidth); return ( - - {/* Selection rail (violet) — selection is shown by COLOR, not indentation. */} - {selected ? theme.rail : " "} - {entry.isFavorite ? "★" : "☆"} - {` ${brand.glyph} `} - - {endTruncate(entry.displayName, nameWidth)} - - {active ? {" ● now"} : null} - {!entry.isAvailable ? {" · sign in"} : null} + + + {/* Cursor arrow — shows ONLY while the model list holds focus; the selected + row still stays purple when focus is on the rail, just without the arrow. */} + {selected && listFocused ? "›" : " "} + {entry.isFavorite ? "★" : "☆"} + {" "} + {showProviderMark ? ( + <> + + {" "} + + ) : null} + + {endTruncate(entry.displayName, nameWidth)} + + {!entry.isAvailable ? {" SIGN IN"} : null} + + {showSubtitle ? ( + + {" ".repeat(prefixWidth)} + + {endTruncate(providerLabel, subtitleWidth)} + + + ) : null} ); } @@ -187,7 +458,7 @@ function SettingsFooter({ {divider} {settingRows.length ? ( - + {settingRows.map((row) => { const focused = footerFocus === row.kind; const hovered = hoveredId === `right:model-picker:setting:${row.kind}`; @@ -195,7 +466,7 @@ function SettingsFooter({ const labelColor = row.disabled ? theme.color.t5 : theme.color.t4; const valueColor = row.disabled ? theme.color.t5 : accent ? theme.color.violet : theme.color.t2; return ( - + {focused ? theme.rail : " "} {`${settingIcon(row.kind)} `} {endTruncate(row.label.toLowerCase(), 12)}{" "} @@ -221,83 +492,142 @@ function SettingsFooter({ } // ── Windowing ──────────────────────────────────────────────────────────────── -// rowWindow + RAIL_WIDTH + MODEL_LIST_ROWS are imported from modelPickerGeometry -// (the single geometry source shared with the app.tsx click hit-test). - -// ── Dev-only hit-test verifier ─────────────────────────────────────────────── -// Gated on ADE_DEBUG_HITTEST. Lists the relative (paneTop/paneLeft = 0) rects -// the SHARED geometry helper produces for the current state, so a developer can -// cross-check that the painted rows line up with the clickable rects. Static -// (no timers, idle = zero extra re-renders); neutral chrome colors only. -function DebugHitOverlay({ state }: { state: ModelPickerState }) { - if (!process.env.ADE_DEBUG_HITTEST) return null; - const geometry = modelPickerGeometry({ - paneLeft: 0, - paneTop: 0, - paneWidth: 40, - state, - rows: 100, - }); - const fmt = (id: string, r: { x: number; y: number; w: number; h: number }) => - `${id} y${r.y} x${r.x} ${r.w}×${r.h}`; - const lines: string[] = [ - fmt("search", geometry.search), - ...geometry.rail.map((entry) => fmt(`rail[${entry.id.split(":").pop()}]`, entry.rect)), - ...geometry.entries.map((entry) => fmt(`entry#${entry.index}`, entry.rect)), - ...geometry.settings.map((entry) => fmt(`set:${entry.id.split(":").pop()}`, entry.rect)), - ...(geometry.apply ? [fmt("apply", geometry.apply)] : []), - ]; - return ( - - hit-test geometry (ADE_DEBUG_HITTEST) - {lines.map((line, index) => ( - {line} - ))} - - ); -} +// rowWindow + sizing helpers are imported from modelPickerGeometry (the single +// geometry source shared with the app.tsx click hit-test). function emptyStateLabel(state: ModelPickerState, railEntry: ModelPickerRailEntry | undefined): string { if (state.query.trim()) return "No models match your search."; - if (railEntry?.kind === "favorites") return "Press f on a model to pin it here."; - if (railEntry?.kind === "recents") return "Models you switch to appear here."; + if (railEntry?.kind === "favorites") return "Star a model to pin it here."; + if (railEntry?.kind === "recents") return "Models you use will appear here."; if (railEntry?.kind === "provider" && railEntry.authStatus === "unavailable") return "Sign in to use this provider."; return "No models available."; } +// Absolute screen cell (0-based) of an Ink element, by summing each ancestor's +// Yoga-computed offset up to the root. Ink renders the whole app at the +// terminal's top-left, so this is the true painted position — the source of +// truth for click hit-testing, replacing hand-derived offset math. Uses Ink +// internals (yogaNode / parentNode) not in the public types, hence the casts; +// fully guarded so any failure leaves the caller on its geometry-math fallback. +function measurePaneOrigin(node: DOMElement): { x: number; y: number; width: number } | null { + try { + const rootYoga = (node as unknown as { yogaNode?: { getComputedWidth?: () => number } }).yogaNode; + const width = rootYoga?.getComputedWidth?.() ?? 0; + let x = 0; + let y = 0; + let cur: unknown = node; + while (cur) { + const yoga = (cur as { yogaNode?: { getComputedLeft?: () => number; getComputedTop?: () => number } }).yogaNode; + if (yoga && typeof yoga.getComputedLeft === "function" && typeof yoga.getComputedTop === "function") { + x += yoga.getComputedLeft(); + y += yoga.getComputedTop(); + } + cur = (cur as { parentNode?: unknown }).parentNode; + } + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; + return { x, y, width: Number.isFinite(width) ? width : 0 }; + } catch { + return null; + } +} + export function ModelPickerPane({ state, width, + railFocused, + onMeasureOrigin, }: { state: ModelPickerState; width: number; + /** True when the left category rail holds focus; false = the model list. */ + railFocused: boolean; + /** + * Reports the pane's measured content origin (1-based screen cell) + width so + * the click hit-test maps to where rows actually paint at any window size. + */ + onMeasureOrigin?: (origin: { x: number; y: number; width: number }) => void; }) { const hoveredId = useHoveredHitId(); + const rootRef = React.useRef(null); + React.useEffect(() => { + if (!onMeasureOrigin) return; + const node = rootRef.current; + if (!node) return; + const origin = measurePaneOrigin(node); + // Yoga is 0-based; mouse/hit-test coords are 1-based. + if (origin) onMeasureOrigin({ x: origin.x + 1, y: origin.y + 1, width: origin.width }); + }, [onMeasureOrigin, width]); const innerWidth = Math.max(20, width - 4); - const searching = state.query.trim().length > 0; + const searching = isSearching(state); const railEntry = state.railEntries[state.railIndex] ?? state.railEntries[0]; - const activeEntry = state.activeModelId - ? state.entries.find((entry) => entry.modelId === state.activeModelId) ?? null - : null; - const window = rowWindow(state.entries.length, state.focusedIndex, MODEL_LIST_ROWS); + const entryHeight = modelEntryHeightForState(state); + const visibleRowCount = modelListRowsForState(state); + const window = rowWindow(state.entries.length, state.focusedIndex, visibleRowCount); const visibleEntries = state.entries.slice(window.start, window.end); - const hiddenAfter = state.entries.length - window.end; - const hiddenBefore = window.start; - // Content sits right of the icon rail (full width while searching). Each row - // reserves its OWN prefix + (active/unavailable) suffix from contentWidth, so - // the precise name width is computed per row inside ModelListRow — guaranteeing - // every row stays exactly one line at any terminal width. + const headerLines = headerLineCount(state); + const selectorLines = hasSubProviderSelector(state) ? 2 : 0; + const railLogoKey = React.useMemo( + () => state.railEntries.map((entry) => entry.kind === "provider" ? entry.provider : entry.kind).join("|"), + [state.railEntries], + ); + const visibleLogoKey = React.useMemo( + () => visibleEntries.map((entry) => `${entry.modelId}:${entry.family}:${entry.subProvider ?? ""}`).join("|"), + [visibleEntries], + ); + // Content sits right of the rail (full width while searching). Each row + // reserves its OWN prefix + unavailable suffix from contentWidth, so the title + // and subtitle stay inside the fixed two-line row budget. const contentWidth = searching ? innerWidth : Math.max(14, innerWidth - RAIL_WIDTH - 2); + const searchWidth = Math.max(8, innerWidth - 2); + const compactProviderRows = usesCompactProviderRows(state); + const rowProviderMarksVisible = !compactProviderRows; + const rowSubtitleVisible = !compactProviderRows; + const listFocused = state.footerFocus == null && !railFocused; - // The list always occupies exactly MODEL_LIST_ROWS rows (padded with blanks) - // so the settings footer never shifts as the catalog length changes. + React.useEffect(() => { + if (!supportsInlineLogoImages()) return; + const node = rootRef.current; + if (!node) return; + const origin = measurePaneOrigin(node); + if (!origin) return; + const rootX = origin.x + 1; + const rootY = origin.y + 1; + const modelRegionY = rootY + headerLines + 3; + const listTop = modelRegionY + selectorLines; + const listLeft = searching ? rootX : rootX + RAIL_WIDTH + RAIL_TO_LIST_GAP; + + if (!searching) { + state.railEntries.forEach((entry, index) => { + if (entry.kind !== "provider") return; + drawInlineLogo(markForProvider(entry.provider), rootX + 1, modelRegionY + index); + }); + } + if (!rowProviderMarksVisible) return; + visibleEntries.forEach((entry, sliceIndex) => { + drawInlineLogo(markForEntry(entry), listLeft + 3, listTop + (sliceIndex * entryHeight)); + }); + }, [ + entryHeight, + headerLines, + railLogoKey, + rowProviderMarksVisible, + searching, + selectorLines, + visibleLogoKey, + ]); + + // The list always occupies exactly the same line budget for this view (padded + // with blanks) so the settings footer never shifts as the catalog length changes. const listRows: React.ReactNode[] = []; if (state.entries.length === 0) { listRows.push( - - {endTruncate(emptyStateLabel(state, railEntry), contentWidth)} - , + + + {endTruncate(emptyStateLabel(state, railEntry), contentWidth)} + + {Array.from({ length: entryHeight - 1 }, (_v, i) => )} + , ); } else { visibleEntries.forEach((entry, sliceIndex) => { @@ -307,32 +637,31 @@ export function ModelPickerPane({ key={entry.modelId || `entry-${flatIndex}`} entry={entry} selected={flatIndex === state.focusedIndex} - active={state.activeModelId != null && entry.modelId === state.activeModelId} hovered={hoveredId === `right:model-picker:entry:${entry.modelId}`} + listFocused={listFocused} contentWidth={contentWidth} + showProviderMark={rowProviderMarksVisible} + showSubtitle={rowSubtitleVisible} />, ); }); } - while (listRows.length < MODEL_LIST_ROWS) { - listRows.push( ); + while (listRows.length < visibleRowCount) { + listRows.push( + + {Array.from({ length: entryHeight }, (_v, i) => )} + , + ); } return ( - + {/* Header (fixed). */} {state.entries.length} model{state.entries.length === 1 ? "" : "s"} {state.laneLabel ? ` · ${endTruncate(state.laneLabel, Math.max(6, innerWidth - 18))}` : ""} - {activeEntry ? ( - - {"● now "} - {theme.provider(activeEntry.family).glyph} - {` ${endTruncate(activeEntry.displayName, Math.max(8, innerWidth - 12))}`} - - ) : null} {state.activeProviderAuthStatus === "unavailable" && state.activeProviderSignInHint ? ( {`Sign in: ${state.activeProviderSignInHint}`} ) : null} @@ -342,7 +671,7 @@ export function ModelPickerPane({ {"⌕ "} - {endTruncate(state.query || "search models…", Math.max(8, innerWidth - 2))} + {endTruncate(state.query || "search models…", Math.max(6, searchWidth - 2))} {state.searchMode ? : null} @@ -352,7 +681,7 @@ export function ModelPickerPane({ {listRows} ) : ( - + - + {listRows} )} - {(hiddenBefore > 0 || hiddenAfter > 0) ? ( - - {hiddenBefore > 0 ? `↑ ${hiddenBefore} ` : ""}{hiddenAfter > 0 ? `↓ ${hiddenAfter} more` : ""} - - ) : null} - {/* Sticky settings footer. */} {/* Key hints. */} - - ); } diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts index 4016153fd..14f46142c 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { + MODEL_ENTRY_HEIGHT, + MODEL_LIST_BODY_LINES, MODEL_LIST_ROWS, + PROVIDER_MODEL_ENTRY_HEIGHT, + PROVIDER_MODEL_LIST_ROWS, RAIL_WIDTH, RAIL_TO_LIST_GAP, headerLineCount, @@ -37,13 +41,12 @@ function makeState(overrides: Partial): ModelPickerState { return { query: "", searchMode: false, - showAll: false, railEntries: [ { kind: "favorites", label: "Favorites" }, { kind: "recents", label: "Recents" }, { kind: "provider", provider: "claude", label: "Anthropic", authStatus: "ready", signInHint: null }, ], - railIndex: 2, + railIndex: 0, entries: [], providerTabs: [], providerTabIndex: 0, @@ -81,13 +84,13 @@ describe("rowWindow", () => { }); it("centers the focused row once the list overflows", () => { - // 20 entries, capacity 9, focus 10 -> half=4 -> start=6, end=15. - expect(rowWindow(20, 10, MODEL_LIST_ROWS)).toEqual({ start: 6, end: 15 }); + // 20 entries, capacity 5, focus 10 -> half=2 -> start=8, end=13. + expect(rowWindow(20, 10, MODEL_LIST_ROWS)).toEqual({ start: 8, end: 13 }); }); it("clamps the window to the end of the list", () => { // focus at the last index pins the window to the tail. - expect(rowWindow(20, 19, MODEL_LIST_ROWS)).toEqual({ start: 11, end: 20 }); + expect(rowWindow(20, 19, MODEL_LIST_ROWS)).toEqual({ start: 15, end: 20 }); }); }); @@ -96,12 +99,12 @@ describe("headerLineCount", () => { expect(headerLineCount(makeState({}))).toBe(1); }); - it("adds a line for the active model when it is in the list", () => { + it("does not add a header line for the active model (the '● now' line was removed)", () => { const state = makeState({ entries: [entry({ modelId: "anthropic/claude-opus-4-8" })], activeModelId: "anthropic/claude-opus-4-8", }); - expect(headerLineCount(state)).toBe(2); + expect(headerLineCount(state)).toBe(1); }); it("adds a sign-in line when the provider is unavailable", () => { @@ -114,7 +117,7 @@ describe("headerLineCount", () => { }); describe("modelPickerGeometry — list rows", () => { - it("places each entry on its own single line below the header + search", () => { + it("places each entry in a two-line block below the header + search", () => { const state = makeState({ entries: [ entry({ modelId: "a" }), @@ -131,17 +134,17 @@ describe("modelPickerGeometry — list rows", () => { const listTop = searchY + 2; expect(g.search).toEqual({ x: PANE_LEFT, y: searchY, w: PANE_WIDTH, h: 1 }); g.entries.forEach((e, index) => { - expect(e.rect.h).toBe(1); - expect(e.rect.y).toBe(listTop + index); + expect(e.rect.h).toBe(MODEL_ENTRY_HEIGHT); + expect(e.rect.y).toBe(listTop + (index * MODEL_ENTRY_HEIGHT)); // not searching -> list sits to the right of the rail. expect(e.rect.x).toBe(PANE_LEFT + RAIL_WIDTH + RAIL_TO_LIST_GAP); }); - // y increments by exactly 1 per row (no phantom 2-line drift). - expect(at(g.entries, 1).rect.y - at(g.entries, 0).rect.y).toBe(1); - expect(at(g.entries, 2).rect.y - at(g.entries, 1).rect.y).toBe(1); + // y increments by exactly the rendered row height. + expect(at(g.entries, 1).rect.y - at(g.entries, 0).rect.y).toBe(MODEL_ENTRY_HEIGHT); + expect(at(g.entries, 2).rect.y - at(g.entries, 1).rect.y).toBe(MODEL_ENTRY_HEIGHT); }); - it("keeps single-line rows even for sub-provider / unavailable entries", () => { + it("keeps fixed-height rows even for sub-provider / unavailable entries", () => { const state = makeState({ entries: [ entry({ modelId: "x", subProvider: "anthropic via OpenCode", isAvailable: true }), @@ -150,8 +153,8 @@ describe("modelPickerGeometry — list rows", () => { focusedIndex: 0, }); const g = geo(state); - expect(g.entries.every((e) => e.rect.h === 1)).toBe(true); - expect(at(g.entries, 1).rect.y - at(g.entries, 0).rect.y).toBe(1); + expect(g.entries.every((e) => e.rect.h === MODEL_ENTRY_HEIGHT)).toBe(true); + expect(at(g.entries, 1).rect.y - at(g.entries, 0).rect.y).toBe(MODEL_ENTRY_HEIGHT); }); it("windows a long scrolled list using MODEL_LIST_ROWS, mapping screen rows to true indices", () => { @@ -173,7 +176,23 @@ describe("modelPickerGeometry — list rows", () => { const listTop = PANE_TOP + headerLines + 1 + 2; const focusRect = g.entries.find((e) => e.index === focusedIndex); expect(focusRect).toBeDefined(); - expect(focusRect!.rect.y).toBe(listTop + (focusedIndex - window.start)); + expect(focusRect!.rect.y).toBe(listTop + ((focusedIndex - window.start) * MODEL_ENTRY_HEIGHT)); + }); + + it("uses one-line rows and a taller window inside a selected provider family", () => { + const entries: ModelPickerEntry[] = Array.from({ length: 12 }, (_v, i) => + entry({ modelId: `m${i}` }), + ); + const focusedIndex = 9; + const state = makeState({ railIndex: 2, entries, focusedIndex }); + const g = geo(state); + const window = rowWindow(entries.length, focusedIndex, PROVIDER_MODEL_LIST_ROWS); + expect(g.window).toEqual(window); + expect(g.entries.length).toBe(PROVIDER_MODEL_LIST_ROWS); + g.entries.forEach((e, index) => { + expect(e.rect.h).toBe(PROVIDER_MODEL_ENTRY_HEIGHT); + expect(e.rect.y).toBe(at(g.entries, 0).rect.y + index); + }); }); }); @@ -242,13 +261,13 @@ describe("modelPickerGeometry — settings footer + apply", () => { }); const g = geo(state); const listTop = PANE_TOP + 1 + 3; // header+mb+search+mb = +4, no selector. - // footer divider: listTop + MODEL_LIST_ROWS + (no more-line) + marginTop(1). - expect(g.footerTop).toBe(listTop + MODEL_LIST_ROWS + 1); + // footer divider: listTop + fixed list body + (no more-line) + marginTop(1). + expect(g.footerTop).toBe(listTop + MODEL_LIST_BODY_LINES + 1); expect(g.settings.length).toBe(2); - g.settings.forEach((s) => { + g.settings.forEach((s, index) => { // divider at footerTop, blank (chips Box marginTop) at footerTop+1, - // chips painted at footerTop+2. - expect(s.rect.y).toBe(g.footerTop + 2); + // settings painted vertically starting at footerTop+2. + expect(s.rect.y).toBe(g.footerTop + 2 + index); expect(s.rect.h).toBe(1); }); // provider/model rows are excluded from the footer chips. @@ -268,9 +287,8 @@ describe("modelPickerGeometry — settings footer + apply", () => { // Only the reasoning chip is a footer setting. expect(g.settings.map((s) => s.id)).toEqual(["right:model-picker:setting:reasoning"]); expect(g.apply).not.toBeNull(); - // chips at footerTop+2, blank (apply Box marginTop) at footerTop+3, - // [ Apply ] painted at footerTop+4 (chipsY + 2 when chips exist). - expect(g.apply!.y).toBe(g.footerTop + 2 + 2); + // settings start at footerTop+2; apply has its own margin after them. + expect(g.apply!.y).toBe(g.footerTop + 2 + 1 + 1); expect(g.apply!.h).toBe(1); }); @@ -284,7 +302,7 @@ describe("modelPickerGeometry — settings footer + apply", () => { expect(g.apply!.y).toBe(g.footerTop + 2); }); - it("shifts the footer down by one when a scroll indicator row is shown", () => { + it("keeps the footer stable when a long list is windowed", () => { const entries: ModelPickerEntry[] = Array.from({ length: 25 }, (_v, i) => entry({ modelId: `m${i}` }), ); @@ -292,7 +310,6 @@ describe("modelPickerGeometry — settings footer + apply", () => { const noScroll = makeState({ entries: [entry({ modelId: "a" })], settingsRows: [settingRow("reasoning")] }); const gMore = geo(withMore); const gNone = geo(noScroll); - // The scrolled case adds the "↑ n / ↓ n more" line, pushing the footer +1. - expect(gMore.footerTop).toBe(gNone.footerTop + 1); + expect(gMore.footerTop).toBe(gNone.footerTop); }); }); diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts index 87b6f5f3d..af8931bf8 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerGeometry.ts @@ -17,11 +17,19 @@ export type HitRect = { x: number; y: number; w: number; h: number }; // and the app.tsx hit-test useEffect calls `modelPickerGeometry()` to derive // rects from the same math. -/** Width (cols) of the vertical icon rail on the left of the model region. */ -export const RAIL_WIDTH = 4; +/** Width (cols) of the vertical rail on the left of the model region. */ +export const RAIL_WIDTH = 16; -/** Fixed number of visible model rows; the list windows/scrolls inside this. */ -export const MODEL_LIST_ROWS = 9; +/** Fixed number of visible model entries; the list windows/scrolls inside this. */ +export const MODEL_LIST_ROWS = 5; +export const PROVIDER_MODEL_LIST_ROWS = 8; + +/** Each model entry paints a title line plus a provider subtitle line. */ +export const MODEL_ENTRY_HEIGHT = 2; +export const PROVIDER_MODEL_ENTRY_HEIGHT = 1; + +/** Total terminal lines occupied by the fixed-height model list. */ +export const MODEL_LIST_BODY_LINES = MODEL_LIST_ROWS * MODEL_ENTRY_HEIGHT; /** * Columns between the rail and the model name, inside the bordered list box: @@ -54,9 +62,7 @@ export function rowWindow( /** Number of fixed header lines above the search row (variable by state). */ export function headerLineCount(state: ModelPickerState): number { let lines = 1; // "N models …" is always present. - if (state.activeModelId && state.entries.some((e) => e.modelId === state.activeModelId)) { - lines += 1; // "● now …" line. - } + // (The "● now" line was removed — the cursor arrow is the source of truth.) if (state.activeProviderAuthStatus === "unavailable" && state.activeProviderSignInHint) { lines += 1; // "Sign in: …" line. } @@ -73,6 +79,23 @@ export function hasSubProviderSelector(state: ModelPickerState): boolean { return !isSearching(state) && state.providerTabs.length > 1; } +export function usesCompactProviderRows(state: ModelPickerState): boolean { + if (isSearching(state)) return false; + return state.railEntries[state.railIndex]?.kind === "provider"; +} + +export function modelEntryHeightForState(state: ModelPickerState): number { + return usesCompactProviderRows(state) ? PROVIDER_MODEL_ENTRY_HEIGHT : MODEL_ENTRY_HEIGHT; +} + +export function modelListRowsForState(state: ModelPickerState): number { + return usesCompactProviderRows(state) ? PROVIDER_MODEL_LIST_ROWS : MODEL_LIST_ROWS; +} + +export function modelListBodyLinesForState(state: ModelPickerState): number { + return modelEntryHeightForState(state) * modelListRowsForState(state); +} + // Settings chip cell width — mirrors SettingsFooter's render EXACTLY so the // hit-rects and the painted chips share one source of truth: // focus/rail indicator (1) + "icon " (icon 1 + space 1 = 2) @@ -92,8 +115,6 @@ export type ModelPickerGeometry = { window: { start: number; end: number }; /** Search input row. */ search: HitRect; - /** Show-all toggle row (kept for parity with existing target). */ - showAll: HitRect; /** One rect per rail entry (empty while searching — rail is hidden). */ rail: GeometryRect[]; /** One rect per visible (windowed) model entry. */ @@ -149,16 +170,14 @@ export function modelPickerGeometry(input: GeometryInput): ModelPickerGeometry { ? paneWidth : Math.max(8, paneWidth - RAIL_WIDTH - RAIL_TO_LIST_GAP); - const window = rowWindow(state.entries.length, state.focusedIndex, MODEL_LIST_ROWS); + const entryHeight = modelEntryHeightForState(state); + const visibleRowCount = modelListRowsForState(state); + const listBodyLines = modelListBodyLinesForState(state); + const window = rowWindow(state.entries.length, state.focusedIndex, visibleRowCount); // Search row spans the full body width. const search: HitRect = { x: paneLeft, y: searchY, w: paneWidth, h: 1 }; - // Show-all toggle. Render does not draw a dedicated row for this anymore, but - // the keyboard/legacy target is harmless; pin it to the search row so it can - // never overlap a model row (zIndex keeps search on top where they coincide). - const showAll: HitRect = { x: paneLeft, y: searchY, w: paneWidth, h: 1 }; - // Rail: leftmost RAIL_WIDTH cols, one 1-line row per rail entry, starting at // the model region top. Hidden entirely while searching. const rail: GeometryRect[] = []; @@ -171,32 +190,34 @@ export function modelPickerGeometry(input: GeometryInput): ModelPickerGeometry { }); } - // Model entries: each is EXACTLY 1 line (matches ModelListRow), windowed. + // Model entries: each is EXACTLY entryHeight lines (matches ModelListRow), + // windowed. const entries: ModelPickerGeometry["entries"] = []; const favorites: ModelPickerGeometry["favorites"] = []; state.entries.slice(window.start, window.end).forEach((entry, sliceIndex) => { const index = window.start + sliceIndex; - const y = listTop + sliceIndex; + const y = listTop + (sliceIndex * entryHeight); entries.push({ id: `right:model-picker:entry:${entry.modelId}`, index, modelId: entry.modelId, - rect: { x: listLeft, y, w: listWidth, h: 1 }, + rect: { x: listLeft, y, w: listWidth, h: entryHeight }, }); - // Star hotspot is the first glyph cell(s) of the row. + // Star hotspot is the first glyph cell(s) on the title line. favorites.push({ modelId: entry.modelId, rect: { x: listLeft, y, w: 2, h: 1 }, }); }); - // Footer: after the fixed list block (always MODEL_LIST_ROWS tall) plus the - // optional "↑ n / ↓ n more" line, a marginTop (1) precedes the divider. - const hiddenBefore = window.start; - const hiddenAfter = state.entries.length - window.end; - const moreLine = hiddenBefore > 0 || hiddenAfter > 0 ? 1 : 0; + // Footer: after the fixed list block. The old "↑ n / ↓ n more" bookkeeping + // row was intentionally removed from the render because it read like another + // provider/status row; scrolling is still available through the cursor. // footerTop is the divider row; the marginTop pushes it down by 1. - let footerTop = listTop + MODEL_LIST_ROWS + moreLine + 1; + const modelRegionLines = searching + ? selectorLines + listBodyLines + : Math.max(state.railEntries.length, selectorLines + listBodyLines); + let footerTop = modelRegionTop + modelRegionLines + 1; // Keep the footer on-screen if the pane is short. footerTop = Math.min(Math.max(footerTop, listTop + 1), Math.max(1, rows - 1)); @@ -206,45 +227,30 @@ export function modelPickerGeometry(input: GeometryInput): ModelPickerGeometry { const settingRows = visibleRows.filter((row) => row.kind !== "apply"); const applyRow = visibleRows.find((row) => row.kind === "apply") ?? null; - // The chips Box has its OWN marginTop (1) below the divider (the divider sits - // at footerTop), so chips paint at footerTop+2. (footerTop already accounts - // for the footer Box's outer marginTop.) + // The settings list has its OWN marginTop (1) below the divider (the divider + // sits at footerTop), so rows paint at footerTop+2. The render is vertical, + // one setting per line. const chipsY = footerTop + 2; - // SIMULATE SettingsFooter's `flexWrap="wrap"` row: lay each natural-width chip - // left-to-right from paneLeft, wrapping to the next row when the next chip - // would overrun the pane. One rect per chip, keyed by kind, with the rendered - // cell width — so the painted chips and the click rects share one source. const settings: GeometryRect[] = []; - const paneRight = paneLeft + paneWidth; - let chipX = paneLeft; - let chipRowY = chipsY; - for (const row of settingRows) { + settingRows.forEach((row, index) => { const w = settingsChipWidth(row.label, row.value); - // Wrap before placing (unless this chip is the first on its row). - if (chipX > paneLeft && chipX + w > paneRight) { - chipRowY += 1; - chipX = paneLeft; - } settings.push({ id: `right:model-picker:setting:${row.kind}`, - rect: { x: chipX, y: chipRowY, w, h: 1 }, + rect: { x: paneLeft, y: chipsY + index, w: Math.min(paneWidth, Math.max(8, w)), h: 1 }, }); - chipX += w; - } + }); - // Apply button: its own marginTop (1) below the LAST (possibly wrapped) chip - // row — i.e. one blank row below it — or below the divider when there are no - // chips. Rendered as "[ Apply ]". + // Apply button: its own marginTop (1) below the last setting row, or directly + // below the divider when there are no settings. Rendered as "[ Apply ]". let apply: HitRect | null = null; if (applyRow) { - const applyY = settingRows.length ? chipRowY + 2 : footerTop + 2; + const applyY = settingRows.length ? chipsY + settingRows.length + 1 : footerTop + 2; apply = { x: paneLeft, y: applyY, w: Math.max(8, Math.min(paneWidth, 24)), h: 1 }; } return { window, search, - showAll, rail, entries, favorites, diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts index ab328557e..cef6c1d51 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.test.ts @@ -47,6 +47,66 @@ describe("buildModelPickerLayout", () => { expect(layout.entries.every((entry) => entry.family === "codex")).toBe(true); }); + it("shows static Anthropic rows immediately before the runtime catalog warms", () => { + const layout = buildModelPickerLayout({ + models: [modelInfo({ id: "openai/gpt-5", displayName: "GPT-5" })], + favorites: [], + recents: [], + activeModelId: null, + query: "", + selection: { kind: "provider", provider: "claude" }, + focusedIndex: 0, + searchMode: false, + }); + expect(layout.entries.length).toBeGreaterThan(0); + expect(layout.entries.every((entry) => entry.family === "claude")).toBe(true); + expect(layout.entries.some((entry) => entry.displayName.includes("Claude"))).toBe(true); + }); + + it("normalizes catalog provider aliases like anthropic into the Anthropic rail", () => { + const catalog: AgentChatModelCatalog = { + fetchedAt: "2026-05-29T00:00:00.000Z", + groups: [{ + key: "anthropic", + displayName: "Anthropic", + providers: [{ + key: "anthropic", + displayName: "Anthropic", + badgeColor: "#D97757", + modelCount: 1, + subsections: [{ + key: "default", + label: "Anthropic", + models: [{ + id: "anthropic/claude-sonnet-4-6", + runtimeModelId: "claude-sonnet-4-6", + provider: "anthropic", + providerKey: "anthropic", + groupKey: "anthropic", + displayName: "Claude Sonnet 4.6", + isDefault: true, + isAvailable: true, + }], + }], + }], + }], + }; + + const layout = buildModelPickerLayout({ + models: [], + catalog, + favorites: [], + recents: [], + activeModelId: null, + query: "", + selection: { kind: "provider", provider: "claude" }, + focusedIndex: 0, + searchMode: false, + }); + expect(layout.entries.some((entry) => entry.modelId === "anthropic/claude-sonnet-4-6")).toBe(true); + expect(layout.entries.every((entry) => entry.family === "claude")).toBe(true); + }); + it("orders recents by insertion order", () => { const layout = buildModelPickerLayout({ models, diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts index 2bf5b62e6..7e4cd19a6 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/modelPickerLayout.ts @@ -4,9 +4,12 @@ import type { AgentChatModelCatalog, AgentChatModelInfo } from "../../../../../d import type { AiSettingsStatus, AiRuntimeConnectionStatus } from "../../../../../desktop/src/shared/types/config"; import { getModelById, + getRuntimeModelRefForDescriptor, + listModelDescriptorsForProvider, resolveProviderGroupForModel, type ModelDescriptor, type ProviderFamily, + type ModelProviderGroup, } from "../../../../../desktop/src/shared/modelRegistry"; import type { AdeCodeProvider } from "../../types"; import type { @@ -27,10 +30,42 @@ const PROVIDER_LABELS: Record = { lmstudio: "LM Studio", }; +const PROVIDER_ORDER: readonly AdeCodeProvider[] = [ + "claude", + "codex", + "droid", + "cursor", + "opencode", + "ollama", + "lmstudio", +]; + +const RAIL_PROVIDER_ORDER: readonly AdeCodeProvider[] = PROVIDER_ORDER; +const STATIC_REGISTRY_FALLBACK_PROVIDERS: readonly ModelProviderGroup[] = ["claude", "codex"]; + function providerLabel(provider: AdeCodeProvider): string { return PROVIDER_LABELS[provider] ?? provider; } +function openCodeProviderLabel(providerId: string): string { + const normalized = providerId.trim().toLowerCase(); + const labels: Record = { + anthropic: "Anthropic", + claude: "Anthropic", + openai: "OpenAI", + google: "Google", + deepseek: "DeepSeek", + mistral: "Mistral", + xai: "xAI", + groq: "Groq", + together: "Together", + openrouter: "OpenRouter", + ollama: "Ollama", + lmstudio: "LM Studio", + }; + return labels[normalized] ?? providerId.trim().replace(/\b\w/g, (ch) => ch.toUpperCase()); +} + function providerSignInHint(provider: AdeCodeProvider): string { return `/login ${provider}`; } @@ -111,11 +146,16 @@ function normalizeProvider(value: ProviderFamily | string | undefined): AdeCodeP } function providerFromCatalogGroup(groupKey: string, fallbackFamily?: string): AdeCodeProvider { - if (groupKey === "claude" || groupKey === "codex" || groupKey === "opencode" || groupKey === "cursor" || groupKey === "droid") { - return groupKey; + const normalized = groupKey.trim().toLowerCase(); + if (normalized === "claude" || normalized === "codex" || normalized === "opencode" || normalized === "cursor" || normalized === "droid") { + return normalized; } - if (groupKey === "ollama" || groupKey === "lmstudio") return groupKey; - return normalizeProvider(fallbackFamily); + if (normalized === "ollama" || normalized === "lmstudio") return normalized; + return normalizeProvider(fallbackFamily ?? normalized); +} + +function modelAvailability(authStatus: ModelPickerAuthStatus, intrinsicAvailable = true): boolean { + return intrinsicAvailable && authStatus !== "unavailable"; } function descriptorFor(modelInfo: AgentChatModelInfo): ModelDescriptor | undefined { @@ -138,16 +178,21 @@ function entriesFromCatalog( if (seen.has(model.id)) continue; seen.add(model.id); const family = providerFromCatalogGroup(String(model.groupKey || group.key), model.family); + const authStatus = modelPickerProviderAuthStatus(aiStatus, family); entries.push({ modelId: model.id, runtimeModelId: model.runtimeModelId || model.id, displayName: model.displayName, family, - subProvider: model.providerName || provider.displayName || subsection.label || undefined, - subProviderKey: model.providerId || provider.key || subsection.key || undefined, + subProvider: family === "cursor" || family === "droid" + ? subsection.label || model.providerName || provider.displayName || undefined + : model.providerName || provider.displayName || subsection.label || undefined, + subProviderKey: family === "cursor" || family === "droid" + ? subsection.key || model.providerId || provider.key || undefined + : model.providerId || provider.key || subsection.key || undefined, isFavorite: favoritesSet.has(model.id), - isAvailable: model.isAvailable, - authStatus: modelPickerProviderAuthStatus(aiStatus, family), + isAvailable: modelAvailability(authStatus, model.isAvailable), + authStatus, reasoningLabel: activeReasoningEffort ? `think ${activeReasoningEffort}` : null, ...(model.serviceTiers?.length ? { serviceTiers: [...model.serviceTiers] } : {}), ...(model.cursorAvailability ? { cursorAvailability: { ...model.cursorAvailability } } : {}), @@ -159,6 +204,43 @@ function entriesFromCatalog( return entries; } +function entryFromDescriptor( + descriptor: ModelDescriptor, + favoritesSet: Set, + aiStatus?: AiSettingsStatus | null, + activeReasoningEffort?: string | null, +): ModelPickerEntry { + const registryProvider = resolveProviderGroupForModel(descriptor); + const provider = normalizeProvider(registryProvider); + const authStatus = modelPickerProviderAuthStatus(aiStatus, provider); + return { + modelId: descriptor.id, + runtimeModelId: getRuntimeModelRefForDescriptor(descriptor, registryProvider), + displayName: descriptor.displayName, + family: provider, + subProvider: providerLabel(provider), + subProviderKey: provider, + isFavorite: favoritesSet.has(descriptor.id), + isAvailable: modelAvailability(authStatus), + authStatus, + reasoningLabel: activeReasoningEffort ? `think ${activeReasoningEffort}` : null, + ...(descriptor.serviceTiers?.length ? { serviceTiers: [...descriptor.serviceTiers] } : {}), + ...(descriptor.cursorAvailability ? { cursorAvailability: { ...descriptor.cursorAvailability } } : {}), + }; +} + +function staticRegistryFallbackEntries( + favoritesSet: Set, + aiStatus?: AiSettingsStatus | null, + activeReasoningEffort?: string | null, +): ModelPickerEntry[] { + return STATIC_REGISTRY_FALLBACK_PROVIDERS.flatMap((provider) => + listModelDescriptorsForProvider(provider).map((descriptor) => + entryFromDescriptor(descriptor, favoritesSet, aiStatus, activeReasoningEffort) + ) + ); +} + function entryFromModelInfo( modelInfo: AgentChatModelInfo, favoritesSet: Set, @@ -167,22 +249,29 @@ function entryFromModelInfo( ): ModelPickerEntry { const modelId = modelInfo.modelId ?? modelInfo.id; const descriptor = descriptorFor(modelInfo); - const provider: AdeCodeProvider = descriptor - ? normalizeProvider(resolveProviderGroupForModel(descriptor)) + const registryProvider = descriptor ? resolveProviderGroupForModel(descriptor) : null; + const provider: AdeCodeProvider = registryProvider + ? normalizeProvider(registryProvider) : normalizeProvider(modelInfo.family); - const runtimeModelId = descriptor?.providerModelId ?? descriptor?.shortId ?? modelInfo.id; + const runtimeModelId = descriptor && registryProvider + ? getRuntimeModelRefForDescriptor(descriptor, registryProvider) + : modelInfo.id; const cursorAvailability = modelInfo.cursorAvailability ?? descriptor?.cursorAvailability; + const authStatus = modelPickerProviderAuthStatus(aiStatus, provider); return { modelId, runtimeModelId, displayName: modelInfo.displayName, family: provider, ...(descriptor?.openCodeProviderId - ? { subProvider: `${descriptor.openCodeProviderId} via OpenCode` } + ? { + subProvider: openCodeProviderLabel(descriptor.openCodeProviderId), + subProviderKey: descriptor.openCodeProviderId, + } : {}), isFavorite: favoritesSet.has(modelId), - isAvailable: true, - authStatus: modelPickerProviderAuthStatus(aiStatus, provider), + isAvailable: modelAvailability(authStatus), + authStatus, reasoningLabel: activeReasoningEffort ? `think ${activeReasoningEffort}` : null, ...(modelInfo.serviceTiers?.length ? { serviceTiers: [...modelInfo.serviceTiers] } : {}), ...(cursorAvailability ? { cursorAvailability: { ...cursorAvailability } } : {}), @@ -197,7 +286,6 @@ export type BuildLayoutInput = { activeModelId: string | null; activeReasoningEffort?: string | null; aiStatus?: AiSettingsStatus | null; - showAll?: boolean; settingsRows?: SetupPaneRow[]; footerFocus?: SetupPaneRowKind | null; laneLabel?: string | null; @@ -210,15 +298,31 @@ export type BuildLayoutInput = { export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerState { const favoritesSet = new Set(input.favorites); - const allEntries = input.catalog + const runtimeEntries = input.catalog ? entriesFromCatalog(input.catalog, favoritesSet, input.aiStatus, input.activeReasoningEffort) : input.models.map((m) => entryFromModelInfo(m, favoritesSet, input.aiStatus, input.activeReasoningEffort)); - const visibleEntries = input.showAll ? allEntries : allEntries.filter((entry) => entry.isAvailable); + const entriesById = new Map(); + for (const entry of staticRegistryFallbackEntries(favoritesSet, input.aiStatus, input.activeReasoningEffort)) { + entriesById.set(entry.modelId, entry); + } + for (const entry of runtimeEntries) { + entriesById.set(entry.modelId, entry); + } + const allEntries = [...entriesById.values()]; + // The TUI picker always shows the full provider catalog. Availability is + // represented on each row (dimmed/SIGN IN), matching desktop, instead of + // hiding models behind a separate "show all" switch. + const visibleEntries = allEntries; - // Providers actually present in the registry-filtered model list. - const providersPresent = Array.from( - new Set(allEntries.map((entry) => entry.family)), - ); + // Keep the family rail stable so Cursor/Droid/etc. do not disappear while a + // runtime is signed out, loading, or temporarily has no discovered models. + const providerSet = new Set([ + ...RAIL_PROVIDER_ORDER, + ...allEntries.map((entry) => entry.family), + ]); + const providersPresent = RAIL_PROVIDER_ORDER + .filter((provider) => providerSet.has(provider)) + .concat(Array.from(providerSet).filter((provider) => !RAIL_PROVIDER_ORDER.includes(provider))); const railEntries: ModelPickerRailEntry[] = [ { kind: "favorites", label: "Favorites" }, { kind: "recents", label: "Recents" }, @@ -355,7 +459,6 @@ export function buildModelPickerLayout(input: BuildLayoutInput): ModelPickerStat return { query: input.query, searchMode: input.searchMode, - showAll: input.showAll === true, railEntries, railIndex, entries, diff --git a/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts b/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts index e489f6ff9..3f7ea71ae 100644 --- a/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts +++ b/apps/ade-cli/src/tuiClient/components/ModelPicker/types.ts @@ -36,7 +36,6 @@ export type ModelPickerProviderTab = { export type ModelPickerState = { query: string; searchMode: boolean; - showAll: boolean; railEntries: ModelPickerRailEntry[]; railIndex: number; entries: ModelPickerEntry[]; diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 219964db7..327c70b6b 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -1425,6 +1425,7 @@ export function RightPane({ focused = false, width = DEFAULT_PANE_WIDTH, modelPickerInputs, + onModelPickerMeasureOrigin, scrollOffsetRows = 0, }: { content: RightPaneContent; @@ -1435,6 +1436,8 @@ export function RightPane({ activeProvider?: AdeCodeProvider | null; width?: number; scrollOffsetRows?: number; + /** Reports the model-picker's measured content origin for click hit-testing. */ + onModelPickerMeasureOrigin?: (origin: { x: number; y: number; width: number }) => void; /** Data passed in by app.tsx for the model-picker content kind. */ modelPickerInputs?: { models: AgentChatModelInfo[]; @@ -1590,11 +1593,10 @@ export function RightPane({ catalog: modelPickerInputs.catalog, favorites: modelPickerInputs.favorites, recents: modelPickerInputs.recents, - activeModelId: modelPickerInputs.activeModelId, - activeReasoningEffort: modelPickerInputs.activeReasoningEffort, - aiStatus: modelPickerInputs.aiStatus, - showAll: content.showAll, - settingsRows: content.settingsRows, + activeModelId: modelPickerInputs.activeModelId, + activeReasoningEffort: modelPickerInputs.activeReasoningEffort, + aiStatus: modelPickerInputs.aiStatus, + settingsRows: content.settingsRows, footerFocus: content.footerFocus ?? null, laneLabel: content.laneLabel ?? null, query: content.query, @@ -1604,6 +1606,8 @@ export function RightPane({ searchMode: content.searchMode, })} width={paneWidth} + railFocused={content.railFocused === true} + onMeasureOrigin={onModelPickerMeasureOrigin} /> ) : null} diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 812d1931a..dbd7385da 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -104,7 +104,6 @@ export type SetupPaneRowKind = | "codex-fast" | "output-style" | "refresh-status" - | "open-settings" | "apply"; export type SetupPaneRow = { @@ -143,10 +142,16 @@ export type ModelPickerRightPaneContent = { surface: "chat" | "new-chat"; query: string; searchMode: boolean; - showAll: boolean; selection: ModelPickerRightPaneSelection; providerTabKey?: string | null; focusedIndex: number; + /** + * Two-column focus within the selection area: `true` = the left category + * rail (favorites/recents/providers), `false` = the model list. ←/→ move + * focus between the two; ↑/↓ navigate within the focused column. Ignored + * while `footerFocus` is set (focus is then in the settings rows). + */ + railFocused?: boolean; footerFocus?: SetupPaneRowKind | null; settingsRows?: SetupPaneRow[]; laneId?: string | null; diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index f6c9b462d..99c827955 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -523,6 +523,8 @@ const PHONE_CRITICAL_CRR_TABLES = [ "terminal_sessions", "pull_requests", "pull_request_snapshots", + "model_picker_favorites", + "model_picker_recents", ] as const; function countTableRows(db: DatabaseSyncType, tableName: string): number { @@ -2944,6 +2946,29 @@ function migrate(db: MigrationDb) { db.run("create index if not exists idx_lane_worktree_locks_lane on lane_worktree_locks(lane_id)"); db.run("create index if not exists idx_lane_worktree_locks_session on lane_worktree_locks(owner_session_id)"); db.run("create index if not exists idx_lane_worktree_locks_expires on lane_worktree_locks(expires_at)"); + + // Model-picker favorites + recents. Per-project (the DB instance is the + // scope, so no project_id column is needed) and CRR-replicated so desktop, + // TUI, and iOS converge on the same set for a project. PK-only by design: + // CRR-converted tables cannot carry any UNIQUE index besides the primary key + // (`crsql_as_crr` rejects them with "Table … has unique indices besides the + // primary key. This is not allowed for CRRs"), so the model_id PK is the + // only uniqueness constraint and the recents cap is enforced in app code + // (see modelPickerStore.ts). `ensureCrrTables` auto-discovers these via + // `listEligibleCrrTables` (PK present, not excluded) and runs + // `crsql_as_crr` on each, so no explicit conversion call is needed here. + db.run(` + create table if not exists model_picker_favorites ( + model_id text primary key, + created_at text not null + ) + `); + db.run(` + create table if not exists model_picker_recents ( + model_id text primary key, + used_at text not null + ) + `); } function loadCrsqlite(db: DatabaseSyncType, extensionPath: string): void { diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 4ac390d5d..37f927ada 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2305,8 +2305,8 @@ struct AgentChatModelCatalog: Codable, Equatable { /// Response envelopes for the cross-surface ModelPicker favorites/recents /// RPC. Each method returns its own keyed wrapper (`{ favorites: [...] }` for /// favorites methods, `{ recents: [...] }` for recents methods, plus -/// `toggleFavorite` adds an `isFavorite` boolean). Persistence lives at -/// `~/.ade/modelPicker.json` on the ade-cli host; `MAX_RECENTS = 10`. +/// `toggleFavorite` adds an `isFavorite` boolean). Persistence lives in the +/// per-project cr-sqlite DB on the ade-cli host; `MAX_RECENTS = 10`. struct ModelPickerFavorites: Codable, Equatable { var favorites: [String] diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index c058111b7..15b7f2e38 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -2255,3 +2255,20 @@ create index if not exists idx_lane_worktree_locks_lane on lane_worktree_locks(l create index if not exists idx_lane_worktree_locks_session on lane_worktree_locks(owner_session_id); create index if not exists idx_lane_worktree_locks_expires on lane_worktree_locks(expires_at); + +-- Model-picker favorites + recents. Per-project (the DB instance is the scope, +-- so no project_id column) and CRR-replicated so desktop, TUI, and iOS converge +-- on the same set for a project. PK-only by design: CRR-converted tables cannot +-- carry any UNIQUE index besides the primary key, so model_id is the only +-- uniqueness constraint and the recents cap is enforced in app code. +-- ensureCrrTables auto-discovers these (PK present, not excluded) and runs +-- crsql_as_crr on each, mirroring desktop's kvDb.ts. +create table if not exists model_picker_favorites ( + model_id text primary key, + created_at text not null +); + +create table if not exists model_picker_recents ( + model_id text primary key, + used_at text not null +); diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 39f2c4c78..711a80e8f 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3964,10 +3964,9 @@ final class SyncService: ObservableObject { // MARK: - Cross-surface model picker (favorites + recents) // // Mirrors the desktop `useModelFavorites` / `useModelRecents` hooks and the - // TUI implementation. Backed by `~/.ade/modelPicker.json` on the ade-cli - // host so favorites and recents follow the user across worktrees, projects, - // and surfaces (desktop, TUI, iOS). Recents are capped at 10 server-side - // — the client should not pre-trim. + // TUI implementation. Backed by the per-project cr-sqlite DB on the ade-cli + // host so favorites and recents converge across desktop, TUI, and iOS via + // sync. Recents are capped at 10 server-side — the client should not pre-trim. func getModelFavorites() async throws -> [String] { let payload = try await sendDecodableCommand( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1e0fbd77c..72ebdac75 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -403,6 +403,10 @@ ade.agentChat.* # agent chat sessions, model inventory, parallel la # `{ mode: "cached"|"refresh-stale"|"force", refreshProvider?: "opencode"|"cursor"|"droid"|"lmstudio"|"ollama" }`) # and ade.agentChat.codex.* goal controls backed by # Codex app-server thread/goal RPCs. +modelPicker.* # cross-surface model favorites/recents backed by + # per-project CRR tables (`model_picker_favorites`, + # `model_picker_recents`) and shared by desktop, + # TUI, and iOS sync commands. ade.ai.* # AI integration status + provider auth (storeApiKey/deleteApiKey/getStatus/...). # ade.ai.isOpenCodeInstalled is a cheap probe (no runtime spin-up) # used to gate the ModelPicker OpenCode rail + Settings install CTA. @@ -505,7 +509,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `runtime/` | `tempCleanupService.ts`, `processRegistryService.ts`, `machineStateMigration.ts` | Runtime temp cleanup. `processRegistryService` is the per-process heartbeat registrar against machine-local `runtime_processes` (see §3.4); reconcile/dispose paths in `sessionService` and `ptyService` consult live and known owner sets before sweeping `terminal_sessions` rows so sibling processes and synced remote-machine owners are preserved. `machineStateMigration` carries one-shot migrations of the per-machine state files under `~/.ade/`. | | `sessions/` | `sessionService.ts`, `sessionDeltaService.ts` | Terminal session CRUD, post-session delta computation. | | `shared/` | `utils.ts`, `queueRebase.ts`, `packLegacyUtils.ts`, `transcriptInsights.ts` | Cross-domain utilities. | -| `state/` | `kvDb.ts`, `crsqliteExtension.ts`, `globalState.ts`, `projectState.ts`, `onConflictAudit.ts` | SQLite schema + open, CRR extension loader, global state file, per-project state init. `globalState.upsertRecentProject` accepts `preserveRecentOrder` so reactivating an already-known project (by app focus, deep link, etc.) refreshes its `lastOpenedAt` in place instead of jumping it to the front of the recents list. `AdeDb.sync.discardUnpublishedChangesForTables(tableNames)` lets a service clear local CRR state for specific tables without leaking those clears to sync peers — it records the cleared tables and `through_db_version` in the local-only `local_crr_change_suppressions` table, and `exportChangesSince` filters local-site rows for those tables at or below that version on the way out. The local-only excluded set (still kept out of replication) includes that suppression table itself, the snapshot caches, `pr_auto_link_ignores`, `pull_request_ai_summaries`, and `runtime_processes`. `crsql_changes` DELETE statements run through a helper that swallows the read-only-table error the cr-sqlite extension raises when a CRR-managed table is wiped, with a `db.crr_changes_cleanup_skipped` warn log instead of failing the migration. | +| `state/` | `kvDb.ts`, `crsqliteExtension.ts`, `globalState.ts`, `projectState.ts`, `onConflictAudit.ts` | SQLite schema + open, CRR extension loader, global state file, per-project state init. `globalState.upsertRecentProject` accepts `preserveRecentOrder` so reactivating an already-known project (by app focus, deep link, etc.) refreshes its `lastOpenedAt` in place instead of jumping it to the front of the recents list. `model_picker_favorites` and `model_picker_recents` are per-project CRR tables shared by desktop, TUI, and iOS; they are primary-key-only so CRR can convert them, with the recents cap enforced in `modelPickerStore.ts`. `AdeDb.sync.discardUnpublishedChangesForTables(tableNames)` lets a service clear local CRR state for specific tables without leaking those clears to sync peers — it records the cleared tables and `through_db_version` in the local-only `local_crr_change_suppressions` table, and `exportChangesSince` filters local-site rows for those tables at or below that version on the way out. The local-only excluded set (still kept out of replication) includes that suppression table itself, the snapshot caches, `pr_auto_link_ignores`, `pull_request_ai_summaries`, and `runtime_processes`. `crsql_changes` DELETE statements run through a helper that swallows the read-only-table error the cr-sqlite extension raises when a CRR-managed table is wiped, with a `db.crr_changes_cleanup_skipped` warn log instead of failing the migration. | | `sync/` | `syncService.ts`, `syncHostService.ts`, `syncPeerService.ts`, `syncRemoteCommandService.ts`, `syncProtocol.ts`, `deviceRegistryService.ts`, `syncPairingStore.ts` | **Thin delegation to the runtime daemon's sync host.** The authoritative sync host now lives in `apps/ade-cli/src/services/sync/`; the desktop main-process instances default to a non-host viewer role for legacy state and tests. The old in-process host is disabled unless `ADE_ENABLE_DESKTOP_SYNC_HOST=1` (diagnostics only). Wire formats — WebSocket envelope, remote command routing, device registry, pairing secrets — are the same across both implementations. Viewer joins clear the local `devices` + `sync_cluster_state` rows and then call `db.sync.discardUnpublishedChangesForTables(["devices", "sync_cluster_state"])` so the resulting DELETE rows do not leak back to other peers; the peer client follows up with `syncPeerService.acknowledgeLocalDbVersion()` to advance the outbound cursor past the suppressed range. | | `notifications/` | `apnsService.ts`, `apnsBridgeService.ts`, `notificationMapper.ts`, `notificationEventBus.ts` | APNs HTTP/2 client (ES256 JWT, key persisted via Electron `safeStorage` on the desktop or `EncryptedFileCredentialStore` under `.ade/secrets/` in the headless daemon), pure domain-event → `MappedNotification` mapping (13 categories / 4 families), event bus routing to APNs alert pushes + Live Activity update pushes + in-app WS delivery, filtered by per-device `NotificationPreferences`. `apnsBridgeService.ts` is the `notifications_apns` ADE action domain (`getStatus`, `saveConfig`, `uploadKey`, `clearKey`, `sendTestPush`) so the same Settings flow works whether the active project is local-bound or SSH-bound. | | `tests/` | `testService.ts` | Test-suite execution + run history. | diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index aae964abd..46828f791 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -54,8 +54,8 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `Drawer` (`visibleDrawerLaneCount` / `visibleDrawerChatCount`, `DrawerPrSummary` rows, lanes mode chat preview under the selected lane), `ChatView` (transcript renderer; exports `renderChatVisibleSelectionRows` / `renderChatSelectableRowTexts` / `selectedTextFromChatRows` for the ADE-owned mouse selection, plus `computeChatScrollMaxOffset` and `renderChatTranscriptPlainText`), `Header`, `RightPane` (`computeLaneChatCounts`, `LANE_DETAIL_PR_ACTION_INDEX`, wireframe `lane-details` STATUS/CHANGES/ACTIONS/PR/CHATS sections, Chat Info `chat-info`, `model-setup`), `SlashPalette`, `MentionPalette`, `ApprovalPrompt`, `ModelStatus`, `FooterControls`, and `TerminalPane` (xterm-headless preview pane that consumes `ChatTerminalPreviewResult` from `ade.terminal.preview` plus live `ade.pty.data` chunks to render a real terminal grid inside Ink; running Claude terminals can be put into direct control mode from the TUI). | | `apps/ade-cli/src/tuiClient/keybindings/index.ts` | Verbatim `~/.claude/keybindings.json` reader and TUI action dispatcher (chord support, vim namespace, clipboard-image paste hooks). Resolves `defaultKeybindingsPath()`, parses the Claude keybindings schema, and maps key sequences onto TUI actions. | | `apps/ade-cli/src/tuiClient/statusline/index.ts` | Claude-compatible status line config reader and runner. Reads the `~/.claude/statusline.json` contract, executes the configured status command, and exposes the rendered lines to `ModelStatus`. | -| `apps/ade-cli/src/tuiClient/components/ModelPicker/` | Ink ModelPicker pane: `ModelPickerPane.tsx` (rail + search + model rows), `modelPickerLayout.ts` (pure derivations — imports `modelOrdering` and `modelPickerSearch` from the desktop package so behaviour stays in lockstep with the renderer), and `types.ts` (`ModelPickerEntry`, `ModelPickerRailEntry`, `ModelPickerState`, plus `AdeCodeProvider` extensions for `ollama` / `lmstudio`). Reads the provider-grouped catalog via `getModelCatalog`, preserves runtime `serviceTiers` and Cursor `cursorAvailability` metadata for Fast Mode / chat-vs-CLI parity, and reads favorites / recents via the cross-surface `modelPicker.*` store. | -| `apps/ade-cli/src/services/modelPickerStore.ts` | Cross-surface (desktop + TUI + iOS) favorites and recents persisted at `~/.ade/modelPicker.json`. Schema is `{ version, favorites: string[], recents: string[] }`; `MAX_RECENTS` caps the recents list. Exposed through the top-level `modelPicker.getFavorites` / `setFavorites` / `toggleFavorite` / `getRecents` / `pushRecent` JSON-RPC methods on `adeRpcServer`. | +| `apps/ade-cli/src/tuiClient/components/ModelPicker/` | Ink ModelPicker pane: `ModelPickerPane.tsx` (provider/category rail + search + model rows), `modelPickerLayout.ts` (pure derivations — imports `modelOrdering` and `modelPickerSearch` from the desktop package so behaviour stays in lockstep with the renderer), `modelPickerGeometry.ts` (shared painted-row / click hit-test geometry), and `types.ts` (`ModelPickerEntry`, `ModelPickerRailEntry`, `ModelPickerState`, plus `AdeCodeProvider` extensions for `ollama` / `lmstudio`). Reads the provider-grouped catalog via `getModelCatalog`, preserves runtime `serviceTiers` and Cursor `cursorAvailability` metadata for Fast Mode / chat-vs-CLI parity, keeps the provider rail stable even when a runtime is signed out or empty, and reads favorites / recents via the cross-surface `modelPicker.*` store. | +| `apps/ade-cli/src/services/modelPickerStore.ts` | Cross-surface (desktop + TUI + iOS) favorites and recents stored in the per-project `ade.db` tables `model_picker_favorites` and `model_picker_recents`, with `~/.ade/modelPicker.json` imported once as a legacy migration source. `MAX_RECENTS` caps the recents list in app code because the CRR tables are primary-key-only. Exposed through the top-level `modelPicker.getFavorites` / `setFavorites` / `toggleFavorite` / `getRecents` / `pushRecent` JSON-RPC methods on `adeRpcServer` and through matching iOS sync commands. | | `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input, `AgentChatContextUsage`, `AgentChatClaudeOutputStyle`, `AgentChatClaudePlugin`, subagent kinds, `AgentChatModelCatalog*`). Imported per-module so ade-cli typecheck stays scoped. | | `apps/desktop/src/shared/modelRegistry.ts` | Default model selection for new sessions (`getDefaultModelDescriptor`). | | `apps/desktop/src/shared/adeLayout.ts` | Resolves project-scoped `.ade` paths. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 0602264b7..01eb70e33 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -287,7 +287,7 @@ power the TUI picker (`apps/ade-cli/src/tuiClient/components/ModelPicker/`). | Module | Role | |---|---| | `ModelPicker.tsx` | Trigger + popover entry point. Owns runtime-catalog loading via `runtimeCatalogCache`, fast-mode chip, and the favorites/recents fan-out. | -| `ModelPickerContent.tsx` | The popover body: search bar, rail, virtualized list (`@tanstack/react-virtual`), empty state. New props: `hidePermissionRail` (forward-compat hook for orchestrated surfaces that suppress permission-related affordances), `allowCliOnlyModels` (switch Cursor filtering from SDK chat models to CLI launch models), `allowRegistryExpansion` (when false, skip merging `MODEL_REGISTRY` entries into the runtime catalog — useful for constrained surfaces). Estimated row height `MODEL_ROW_ESTIMATED_HEIGHT = 44`. | +| `ModelPickerContent.tsx` | The popover body: search bar, rail, virtualized list (`@tanstack/react-virtual`), empty state. Props include `hidePermissionRail` (forward-compat hook for orchestrated surfaces that suppress permission-related affordances), `allowCliOnlyModels` (switch Cursor filtering from SDK chat models to CLI launch models), `allowRegistryExpansion` (when false, skip merging `MODEL_REGISTRY` entries into the runtime catalog — useful for constrained surfaces). Estimated row height `MODEL_ROW_ESTIMATED_HEIGHT = 44`. | | `ModelPickerRail.tsx` | Left-rail tabs (Favorites / Recents / per-provider groups). Reads `AuthStatus` per family to render auth gates and the OpenCode "Install OpenCode" CTA from `providerEmptyState`. | | `ModelListRow.tsx` | A single model row (favorite star, brand logo, display name, sub-provider chip, availability tone). | | `ReasoningEffortPicker.tsx` | Standalone reasoning-effort dropdown, mounted next to the model trigger and inside per-slot parallel-launch controls. | @@ -298,7 +298,7 @@ power the TUI picker (`apps/ade-cli/src/tuiClient/components/ModelPicker/`). | `runtimeCatalogCache.ts` | Renderer-side shared catalog cache. Tracks per-provider freshness (30 min for `opencode`/`cursor`/`droid`, 30 s for `lmstudio`/`ollama`) and dedupes concurrent `modelCatalog` requests by `${mode}:${refreshProvider}` keys. | | `useProviderAuthStatus.ts` | Resolves `AuthStatus` (`authenticated` / `missing` / `unknown`) per `ProviderFamily` from the AI integration status. | | `useAuthOnlyFilter.ts` | Hides models whose provider is not authenticated, with a toggle for the catalog browse mode. | -| `useModelFavorites.ts` / `useModelRecents.ts` | Cross-surface favorites and recents persisted to `~/.ade/modelPicker.json` via the `modelPicker.*` JSON-RPC methods on `adeRpcServer`. The TUI shares the same store. | +| `useModelFavorites.ts` / `useModelRecents.ts` | Cross-surface favorites and recents persisted to the per-project `ade.db` tables `model_picker_favorites` and `model_picker_recents` via the `modelPicker.*` JSON-RPC methods on `adeRpcServer`. Desktop, TUI, and iOS share the CRR-backed store; the legacy `~/.ade/modelPicker.json` file is only a one-time migration source. | | `usePerSurfaceModelDefaults.ts` | Per-surface default-model resolver (Settings, parallel slots, worker delegation, etc.) — keyed by surface so each call site can have its own remembered default. | | `useReasoningByFamily.ts` | Last-used reasoning effort per model family. | @@ -308,7 +308,10 @@ Renderer state and the TUI share descriptors and ordering: the TUI so behaviour stays in lockstep. The TUI layout also preserves `serviceTiers` and Cursor `cursorAvailability` from the same catalog so Fast Mode and chat-vs-CLI model availability do not drift between -desktop and `ade code`. +desktop and `ade code`. Its provider rail stays stable across auth and +runtime-loading states, always shows the full provider catalog with +unavailable rows dimmed, and uses separate rail/list focus so arrow-key +navigation matches the rendered two-column picker. ### Attachment handling diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 19a8d76c0..2f015778f 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -161,7 +161,7 @@ Canonical files (`apps/ade-cli/src/services/sync/`): - `syncRemoteCommandService.ts` (~2,840 lines) — command registry (lanes, chat, git, PR, sessions, conflicts, files, `prs.getMobileSnapshot`, `lanes.presence.*`, `work.runQuickCommand`, - `work.startCliSession`, …). Each registration carries a + `work.startCliSession`, `modelPicker.*`, …). Each registration carries a `SyncRemoteCommandDescriptor` with a **scope** label of `"runtime"` or `"project"`. The host rejects a `project`-scoped command when no project is open or when the caller did not bundle a @@ -169,6 +169,10 @@ Canonical files (`apps/ade-cli/src/services/sync/`): controller CLI launches resolve the target lane worktree before building provider argv/env so Agent Skill roots and `ADE_AGENT_SKILLS_DIRS` stay lane-aware. + Model-picker commands read/write the same per-project CRR-backed + favorites/recents store as desktop and the TUI; the sync host falls + back to the DB-wired shared store when no explicit accessor is + injected, so iOS never reads an empty process stub in production. Lane reparent commands parse the optional `stackBaseBranchRef` override and forward it to the host lane service so controllers can pick a specific branch to stack onto instead of always using the