Skip to content
1 change: 1 addition & 0 deletions apps/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions apps/tui/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
waitUntilReady,
} from "@techatnyu/ralphd";
import { runTui } from "./index";
import { parseModelRef, ralphStore, setModelAndRecent } from "./lib/store";

async function requireDaemon(): Promise<void> {
const running = await daemon.isDaemonRunning();
Expand Down Expand Up @@ -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
Expand All @@ -115,6 +118,7 @@ const cli = new Crust("ralph")
task: {
type: "prompt",
prompt,
model,
},
});
printJson(result);
Expand Down Expand Up @@ -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();
126 changes: 122 additions & 4 deletions apps/tui/src/components/app.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 =
Expand All @@ -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<string, number> = {
anthropic: 0,
openai: 1,
google: 2,
openrouter: 3,
};

const SEPARATOR_VALUE = "__separator__";

async function fetchModelOptions(): Promise<SelectOption[]> {
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;
}
Expand Down Expand Up @@ -56,16 +114,22 @@ function Dashboard({
const [error, setError] = useState<string>();
const [data, setData] = useState<DashboardData>();
const [selectedIndex, setSelectedIndex] = useState(0);
const [currentModel, setCurrentModel] = useState("");
const [modelPicker, setModelPicker] = useState(false);
const [modelOptions, setModelOptions] = useState<SelectOption[]>([]);
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(
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -132,6 +219,36 @@ function Dashboard({

const selected = data?.instances[selectedIndex];

if (modelPicker) {
return (
<box flexDirection="column" flexGrow={1} padding={1}>
<box flexDirection="column" marginBottom={1}>
<text attributes={TextAttributes.BOLD}>Select Model</text>
<text attributes={TextAttributes.DIM}>
{`${modelOptions.length} models available — esc: cancel`}
</text>
</box>
<select
focused
flexGrow={1}
options={modelOptions}
showDescription
showScrollIndicator
wrapSelection
onSelect={(_index, option) => {
if (option?.value && option.value !== SEPARATOR_VALUE) {
const modelRef = option.value as string;
void setModelAndRecent(modelRef).then(() => {
setCurrentModel(modelRef);
setModelPicker(false);
});
}
}}
/>
</box>
);
}

return (
<box flexDirection="column" flexGrow={1} padding={1}>
<box flexDirection="column" marginBottom={1}>
Expand All @@ -145,7 +262,7 @@ function Dashboard({
</text>
<text attributes={TextAttributes.DIM}>
{data
? `${data.health.running} running, ${data.health.queued} queued`
? `${data.health.running} running, ${data.health.queued} queued | Model: ${currentModel || "default"}`
: (error ?? "No data available")}
</text>
</box>
Expand Down Expand Up @@ -199,7 +316,8 @@ function Dashboard({

<box flexDirection="column" marginTop={1}>
<text attributes={TextAttributes.DIM}>
{error ?? "j/k or arrows: select enter: chat r: refresh q: quit"}
{error ??
"j/k or arrows: select enter: chat m: model r: refresh q: quit"}
</text>
</box>
</box>
Expand Down
1 change: 0 additions & 1 deletion apps/tui/src/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ interface ChatProps {
const JOB_POLL_INTERVAL_MS = 500;

async function waitForJob(jobId: string): Promise<DaemonJob> {
// biome-ignore lint/correctness/noConstantCondition: polling loop
while (true) {
const result = await daemon.getJob(jobId);
if (
Expand Down
39 changes: 39 additions & 0 deletions apps/tui/src/lib/store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { configDir, createStore } from "@crustjs/store";

export const ralphStore = createStore({
dirPath: configDir("ralph"),
fields: {
model: { type: "string", default: "" },
recentModels: { type: "string", array: true, default: [] as string[] },
},
});

const RECENT_MODELS_LIMIT = 5;

/**
* Set the active model and push it to the front of recentModels in a single atomic write.
*/
export async function setModelAndRecent(modelRef: string): Promise<void> {
await ralphStore.update((current) => ({
...current,
model: modelRef,
recentModels: [
modelRef,
...(current.recentModels ?? []).filter((m) => m !== modelRef),
].slice(0, RECENT_MODELS_LIMIT),
}));
}

/**
* Parse a "provider/model" string into { providerId, modelId }.
* Handles models with slashes (e.g. "openrouter/openai/gpt-5").
*/
export function parseModelRef(
model: string,
): { providerId: string; modelId: string } | undefined {
if (!model) return undefined;
const [providerId, ...rest] = model.split("/");
const modelId = rest.join("/");
if (!providerId || !modelId) return undefined;
return { providerId, modelId };
}
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions packages/daemon/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager {
return undefined;
},
},
provider: {
list: async () => ({
providers: [],
connected: [],
}),
},
async ping() {
return true;
},
},
server: {
url: `fake://${instanceId}`,
Expand All @@ -150,4 +159,8 @@ export class FakeOpencodeRegistry implements OpencodeRuntimeManager {
async stopAll(): Promise<void> {
this.runtimes.clear();
}

async queryProviders(_directory?: string, _refresh?: boolean) {
return { providers: [], connected: [] };
}
}
6 changes: 6 additions & 0 deletions packages/daemon/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ export class DaemonClient {
>;
}

providerList(params: ParamsByMethod<"provider.list"> = {}) {
return send(this.socketPath, "provider.list", params, 30_000) as Promise<
ResultByMethod<"provider.list">
>;
}

submitJob(params: ParamsByMethod<"job.submit">) {
return send(this.socketPath, "job.submit", params) as Promise<
ResultByMethod<"job.submit">
Expand Down
Loading
Loading