-
Notifications
You must be signed in to change notification settings - Fork 4
Droid Updates #319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Droid Updates #319
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
ae5d04b
WIP: droid SDK pool + ModelPicker overhaul (safety snapshot)
arul28 6cbfe68
TUI ModelPicker right-pane overhaul + cross-surface favorites/recents…
arul28 45a4d39
Fix droid slash commands + TUI default model resolution
arul28 8217a9b
iOS ModelPicker overhaul + droidPermissionMode round-trip
arul28 47db1e5
Flush model picker store on process exit
arul28 1206e7d
ModelPicker: labeled 'Show all models' toggle + all-providers rail
arul28 229a305
Extract reasoning effort into standalone ReasoningEffortPicker
arul28 11c43b1
ModelPicker: canonical lists for dynamic providers + auth fixes + set…
arul28 27b3ab3
Warm OpenCode inventory cache on first getStatus peek miss
arul28 d65e432
ModelPicker: drop canonical preview lists; gate Ollama/LMStudio/OpenC…
arul28 adec472
ModelPicker: don't flash 'Install OpenCode' before auth status loads
arul28 0940580
ModelPicker: cheap OpenCode-installed probe + cleaner OpenCode rail
arul28 176ec5c
ship: prepare lane for review
arul28 34115ac
ship: iter 1 — fix test-desktop(8) timeout, address 13 CodeRabbit com…
arul28 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
| import { describe, expect, it } from "vitest"; | ||
| import { createModelPickerStore, MODEL_PICKER_MAX_RECENTS } from "./modelPickerStore"; | ||
|
|
||
| function tempFile(name: string): string { | ||
| return path.join(os.tmpdir(), `ade-model-picker-${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${name}`); | ||
| } | ||
|
|
||
| describe("modelPickerStore", () => { | ||
| it("starts empty when the persistence file is missing", () => { | ||
| const filePath = tempFile("missing.json"); | ||
| const store = createModelPickerStore({ filePath }); | ||
| expect(store.getFavorites()).toEqual([]); | ||
| expect(store.getRecents()).toEqual([]); | ||
| }); | ||
|
|
||
| it("toggleFavorite adds and removes entries and reports state", () => { | ||
| const filePath = tempFile("toggle.json"); | ||
| const store = createModelPickerStore({ filePath }); | ||
| const first = store.toggleFavorite("claude-opus-4-7"); | ||
| expect(first).toEqual({ favorites: ["claude-opus-4-7"], isFavorite: true }); | ||
| const second = store.toggleFavorite("gpt-5"); | ||
| expect(second.favorites).toEqual(["claude-opus-4-7", "gpt-5"]); | ||
| expect(second.isFavorite).toBe(true); | ||
| const third = store.toggleFavorite("claude-opus-4-7"); | ||
| expect(third).toEqual({ favorites: ["gpt-5"], isFavorite: false }); | ||
| }); | ||
|
|
||
| it("setFavorites dedupes, trims, and persists", () => { | ||
| const filePath = tempFile("set-favorites.json"); | ||
| const store = createModelPickerStore({ filePath }); | ||
| const result = store.setFavorites(["a", "a", " b ", "", "c"]); | ||
| expect(result).toEqual(["a", "b", "c"]); | ||
| const reloaded = createModelPickerStore({ filePath }); | ||
| expect(reloaded.getFavorites()).toEqual(["a", "b", "c"]); | ||
| }); | ||
|
|
||
| it("pushRecent prepends, dedupes, and caps at MAX_RECENTS", () => { | ||
| const filePath = tempFile("recents.json"); | ||
| const store = createModelPickerStore({ filePath }); | ||
| for (let i = 0; i < MODEL_PICKER_MAX_RECENTS + 5; i += 1) { | ||
| store.pushRecent(`model-${i}`); | ||
| } | ||
| const recents = store.getRecents(); | ||
| expect(recents).toHaveLength(MODEL_PICKER_MAX_RECENTS); | ||
| expect(recents[0]).toBe(`model-${MODEL_PICKER_MAX_RECENTS + 4}`); | ||
| // Re-pushing an existing entry should move it to head without growing the list. | ||
| const before = store.getRecents(); | ||
| const head = before[before.length - 1]; | ||
| expect(head).toBeDefined(); | ||
| if (!head) throw new Error("unreachable"); | ||
| const moved = store.pushRecent(head); | ||
| expect(moved[0]).toBe(head); | ||
| expect(moved).toHaveLength(MODEL_PICKER_MAX_RECENTS); | ||
| expect(new Set(moved).size).toBe(MODEL_PICKER_MAX_RECENTS); | ||
| }); | ||
|
|
||
| it("ignores empty/whitespace modelId in toggleFavorite and pushRecent", () => { | ||
| const filePath = tempFile("empty.json"); | ||
| const store = createModelPickerStore({ filePath }); | ||
| expect(store.toggleFavorite("")).toEqual({ favorites: [], isFavorite: false }); | ||
| expect(store.toggleFavorite(" ")).toEqual({ favorites: [], isFavorite: false }); | ||
| expect(store.pushRecent("")).toEqual([]); | ||
| }); | ||
|
|
||
| it("tolerates a malformed persistence file", () => { | ||
| const filePath = tempFile("malformed.json"); | ||
| fs.mkdirSync(path.dirname(filePath), { recursive: true }); | ||
| fs.writeFileSync(filePath, "this is not json", "utf8"); | ||
| const store = createModelPickerStore({ filePath }); | ||
| expect(store.getFavorites()).toEqual([]); | ||
| expect(store.getRecents()).toEqual([]); | ||
| }); | ||
|
|
||
| it("flushes pending recents so reload sees them", () => { | ||
| const filePath = tempFile("flush.json"); | ||
| const store = createModelPickerStore({ filePath }); | ||
| store.pushRecent("alpha"); | ||
| store.flush(); | ||
| const reloaded = createModelPickerStore({ filePath }); | ||
| expect(reloaded.getRecents()).toEqual(["alpha"]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| import fs from "node:fs"; | ||
| import os from "node:os"; | ||
| import path from "node:path"; | ||
|
|
||
| const MAX_RECENTS = 10; | ||
| const STORE_VERSION = 1; | ||
| const PERSIST_DEBOUNCE_MS = 250; | ||
|
|
||
| type PersistedShape = { | ||
| version: number; | ||
| favorites: string[]; | ||
| recents: string[]; | ||
| }; | ||
|
|
||
| export type ModelPickerStore = { | ||
| getFavorites: () => string[]; | ||
| setFavorites: (favorites: string[]) => string[]; | ||
| toggleFavorite: (modelId: string) => { favorites: string[]; isFavorite: boolean }; | ||
| getRecents: () => string[]; | ||
| pushRecent: (modelId: string) => string[]; | ||
| /** Flush any pending debounced write. Exposed for tests/teardown. */ | ||
| flush: () => void; | ||
| }; | ||
|
|
||
| export type CreateModelPickerStoreOptions = { | ||
| filePath?: string; | ||
| }; | ||
|
|
||
| function defaultFilePath(): string { | ||
| return path.join(os.homedir(), ".ade", "modelPicker.json"); | ||
| } | ||
|
|
||
| function sanitizeIdList(values: unknown): string[] { | ||
| if (!Array.isArray(values)) return []; | ||
| const seen = new Set<string>(); | ||
| const out: string[] = []; | ||
| for (const entry of values) { | ||
| if (typeof entry !== "string") continue; | ||
| const trimmed = entry.trim(); | ||
| if (!trimmed || seen.has(trimmed)) continue; | ||
| seen.add(trimmed); | ||
| out.push(trimmed); | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| function readPersisted(filePath: string): PersistedShape { | ||
| try { | ||
| const raw = fs.readFileSync(filePath, "utf8"); | ||
| const parsed = JSON.parse(raw) as unknown; | ||
| if (!parsed || typeof parsed !== "object") { | ||
| return { version: STORE_VERSION, favorites: [], recents: [] }; | ||
| } | ||
| const record = parsed as Record<string, unknown>; | ||
| return { | ||
| version: typeof record.version === "number" ? record.version : STORE_VERSION, | ||
| favorites: sanitizeIdList(record.favorites), | ||
| recents: sanitizeIdList(record.recents).slice(0, MAX_RECENTS), | ||
| }; | ||
| } catch { | ||
| return { version: STORE_VERSION, favorites: [], recents: [] }; | ||
| } | ||
| } | ||
|
|
||
| function writePersisted(filePath: string, state: PersistedShape): void { | ||
| try { | ||
| fs.mkdirSync(path.dirname(filePath), { recursive: true }); | ||
| const body = JSON.stringify(state, null, 2); | ||
| fs.writeFileSync(filePath, body, "utf8"); | ||
| } catch { | ||
| // best-effort — favorites/recents are convenience state, not load-bearing. | ||
| } | ||
| } | ||
|
|
||
| export function createModelPickerStore(options: CreateModelPickerStoreOptions = {}): ModelPickerStore { | ||
| const filePath = options.filePath ?? defaultFilePath(); | ||
| const state = readPersisted(filePath); | ||
|
|
||
| let persistTimer: ReturnType<typeof setTimeout> | null = null; | ||
| const persistSoon = () => { | ||
| if (persistTimer != null) return; | ||
| persistTimer = setTimeout(() => { | ||
| persistTimer = null; | ||
| writePersisted(filePath, { ...state, version: STORE_VERSION }); | ||
| }, PERSIST_DEBOUNCE_MS); | ||
| }; | ||
| const flush = () => { | ||
| if (persistTimer != null) { | ||
| clearTimeout(persistTimer); | ||
| persistTimer = null; | ||
| } | ||
| writePersisted(filePath, { ...state, version: STORE_VERSION }); | ||
| }; | ||
|
|
||
| // Flush pending debounced pushRecent on process exit so the latest model | ||
| // selection isn't lost when the user closes the TUI within the debounce window. | ||
| // exit handler runs synchronously and flush uses writeFileSync. | ||
| if (!options.filePath) { | ||
| process.once("exit", () => { | ||
| if (persistTimer != null) flush(); | ||
| }); | ||
| } | ||
|
|
||
| return { | ||
| getFavorites: () => state.favorites.slice(), | ||
| setFavorites: (favorites) => { | ||
| state.favorites = sanitizeIdList(favorites); | ||
| flush(); | ||
| return state.favorites.slice(); | ||
| }, | ||
| toggleFavorite: (modelId) => { | ||
| const id = typeof modelId === "string" ? modelId.trim() : ""; | ||
| if (!id) return { favorites: state.favorites.slice(), isFavorite: false }; | ||
| const idx = state.favorites.indexOf(id); | ||
| let isFavorite: boolean; | ||
| if (idx >= 0) { | ||
| state.favorites = [...state.favorites.slice(0, idx), ...state.favorites.slice(idx + 1)]; | ||
| isFavorite = false; | ||
| } else { | ||
| state.favorites = [...state.favorites, id]; | ||
| isFavorite = true; | ||
| } | ||
| flush(); | ||
| return { favorites: state.favorites.slice(), isFavorite }; | ||
| }, | ||
| getRecents: () => state.recents.slice(), | ||
| pushRecent: (modelId) => { | ||
| const id = typeof modelId === "string" ? modelId.trim() : ""; | ||
| if (!id) return state.recents.slice(); | ||
| const filtered = state.recents.filter((entry) => entry !== id); | ||
| state.recents = [id, ...filtered].slice(0, MAX_RECENTS); | ||
| persistSoon(); | ||
| return state.recents.slice(); | ||
| }, | ||
| flush, | ||
| }; | ||
| } | ||
|
|
||
| export const MODEL_PICKER_MAX_RECENTS = MAX_RECENTS; | ||
|
|
||
| // Process-wide singleton shared across the JSON-RPC server (adeRpcServer) and | ||
| // the sync host (syncRemoteCommandService) so favorites + recents stay | ||
| // consistent for desktop/TUI/iOS without coordination races. Lazy-init so | ||
| // tests can avoid touching the user's real ~/.ade/modelPicker.json by | ||
| // supplying their own store instance via `createModelPickerStore`. | ||
| let sharedStoreInstance: ModelPickerStore | null = null; | ||
| export function getSharedModelPickerStore(): ModelPickerStore { | ||
| if (!sharedStoreInstance) { | ||
| sharedStoreInstance = createModelPickerStore(); | ||
| } | ||
| return sharedStoreInstance; | ||
| } | ||
| export function resetSharedModelPickerStoreForTests(): void { | ||
| if (sharedStoreInstance) { | ||
| try { | ||
| sharedStoreInstance.flush(); | ||
| } catch { | ||
| // best-effort flush during teardown | ||
| } | ||
| } | ||
| sharedStoreInstance = null; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.