diff --git a/apps/tui/package.json b/apps/tui/package.json index 69579c2..89529bc 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -22,6 +22,7 @@ "dependencies": { "@crustjs/core": "^0.0.13", "@crustjs/plugins": "^0.0.16", + "@crustjs/store": "^0.0.4", "@techatnyu/ralphd": "workspace:*", "@opentui/core": "^0.1.77", "@opentui/react": "^0.1.77", diff --git a/apps/tui/src/cli.ts b/apps/tui/src/cli.ts index 1b540b6..b5e6fb8 100644 --- a/apps/tui/src/cli.ts +++ b/apps/tui/src/cli.ts @@ -9,6 +9,7 @@ import { waitUntilReady, } from "@techatnyu/ralphd"; import { runTui } from "./index"; +import { parseModelRef, ralphStore, setModelAndRecent } from "./lib/store"; async function requireDaemon(): Promise { const running = await daemon.isDaemonRunning(); @@ -107,6 +108,8 @@ const cli = new Crust("ralph") .run( withDaemon(async ({ args, flags }) => { const prompt = args.prompt.join(" ").trim(); + const stored = await ralphStore.read(); + const model = parseModelRef(stored.model); const result = await daemon.submitJob({ instanceId: flags.instance, session: flags.session @@ -115,6 +118,7 @@ const cli = new Crust("ralph") task: { type: "prompt", prompt, + model, }, }); printJson(result); @@ -292,6 +296,39 @@ const cli = new Crust("ralph") ), ), ), + ) + .command("model", (modelCommand) => + modelCommand + .meta({ description: "Manage model selection" }) + .command("set", (cmd) => + cmd + .meta({ description: "Set the active model" }) + .args([ + { + name: "model", + type: "string", + required: true, + description: + "Model in provider/model format (e.g. anthropic/claude-sonnet-4-5)", + }, + ]) + .run(async ({ args }) => { + const parsed = parseModelRef(args.model); + if (!parsed) { + throw new Error( + "Invalid model format. Use provider/model (e.g. anthropic/claude-sonnet-4-5)", + ); + } + await setModelAndRecent(args.model); + console.log(`Model set to: ${args.model}`); + }), + ) + .command("get", (cmd) => + cmd.meta({ description: "Show the active model" }).run(async () => { + const { model } = await ralphStore.read(); + console.log(model || "No model set (using SDK default)"); + }), + ), ); await cli.execute(); diff --git a/apps/tui/src/components/app.tsx b/apps/tui/src/components/app.tsx index 070236e..15d6c7f 100644 --- a/apps/tui/src/components/app.tsx +++ b/apps/tui/src/components/app.tsx @@ -1,5 +1,5 @@ import { basename } from "node:path"; -import { TextAttributes } from "@opentui/core"; +import { type SelectOption, TextAttributes } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; import type { DaemonJob, @@ -8,6 +8,7 @@ import type { } from "@techatnyu/ralphd"; import { daemon } from "@techatnyu/ralphd"; import { useCallback, useEffect, useState } from "react"; +import { ralphStore, setModelAndRecent } from "../lib/store"; import { Chat } from "./chat"; type View = @@ -20,6 +21,63 @@ interface DashboardData { jobs: DaemonJob[]; } +/** Provider IDs sorted by popularity — used to push well-known providers to the top. */ +const PROVIDER_PRIORITY: Record = { + anthropic: 0, + openai: 1, + google: 2, + openrouter: 3, +}; + +const SEPARATOR_VALUE = "__separator__"; + +async function fetchModelOptions(): Promise { + const [result, store] = await Promise.all([ + daemon.providerList({ refresh: true }), + ralphStore.read(), + ]); + const connected = new Set(result.connected); + const recentRefs = new Set(store.recentModels ?? []); + + // Build flat list of all connected models + const allModels: SelectOption[] = result.providers + .filter((provider) => connected.has(provider.id)) + .sort( + (a, b) => + (PROVIDER_PRIORITY[a.id] ?? 99) - (PROVIDER_PRIORITY[b.id] ?? 99) || + a.name.localeCompare(b.name), + ) + .flatMap((provider) => + Object.values(provider.models) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((model) => ({ + name: `${provider.name}/${model.name}`, + description: `${provider.id}/${model.id}`, + value: `${provider.id}/${model.id}`, + })), + ); + + // Build recent section from stored order, only including models that still exist + const allByRef = new Map(allModels.map((m) => [m.value, m])); + const recentOptions: SelectOption[] = (store.recentModels ?? []) + .filter((ref) => allByRef.has(ref)) + .map((ref) => allByRef.get(ref) as SelectOption); + + if (recentOptions.length === 0) return allModels; + + // Filter recents out of the "all" section to avoid duplicates + const restModels = allModels.filter( + (m) => !recentRefs.has(m.value as string), + ); + + return [ + { name: "── Recent ──", description: "", value: SEPARATOR_VALUE }, + ...recentOptions, + { name: "── All Models ──", description: "", value: SEPARATOR_VALUE }, + ...restModels, + ]; +} + interface AppProps { onQuit(): void; } @@ -56,16 +114,22 @@ function Dashboard({ const [error, setError] = useState(); const [data, setData] = useState(); const [selectedIndex, setSelectedIndex] = useState(0); + const [currentModel, setCurrentModel] = useState(""); + const [modelPicker, setModelPicker] = useState(false); + const [modelOptions, setModelOptions] = useState([]); + const [fetchingModels, setFetchingModels] = useState(false); const refresh = useCallback( async (nextIndex = selectedIndex) => { setLoading(true); setError(undefined); try { - const [health, instanceList] = await Promise.all([ + const [health, instanceList, storeState] = await Promise.all([ daemon.health(), daemon.listInstances(), + ralphStore.read(), ]); + setCurrentModel(storeState.model); const safeIndex = clampIndex(nextIndex, instanceList.instances.length); const selected = instanceList.instances[safeIndex]; const jobs = await daemon.listJobs( @@ -95,6 +159,13 @@ function Dashboard({ }, [refresh]); useKeyboard((key) => { + if (modelPicker) { + if (key.name === "escape" || key.name === "q") { + setModelPicker(false); + } + return; + } + if (key.name === "q" || (key.ctrl && key.name === "c")) { onQuit(); return; @@ -105,6 +176,22 @@ function Dashboard({ return; } + if (key.name === "m" && !fetchingModels) { + setFetchingModels(true); + void fetchModelOptions() + .then((options) => { + setModelOptions(options); + setModelPicker(true); + }) + .catch((err) => { + setError( + err instanceof Error ? err.message : "Failed to fetch models", + ); + }) + .finally(() => setFetchingModels(false)); + return; + } + if (!data) { return; } @@ -132,6 +219,36 @@ function Dashboard({ const selected = data?.instances[selectedIndex]; + if (modelPicker) { + return ( + + + Select Model + + {`${modelOptions.length} models available — esc: cancel`} + + +