Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
import { ChoiceConfirm, type ChoiceConfirmChoice } from "./ChoiceConfirm.js";
import { EditConfirm, type EditReviewChoice } from "./EditConfirm.js";
import { McpHub } from "./McpHub.js";
import { ModelPicker } from "./ModelPicker.js";
import { PlanCheckpointConfirm } from "./PlanCheckpointConfirm.js";
import { PlanConfirm, type PlanConfirmChoice } from "./PlanConfirm.js";
import { PlanRefineInput } from "./PlanRefineInput.js";
Expand Down Expand Up @@ -525,6 +526,8 @@ function AppInner({
const [sessionsPickerList, setSessionsPickerList] = useState<ReturnType<typeof listSessions>>([]);
/** Opens the unified McpHub modal — null when closed. `tab` selects the initial tab. */
const [pendingMcpHub, setPendingMcpHub] = useState<{ tab: "live" | "marketplace" } | null>(null);
/** True while the ModelPicker is open mid-chat (triggered by bare `/model`). */
const [pendingModelPicker, setPendingModelPicker] = useState(false);
// Stashed plan + intent while the user types free-form feedback
// (refinement or last instructions on approve). When the picker
// returns "refine" or "approve", we defer the loop-resume and show
Expand Down Expand Up @@ -588,6 +591,7 @@ function AppInner({
!!pendingReviseEditor ||
!!pendingSessionsPicker ||
!!pendingMcpHub ||
pendingModelPicker ||
!!stagedInput ||
!!pendingEditReview ||
walkthroughActive ||
Expand Down Expand Up @@ -2152,6 +2156,11 @@ function AppInner({
pushHistory(text);
return;
}
if (result.openModelPicker) {
setPendingModelPicker(true);
pushHistory(text);
return;
}
if (result.openArgPickerFor) {
pushHistory(text);
setInput(`/${result.openArgPickerFor} `);
Expand Down Expand Up @@ -3277,6 +3286,20 @@ function AppInner({
}
}}
/>
) : pendingModelPicker ? (
<ModelPicker
models={models}
current={loop.model}
onRefresh={refreshModels}
onChoose={(outcome) => {
setPendingModelPicker(false);
if (outcome.kind === "select") {
loop.configure({ model: outcome.id });
agentStore.dispatch({ type: "session.model.change", model: outcome.id });
log.pushInfo(`▸ model: ${outcome.id}`);
}
}}
/>
) : pendingMcpHub ? (
<McpHub
initialTab={pendingMcpHub.tab}
Expand Down
125 changes: 125 additions & 0 deletions src/cli/ui/ModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Box, Text, useStdout } from "ink";
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
import React, { useState } from "react";
import { useKeystroke } from "./keystroke-context.js";
import { PILL_MODEL, Pill, modelBadgeFor } from "./primitives/Pill.js";
import { FG, TONE } from "./theme/tokens.js";

export type ModelPickerOutcome = { kind: "select"; id: string } | { kind: "quit" };

export interface ModelPickerProps {
/** API-fetched ids; null means "still loading / offline". */
models: ReadonlyArray<string> | null;
/** Model id currently active in the loop — marked with the cursor on open. */
current: string;
onChoose: (outcome: ModelPickerOutcome) => void;
/** Triggers a refetch when the catalog is null/empty and the user presses [r]. */
onRefresh?: () => void;
}

const PAGE_MARGIN = 6;

export function ModelPicker({
models,
current,
onChoose,
onRefresh,
}: ModelPickerProps): React.ReactElement {
const list = (models && models.length > 0 ? models : FALLBACK_MODELS).slice();
if (!list.includes(current)) list.unshift(current);
const initialIndex = Math.max(0, list.indexOf(current));
const [focus, setFocus] = useState(initialIndex);
const { stdout } = useStdout();
const rows = stdout?.rows ?? 40;
const visibleCount = Math.max(3, rows - PAGE_MARGIN);

useKeystroke((ev) => {
if (ev.escape) return onChoose({ kind: "quit" });
if (ev.upArrow) return setFocus((f) => Math.max(0, f - 1));
if (ev.downArrow) return setFocus((f) => Math.min(list.length - 1, f + 1));
if (ev.return) {
const target = list[focus];
if (target) onChoose({ kind: "select", id: target });
return;
}
if (!ev.input) return;
if (ev.input === "q") return onChoose({ kind: "quit" });
if (ev.input === "r") onRefresh?.();
});

const start = Math.max(
0,
Math.min(focus - Math.floor(visibleCount / 2), list.length - visibleCount),
);
const end = Math.min(list.length, start + visibleCount);
const shown = list.slice(start, end);
const hiddenAbove = start;
const hiddenBelow = list.length - end;
const loading = models === null;
const empty = models !== null && models.length === 0;

return (
<Box flexDirection="column" marginY={1}>
<Box>
<Text bold color={TONE.brand}>
{" ◈ REASONIX · pick a model "}
</Text>
<Text color={FG.meta}>
{loading
? " · loading catalog…"
: empty
? " · catalog empty — using known fallbacks"
: ` · ${list.length} available`}
</Text>
</Box>
<Box height={1} />
{hiddenAbove > 0 ? (
<Box>
<Text color={FG.faint}>{` … ${hiddenAbove} earlier`}</Text>
</Box>
) : null}
{shown.map((id, i) => (
<ModelRow key={id} id={id} focused={start + i === focus} active={id === current} />
))}
{hiddenBelow > 0 ? (
<Box>
<Text color={FG.faint}>{` … ${hiddenBelow} more`}</Text>
</Box>
) : null}
<Box marginTop={1}>
<Text color={FG.faint}>{" ↑↓ pick · ⏎ confirm · [r] refresh · esc cancel"}</Text>
</Box>
</Box>
);
}

function ModelRow({
id,
focused,
active,
}: {
id: string;
focused: boolean;
active: boolean;
}): React.ReactElement {
const badge = modelBadgeFor(id);
return (
<Box>
<Text color={focused ? TONE.brand : FG.faint}>{focused ? " ▸ " : " "}</Text>
<Text bold={focused} color={focused ? FG.strong : FG.sub}>
{id.padEnd(24)}
</Text>
<Text> </Text>
<Pill label={badge.label} {...PILL_MODEL[badge.kind]} bold={false} />
{active ? <Text color={TONE.brand}>{" · current"}</Text> : null}
</Box>
);
}

/** Hard-coded known DeepSeek ids — used when the API catalog hasn't loaded yet so the picker isn't empty on first open. */
const FALLBACK_MODELS: ReadonlyArray<string> = [
"deepseek-v4-flash",
"deepseek-v4-pro",
"deepseek-chat",
"deepseek-reasoner",
];
3 changes: 1 addition & 2 deletions src/cli/ui/slash/handlers/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ const model: SlashHandler = (args, loop, ctx) => {
const id = args[0];
const known = ctx.models ?? null;
if (!id) {
const hint = known && known.length > 0 ? known.join(" | ") : t("handlers.model.modelHint");
return { info: t("handlers.model.modelUsage", { hint }) };
return { openModelPicker: true };
}
loop.configure({ model: id });
ctx.dispatch?.({ type: "session.model.change", model: id });
Expand Down
2 changes: 2 additions & 0 deletions src/cli/ui/slash/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface SlashResult {
info?: string;
/** Open the SessionPicker modal mid-chat — used by `/sessions` slash. */
openSessionsPicker?: boolean;
/** Open the ModelPicker modal — bare `/model` (no id) opens it. */
openModelPicker?: boolean;
/** Open the unified MCP hub — `/mcp` defaults to "live", `/mcp browse` to "marketplace". */
openMcpHub?: { tab: "live" | "marketplace" };
/** Open the arg-completer picker for this command (e.g. `/language` → language picker). */
Expand Down
4 changes: 2 additions & 2 deletions tests/slash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,12 @@ describe("handleSlash", () => {
expect(r.info).toMatch(/\/models/);
});

it("/model with no arg and loaded list hints at available ids", () => {
it("/model with no arg opens the interactive picker (#371)", () => {
const loop = makeLoop();
const r = handleSlash("model", [], loop, {
models: ["deepseek-chat", "deepseek-reasoner"],
});
expect(r.info).toMatch(/deepseek-chat \| deepseek-reasoner/);
expect(r.openModelPicker).toBe(true);
});

it("/models renders the fetched catalog and marks the current one", () => {
Expand Down
76 changes: 76 additions & 0 deletions tests/ui-model-picker.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { render } from "ink";
import React from "react";
import { describe, expect, it } from "vitest";
import { ModelPicker } from "../src/cli/ui/ModelPicker.js";
import { makeFakeStdin, makeFakeStdout } from "./helpers/ink-stdio.js";

function renderPicker(props: {
models: ReadonlyArray<string> | null;
current: string;
}): string {
const stdout = makeFakeStdout();
const { unmount } = render(
React.createElement(ModelPicker, {
models: props.models,
current: props.current,
onChoose: () => {},
}),
{ stdout: stdout as never, stdin: makeFakeStdin() as never },
);
unmount();
return stdout.text();
}

describe("ModelPicker (#371)", () => {
it("lists API models when the catalog has loaded", () => {
const text = renderPicker({
models: ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-reasoner"],
current: "deepseek-v4-flash",
});
expect(text).toContain("deepseek-v4-flash");
expect(text).toContain("deepseek-v4-pro");
expect(text).toContain("deepseek-reasoner");
});

it("marks the current model with a `current` tag", () => {
const text = renderPicker({
models: ["deepseek-v4-flash", "deepseek-v4-pro"],
current: "deepseek-v4-pro",
});
expect(text).toMatch(/deepseek-v4-pro[\s\S]*current/);
});

it("shows loading hint when catalog is null", () => {
const text = renderPicker({ models: null, current: "deepseek-v4-flash" });
expect(text).toContain("loading catalog");
});

it("falls back to the known DeepSeek ids when catalog is null so the picker isn't empty on first open", () => {
const text = renderPicker({ models: null, current: "deepseek-v4-flash" });
expect(text).toContain("deepseek-v4-flash");
expect(text).toContain("deepseek-v4-pro");
});

it("shows the explicit empty hint when catalog loaded but is empty", () => {
const text = renderPicker({ models: [], current: "deepseek-v4-flash" });
expect(text).toContain("catalog empty");
});

it("includes the current id in the list even when API didn't return it (handles stale catalog)", () => {
const text = renderPicker({
models: ["deepseek-v4-flash"],
current: "deepseek-experimental-x",
});
expect(text).toContain("deepseek-experimental-x");
});

it("renders the keybind hint footer", () => {
const text = renderPicker({
models: ["deepseek-v4-flash"],
current: "deepseek-v4-flash",
});
expect(text).toContain("↑↓");
expect(text).toContain("⏎");
expect(text).toContain("esc");
});
});
Loading