mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bef219d31 | |||
| 506b2fd958 | |||
| a60a408374 | |||
| 00ffea0c38 | |||
| 1ac089ae76 | |||
| 310bd67149 | |||
| 466e87e78b | |||
| 64399c1589 | |||
| f28eb8fd8a | |||
| 9734688c8b | |||
| 6d82700d41 | |||
| 63d411d91f | |||
| e2150cd73a | |||
| 9ab8a55414 | |||
| d07a147964 | |||
| 662552cded |
@@ -0,0 +1,178 @@
|
||||
import type {
|
||||
ClineRecommendedModel,
|
||||
ClineRecommendedModelsData,
|
||||
} from "@cline/core";
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelsEntry = {
|
||||
kind: "model";
|
||||
tier: ClineModelTier;
|
||||
model: ClineRecommendedModel;
|
||||
};
|
||||
export type ClineModelPickerEntry = ClineModelsEntry | ClineModelPickerBrowse;
|
||||
export type ClineModelTier = "recommended" | "free" | "clinePass";
|
||||
export type ClineModelProviderId = "cline" | "cline-pass";
|
||||
|
||||
export interface BuildClineModelEntriesOptions {
|
||||
includeClinePass?: boolean;
|
||||
}
|
||||
|
||||
export function resolveClineModelDisplayName(
|
||||
modelId: string,
|
||||
knownModels?: Record<string, unknown>,
|
||||
): string {
|
||||
if (knownModels) {
|
||||
const candidates = [modelId, modelId.split("/").pop()];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
}
|
||||
|
||||
export function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
options: BuildClineModelEntriesOptions = {},
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [
|
||||
...(options.includeClinePass
|
||||
? data.clinePass.map((model) => ({
|
||||
kind: "model" as const,
|
||||
tier: "clinePass" as const,
|
||||
model,
|
||||
}))
|
||||
: []),
|
||||
...data.recommended.map((model) => ({
|
||||
kind: "model" as const,
|
||||
tier: "recommended" as const,
|
||||
model,
|
||||
})),
|
||||
...data.free.map((model) => ({
|
||||
kind: "model" as const,
|
||||
tier: "free" as const,
|
||||
model,
|
||||
})),
|
||||
{
|
||||
kind: "browse",
|
||||
},
|
||||
];
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function resolveClineModelProviderId(
|
||||
tier: ClineModelTier,
|
||||
): ClineModelProviderId {
|
||||
return tier === "clinePass" ? "cline-pass" : "cline";
|
||||
}
|
||||
|
||||
export function resolveClineModelEntryProviderId(
|
||||
entry: ClineModelPickerEntry,
|
||||
): ClineModelProviderId | undefined {
|
||||
return entry.kind === "model"
|
||||
? resolveClineModelProviderId(entry.tier)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export type ClineModelPickerDisplayRow =
|
||||
| {
|
||||
kind: "model";
|
||||
key: string;
|
||||
label: string;
|
||||
tags: string[];
|
||||
isCurrent: boolean;
|
||||
entryIndex: number;
|
||||
}
|
||||
| {
|
||||
kind: "browse";
|
||||
key: string;
|
||||
label: string;
|
||||
entryIndex: number;
|
||||
};
|
||||
|
||||
export function buildClineModelPickerDisplayRows(
|
||||
entries: ClineModelPickerEntry[],
|
||||
knownModels?: Record<string, unknown>,
|
||||
currentModelId?: string,
|
||||
): ClineModelPickerDisplayRow[] {
|
||||
const rows: ClineModelPickerDisplayRow[] = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.kind === "model") {
|
||||
rows.push({
|
||||
kind: "model",
|
||||
key: `${entry.tier}-${entry.model.id}-${i}`,
|
||||
label: resolveClineModelDisplayName(entry.model.id, knownModels),
|
||||
tags: entry.model.tags,
|
||||
isCurrent: currentModelId === entry.model.id,
|
||||
entryIndex: i,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
kind: "browse",
|
||||
key: "browse-all",
|
||||
label: "Browse all models...",
|
||||
entryIndex: i,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function getClineModelPickerDisplayRowsWindow(
|
||||
rows: ClineModelPickerDisplayRow[],
|
||||
selected: number,
|
||||
maxVisibleRows: number,
|
||||
) {
|
||||
if (rows.length <= maxVisibleRows) {
|
||||
return {
|
||||
visibleRows: rows,
|
||||
aboveCount: 0,
|
||||
belowCount: 0,
|
||||
showAbove: false,
|
||||
showBelow: false,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedRowIndex = Math.max(0, selected);
|
||||
let visibleLimit = maxVisibleRows;
|
||||
let start = 0;
|
||||
let end = rows.length;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const showAbove = start > 0;
|
||||
const showBelow = end < rows.length;
|
||||
visibleLimit = Math.max(
|
||||
1,
|
||||
maxVisibleRows - (showAbove ? 1 : 0) - (showBelow ? 1 : 0),
|
||||
);
|
||||
start = Math.max(0, selectedRowIndex - Math.floor(visibleLimit / 2));
|
||||
if (start + visibleLimit > rows.length) {
|
||||
start = Math.max(0, rows.length - visibleLimit);
|
||||
}
|
||||
end = Math.min(rows.length, start + visibleLimit);
|
||||
}
|
||||
|
||||
const aboveCount = start;
|
||||
const belowCount = rows.length - end;
|
||||
|
||||
return {
|
||||
visibleRows: rows.slice(start, end),
|
||||
aboveCount,
|
||||
belowCount,
|
||||
showAbove: aboveCount > 0,
|
||||
showBelow: belowCount > 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ClineRecommendedModelsData } from "@cline/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
buildClineModelPickerDisplayRows,
|
||||
getClineModelPickerDisplayRowsWindow,
|
||||
resolveClineModelProviderId,
|
||||
} from "./cline-model-picker-utils";
|
||||
|
||||
const data: ClineRecommendedModelsData = {
|
||||
clinePass: [
|
||||
{
|
||||
id: "cline-pass/glm-5.1",
|
||||
name: "GLM 5.1",
|
||||
description: "Cline Pass model",
|
||||
tags: ["PASS"],
|
||||
},
|
||||
],
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
description: "Recommended Claude model",
|
||||
tags: ["BEST"],
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.3-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
description: "Recommended Codex model",
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
free: [
|
||||
{
|
||||
id: "deepseek/deepseek-chat",
|
||||
name: "DeepSeek Chat",
|
||||
description: "Free DeepSeek model",
|
||||
tags: ["FREE"],
|
||||
},
|
||||
{
|
||||
id: "qwen/qwen3-coder",
|
||||
name: "Qwen3 Coder",
|
||||
description: "Free Qwen model",
|
||||
tags: ["FREE"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("cline model picker helpers", () => {
|
||||
it("builds tiered entries with browse action at the end while hiding Cline Pass by default", () => {
|
||||
expect(buildClineModelEntries(data)).toMatchObject([
|
||||
{
|
||||
kind: "model",
|
||||
tier: "recommended",
|
||||
model: { id: "anthropic/claude-sonnet-4.6" },
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
tier: "recommended",
|
||||
model: { id: "openai/gpt-5.3-codex" },
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
tier: "free",
|
||||
model: { id: "deepseek/deepseek-chat" },
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
tier: "free",
|
||||
model: { id: "qwen/qwen3-coder" },
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes Cline Pass models when enabled", () => {
|
||||
expect(
|
||||
buildClineModelEntries(data, { includeClinePass: true }).slice(0, 2),
|
||||
).toMatchObject([
|
||||
{
|
||||
kind: "model",
|
||||
tier: "clinePass",
|
||||
model: { id: "cline-pass/glm-5.1" },
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
tier: "recommended",
|
||||
model: { id: "anthropic/claude-sonnet-4.6" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps Cline Pass models to the cline-pass provider", () => {
|
||||
expect(resolveClineModelProviderId("clinePass")).toBe("cline-pass");
|
||||
expect(resolveClineModelProviderId("recommended")).toBe("cline");
|
||||
expect(resolveClineModelProviderId("free")).toBe("cline");
|
||||
});
|
||||
|
||||
it("builds flat display rows for every picker entry", () => {
|
||||
const entries = buildClineModelEntries(data, { includeClinePass: true });
|
||||
const rows = buildClineModelPickerDisplayRows(entries);
|
||||
|
||||
expect(
|
||||
rows.map((row) =>
|
||||
row.kind === "model"
|
||||
? { kind: row.kind, entryIndex: row.entryIndex }
|
||||
: row,
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "model", entryIndex: 0 },
|
||||
{ kind: "model", entryIndex: 1 },
|
||||
{ kind: "model", entryIndex: 2 },
|
||||
{ kind: "model", entryIndex: 3 },
|
||||
{ kind: "model", entryIndex: 4 },
|
||||
{
|
||||
kind: "browse",
|
||||
key: "browse-all",
|
||||
label: "Browse all models...",
|
||||
entryIndex: 5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("windows flat display rows around selected indexes", () => {
|
||||
const entries = buildClineModelEntries(data);
|
||||
const rows = buildClineModelPickerDisplayRows(entries);
|
||||
|
||||
const window = getClineModelPickerDisplayRowsWindow(rows, 3, 3);
|
||||
expect(
|
||||
window.visibleRows.some(
|
||||
(row) => row.kind === "model" && row.entryIndex === 3,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,33 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
|
||||
import {
|
||||
type ClineRecommendedModel,
|
||||
type ClineRecommendedModelsData,
|
||||
fetchClineRecommendedModels,
|
||||
} from "@cline/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
buildClineModelPickerDisplayRows,
|
||||
type ClineModelPickerDisplayRow,
|
||||
type ClineModelPickerEntry,
|
||||
getClineModelPickerDisplayRowsWindow,
|
||||
} from "./cline-model-picker-utils";
|
||||
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: "recommended" | "free";
|
||||
}
|
||||
export {
|
||||
buildClineModelEntries,
|
||||
buildClineModelPickerDisplayRows,
|
||||
type ClineModelPickerDisplayRow,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelProviderId,
|
||||
type ClineModelTier,
|
||||
getClineModelPickerDisplayRowsWindow,
|
||||
resolveClineModelEntryProviderId,
|
||||
resolveClineModelProviderId,
|
||||
} from "./cline-model-picker-utils";
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
const MAX_VISIBLE_ROWS = 10;
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
if (tag === "FREE") return palette.success;
|
||||
@@ -30,23 +35,6 @@ function tagColor(tag: string): string {
|
||||
return "cyan";
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
modelId: string,
|
||||
knownModels?: Record<string, unknown>,
|
||||
): string {
|
||||
if (knownModels) {
|
||||
const candidates = [modelId, modelId.split("/").pop()];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
}
|
||||
|
||||
export function useClineRecommendedModels() {
|
||||
const [data, setData] = useState<ClineRecommendedModelsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -68,18 +56,56 @@ export function useClineRecommendedModels() {
|
||||
return { data, loading };
|
||||
}
|
||||
|
||||
export function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
function ClineModelRow(props: {
|
||||
row: Extract<ClineModelPickerDisplayRow, { kind: "model" | "browse" }>;
|
||||
isSelected: boolean;
|
||||
onSelect?: (entryIndex: number, entry: ClineModelPickerEntry) => void;
|
||||
entry: ClineModelPickerEntry;
|
||||
}) {
|
||||
const { row, isSelected, onSelect, entry } = props;
|
||||
const isBrowse = row.kind === "browse";
|
||||
return (
|
||||
<box
|
||||
key={row.key}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSelected ? palette.selection : undefined}
|
||||
marginTop={isBrowse ? 1 : 0}
|
||||
onMouseDown={() => onSelect?.(row.entryIndex, entry)}
|
||||
overflow="hidden"
|
||||
height={1}
|
||||
>
|
||||
<text fg={isSelected ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isSelected ? "\u276f" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
isSelected ? palette.textOnSelection : isBrowse ? "gray" : undefined
|
||||
}
|
||||
>
|
||||
{row.label}
|
||||
</text>
|
||||
{row.kind === "model" &&
|
||||
row.tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSelected ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
))}
|
||||
{row.kind === "model" && row.isCurrent && (
|
||||
<text
|
||||
fg={isSelected ? palette.textOnSelection : palette.success}
|
||||
flexShrink={0}
|
||||
>
|
||||
(current)
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClineModelPicker(props: {
|
||||
@@ -88,8 +114,23 @@ export function ClineModelPicker(props: {
|
||||
loading?: boolean;
|
||||
knownModels?: Record<string, unknown>;
|
||||
currentModelId?: string;
|
||||
maxVisibleRows?: number;
|
||||
onEntrySelect?: (entryIndex: number, entry: ClineModelPickerEntry) => void;
|
||||
}) {
|
||||
const { entries, selected, loading, knownModels, currentModelId } = props;
|
||||
const {
|
||||
entries,
|
||||
selected,
|
||||
loading,
|
||||
knownModels,
|
||||
currentModelId,
|
||||
maxVisibleRows = MAX_VISIBLE_ROWS,
|
||||
onEntrySelect,
|
||||
} = props;
|
||||
const displayRows = useMemo(
|
||||
() =>
|
||||
buildClineModelPickerDisplayRows(entries, knownModels, currentModelId),
|
||||
[entries, knownModels, currentModelId],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -100,81 +141,65 @@ export function ClineModelPicker(props: {
|
||||
);
|
||||
}
|
||||
|
||||
const { visibleRows, aboveCount, belowCount, showAbove, showBelow } =
|
||||
getClineModelPickerDisplayRowsWindow(displayRows, selected, maxVisibleRows);
|
||||
const rows: ReactNode[] = [];
|
||||
let lastTier: string | null = null;
|
||||
let isFirstHeader = true;
|
||||
const rows: ReactNode[] = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (showAbove) {
|
||||
rows.push(
|
||||
<box key="more-above" paddingX={1} justifyContent="center" height={1}>
|
||||
<text fg="gray">
|
||||
{"\u25b2"} {aboveCount} more
|
||||
</text>
|
||||
</box>,
|
||||
);
|
||||
}
|
||||
|
||||
for (const row of visibleRows) {
|
||||
const entry = entries[row.entryIndex];
|
||||
if (!entry) continue;
|
||||
const isSel = i === selected;
|
||||
|
||||
if (entry.kind === "model") {
|
||||
if (entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
const label = entry.tier === "recommended" ? "Recommended" : "Free";
|
||||
rows.push(
|
||||
<box
|
||||
key={`tier-${entry.tier}`}
|
||||
paddingX={1}
|
||||
marginTop={isFirstHeader ? 0 : 1}
|
||||
>
|
||||
<text fg="gray">{label}</text>
|
||||
</box>,
|
||||
);
|
||||
isFirstHeader = false;
|
||||
}
|
||||
|
||||
const tags = entry.model.tags;
|
||||
const name = resolveDisplayName(entry.model.id, knownModels);
|
||||
const isCurrent = currentModelId === entry.model.id;
|
||||
if (entry.kind === "model" && entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
const label =
|
||||
entry.tier === "clinePass"
|
||||
? "Cline Pass"
|
||||
: entry.tier === "recommended"
|
||||
? "Recommended"
|
||||
: "Free";
|
||||
rows.push(
|
||||
<box
|
||||
key={entry.model.id}
|
||||
key={`tier-${entry.tier}`}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
marginTop={isFirstHeader ? 0 : 1}
|
||||
>
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text fg={isSel ? palette.textOnSelection : undefined}>{name}</text>
|
||||
{tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
))}
|
||||
{isCurrent && (
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
(current)
|
||||
</text>
|
||||
)}
|
||||
</box>,
|
||||
);
|
||||
} else {
|
||||
rows.push(
|
||||
<box
|
||||
key="browse-all"
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
marginTop={1}
|
||||
>
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"}>
|
||||
Browse all models...
|
||||
</text>
|
||||
<text fg="gray">{label}</text>
|
||||
</box>,
|
||||
);
|
||||
isFirstHeader = false;
|
||||
}
|
||||
|
||||
rows.push(
|
||||
<ClineModelRow
|
||||
key={row.key}
|
||||
row={row}
|
||||
entry={entry}
|
||||
isSelected={row.entryIndex === selected}
|
||||
onSelect={onEntrySelect}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (showBelow) {
|
||||
rows.push(
|
||||
<box key="more-below" paddingX={1} justifyContent="center" height={1}>
|
||||
<text fg="gray">
|
||||
{"\u25bc"} {belowCount} more
|
||||
</text>
|
||||
</box>,
|
||||
);
|
||||
}
|
||||
|
||||
return <box flexDirection="column">{rows}</box>;
|
||||
|
||||
@@ -3,11 +3,28 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { palette } from "../../palette";
|
||||
import type { ClineModelPickerEntry } from "./cline-model-picker";
|
||||
import {
|
||||
buildClineModelPickerDisplayRows,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelProviderId,
|
||||
getClineModelPickerDisplayRowsWindow,
|
||||
resolveClineModelEntryProviderId,
|
||||
} from "./cline-model-picker";
|
||||
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
|
||||
import { ProviderRow } from "./provider-row";
|
||||
|
||||
export const BROWSE_ALL_ACTION = "__browse_all__";
|
||||
const MAX_VISIBLE_ROWS = 10;
|
||||
|
||||
export interface ClineModelSelection {
|
||||
modelId: string;
|
||||
providerId: ClineModelProviderId;
|
||||
}
|
||||
|
||||
export type ClineModelSelectorResult =
|
||||
| ClineModelSelection
|
||||
| typeof BROWSE_ALL_ACTION
|
||||
| typeof CHANGE_PROVIDER_ACTION;
|
||||
|
||||
type ClineModelEntriesState =
|
||||
| { status: "loading"; message: string }
|
||||
@@ -20,25 +37,8 @@ function tagColor(tag: string): string {
|
||||
return "cyan";
|
||||
}
|
||||
|
||||
function resolveDisplayName(
|
||||
modelId: string,
|
||||
knownModels?: Record<string, unknown>,
|
||||
): string {
|
||||
if (knownModels) {
|
||||
const candidates = [modelId, modelId.split("/").pop()];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const hit = knownModels[key] as { name?: string } | undefined;
|
||||
if (hit?.name) return hit.name;
|
||||
}
|
||||
}
|
||||
return modelId.includes("/")
|
||||
? (modelId.split("/").pop() ?? modelId)
|
||||
: modelId;
|
||||
}
|
||||
|
||||
export function ClineModelSelectorContent(
|
||||
props: ChoiceContext<string> & {
|
||||
props: ChoiceContext<ClineModelSelectorResult> & {
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
@@ -56,54 +56,36 @@ export function ClineModelSelectorContent(
|
||||
} = props;
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [onProvider, setOnProvider] = useState(false);
|
||||
|
||||
const displayRows = useMemo(() => {
|
||||
const rows: {
|
||||
key: string;
|
||||
kind: "header" | "model" | "browse";
|
||||
label: string;
|
||||
tags: string[];
|
||||
isCurrent: boolean;
|
||||
entryIndex: number;
|
||||
}[] = [];
|
||||
let lastTier: string | null = null;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (!entry) continue;
|
||||
if (entry.kind === "model") {
|
||||
if (entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
rows.push({
|
||||
key: `tier-${entry.tier}`,
|
||||
kind: "header",
|
||||
label: entry.tier === "recommended" ? "Recommended" : "Free",
|
||||
tags: [],
|
||||
isCurrent: false,
|
||||
entryIndex: -1,
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
key: entry.model.id,
|
||||
kind: "model",
|
||||
label: resolveDisplayName(entry.model.id, knownModels),
|
||||
tags: entry.model.tags,
|
||||
isCurrent: currentModel === entry.model.id,
|
||||
entryIndex: i,
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
key: "browse-all",
|
||||
kind: "browse",
|
||||
label: "Browse all models...",
|
||||
tags: [],
|
||||
isCurrent: false,
|
||||
entryIndex: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
return buildClineModelPickerDisplayRows(entries, knownModels, currentModel);
|
||||
}, [entries, knownModels, currentModel]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected((value) =>
|
||||
Math.min(value, Math.max(0, displayRows.length - 1)),
|
||||
);
|
||||
}, [displayRows.length]);
|
||||
|
||||
const { visibleRows, aboveCount, belowCount, showAbove, showBelow } =
|
||||
getClineModelPickerDisplayRowsWindow(
|
||||
displayRows,
|
||||
selected,
|
||||
MAX_VISIBLE_ROWS,
|
||||
);
|
||||
|
||||
const resolveEntry = (entryIndex: number) => {
|
||||
const entry = entries[entryIndex];
|
||||
if (!entry) return;
|
||||
if (entry.kind === "model") {
|
||||
resolve({
|
||||
modelId: entry.model.id,
|
||||
providerId: resolveClineModelEntryProviderId(entry) ?? "cline",
|
||||
});
|
||||
} else {
|
||||
resolve(BROWSE_ALL_ACTION);
|
||||
}
|
||||
};
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
dismiss();
|
||||
@@ -118,20 +100,16 @@ export function ClineModelSelectorContent(
|
||||
resolve(CHANGE_PROVIDER_ACTION);
|
||||
return;
|
||||
}
|
||||
const entry = entries[selected];
|
||||
if (!entry) return;
|
||||
if (entry.kind === "model") {
|
||||
resolve(entry.model.id);
|
||||
} else {
|
||||
resolve(BROWSE_ALL_ACTION);
|
||||
}
|
||||
const row = displayRows[selected];
|
||||
if (!row) return;
|
||||
resolveEntry(row.entryIndex);
|
||||
return;
|
||||
}
|
||||
const total = entries.length;
|
||||
const total = displayRows.length;
|
||||
if (total === 0) return;
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
if (!onProvider) {
|
||||
setSelected((s) => (s <= 0 ? total - 1 : s - 1));
|
||||
setSelected((s) => (s <= 0 ? total - 1 : Math.min(s - 1, total - 1)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -143,6 +121,76 @@ export function ClineModelSelectorContent(
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
let lastTier: string | null = null;
|
||||
let isFirstHeader = true;
|
||||
const renderedRows = visibleRows.flatMap((row) => {
|
||||
const entry = entries[row.entryIndex];
|
||||
if (!entry) return [];
|
||||
|
||||
const elements = [];
|
||||
if (entry.kind === "model" && entry.tier !== lastTier) {
|
||||
lastTier = entry.tier;
|
||||
const label =
|
||||
entry.tier === "clinePass"
|
||||
? "Cline Pass"
|
||||
: entry.tier === "recommended"
|
||||
? "Recommended"
|
||||
: "Free";
|
||||
elements.push(
|
||||
<box
|
||||
key={`tier-${entry.tier}-${row.entryIndex}`}
|
||||
paddingX={1}
|
||||
marginTop={isFirstHeader ? 0 : 1}
|
||||
>
|
||||
<text fg="gray">{label}</text>
|
||||
</box>,
|
||||
);
|
||||
isFirstHeader = false;
|
||||
}
|
||||
|
||||
const isSel = row.entryIndex === selected && !onProvider;
|
||||
const isGray = row.kind === "browse";
|
||||
elements.push(
|
||||
<box
|
||||
key={row.key}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
marginTop={row.kind === "browse" ? 1 : 0}
|
||||
onMouseDown={() => resolveEntry(row.entryIndex)}
|
||||
overflow="hidden"
|
||||
height={1}
|
||||
>
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : isGray ? "gray" : undefined}
|
||||
>
|
||||
{row.label}
|
||||
</text>
|
||||
{row.kind === "model" &&
|
||||
row.tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
))}
|
||||
{row.kind === "model" && row.isCurrent && (
|
||||
<text fg={isSel ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
(current)
|
||||
</text>
|
||||
)}
|
||||
</box>,
|
||||
);
|
||||
|
||||
return elements;
|
||||
});
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text>
|
||||
@@ -152,59 +200,21 @@ export function ClineModelSelectorContent(
|
||||
<ProviderRow providerName={currentProviderName} focused={onProvider} />
|
||||
|
||||
<box flexDirection="column">
|
||||
{displayRows.map((row, idx) => {
|
||||
if (row.kind === "header") {
|
||||
const isFirst = idx === 0;
|
||||
return (
|
||||
<box key={row.key} paddingX={1} marginTop={isFirst ? 0 : 1}>
|
||||
<text fg="gray">{row.label}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
const isSel = row.entryIndex === selected && !onProvider;
|
||||
const isGray = row.kind === "browse";
|
||||
return (
|
||||
<box
|
||||
key={row.key}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isSel ? palette.selection : undefined}
|
||||
marginTop={row.kind === "browse" ? 1 : 0}
|
||||
>
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isSel ? "\u276f" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
isSel ? palette.textOnSelection : isGray ? "gray" : undefined
|
||||
}
|
||||
>
|
||||
{row.label}
|
||||
</text>
|
||||
{row.tags.map((t) => (
|
||||
<text
|
||||
key={t}
|
||||
fg={isSel ? palette.textOnSelection : tagColor(t)}
|
||||
flexShrink={0}
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
))}
|
||||
{row.isCurrent && (
|
||||
<text
|
||||
fg={isSel ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
(current)
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
{showAbove && (
|
||||
<box paddingX={1} justifyContent="center" height={1}>
|
||||
<text fg="gray">
|
||||
{"\u25b2"} {aboveCount} more
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
{renderedRows}
|
||||
{showBelow && (
|
||||
<box paddingX={1} justifyContent="center" height={1}>
|
||||
<text fg="gray">
|
||||
{"\u25bc"} {belowCount} more
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
|
||||
<text fg="gray">
|
||||
@@ -215,7 +225,7 @@ export function ClineModelSelectorContent(
|
||||
}
|
||||
|
||||
export function ClineModelSelectorDialogContent(
|
||||
props: ChoiceContext<string> & {
|
||||
props: ChoiceContext<ClineModelSelectorResult> & {
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
|
||||
@@ -134,6 +134,7 @@ export function ModelSelectorContent(
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
models: ModelOption[];
|
||||
allowCustomModel?: boolean;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
@@ -143,6 +144,7 @@ export function ModelSelectorContent(
|
||||
currentModel,
|
||||
currentProviderName,
|
||||
models,
|
||||
allowCustomModel = true,
|
||||
} = props;
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState(() => {
|
||||
@@ -164,7 +166,7 @@ export function ModelSelectorContent(
|
||||
return scored.map((r) => r.model);
|
||||
}, [models, search]);
|
||||
|
||||
const optionCount = filtered.length + 1;
|
||||
const optionCount = filtered.length + (allowCustomModel ? 1 : 0);
|
||||
const safeSelected = Math.min(selected, Math.max(0, optionCount - 1));
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
@@ -188,7 +190,7 @@ export function ModelSelectorContent(
|
||||
resolve(model.key);
|
||||
return;
|
||||
}
|
||||
if (safeSelected === filtered.length) {
|
||||
if (allowCustomModel && safeSelected === filtered.length) {
|
||||
setIsCreatingCustomModel(true);
|
||||
setCustomModelId("");
|
||||
setCustomModelError("");
|
||||
@@ -290,6 +292,7 @@ export function ModelSelectorContent(
|
||||
dimmed={onProvider}
|
||||
currentModel={currentModel}
|
||||
onSelect={resolve}
|
||||
allowCustomModel={allowCustomModel}
|
||||
onCreateCustomModel={() => {
|
||||
setIsCreatingCustomModel(true);
|
||||
setCustomModelId("");
|
||||
@@ -408,6 +411,7 @@ function ModelList(props: {
|
||||
dimmed?: boolean;
|
||||
currentModel: string;
|
||||
onSelect: (key: string) => void;
|
||||
allowCustomModel: boolean;
|
||||
onCreateCustomModel: () => void;
|
||||
}) {
|
||||
const {
|
||||
@@ -416,11 +420,12 @@ function ModelList(props: {
|
||||
dimmed,
|
||||
currentModel,
|
||||
onSelect,
|
||||
allowCustomModel,
|
||||
onCreateCustomModel,
|
||||
} = props;
|
||||
const rows: ({ type: "model"; model: ModelOption } | { type: "custom" })[] = [
|
||||
...items.map((model) => ({ type: "model" as const, model })),
|
||||
{ type: "custom" as const },
|
||||
...(allowCustomModel ? [{ type: "custom" as const }] : []),
|
||||
];
|
||||
|
||||
if (rows.length <= MAX_VISIBLE) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback } from "react";
|
||||
import { isOpenAICodexCliProvider } from "../../utils/codex-cli";
|
||||
import { getCliFeatureFlagsService } from "../../utils/feature-flags";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
@@ -29,6 +30,7 @@ import { buildClineModelEntries } from "../components/model-selector/cline-model
|
||||
import {
|
||||
BROWSE_ALL_ACTION,
|
||||
ClineModelSelectorDialogContent,
|
||||
type ClineModelSelectorResult,
|
||||
} from "../components/model-selector/cline-model-selector";
|
||||
import {
|
||||
buildModelOptions,
|
||||
@@ -78,6 +80,10 @@ function usesModelIdInput(providerId: string): boolean {
|
||||
return providerId === "openai-compatible";
|
||||
}
|
||||
|
||||
function usesClineModelSelector(providerId: string): boolean {
|
||||
return providerId === "cline";
|
||||
}
|
||||
|
||||
async function runProviderChange(
|
||||
dialog: DialogActions,
|
||||
config: Config,
|
||||
@@ -264,6 +270,8 @@ export function useModelSelector(opts: {
|
||||
}
|
||||
|
||||
let pickingModel = true;
|
||||
const isClinePassEnabled =
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass");
|
||||
|
||||
while (pickingModel) {
|
||||
if (usesModelIdInput(config.providerId)) {
|
||||
@@ -291,17 +299,19 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.providerId === "cline") {
|
||||
const clineResult = await dialog.choice<string>({
|
||||
if (usesClineModelSelector(config.providerId)) {
|
||||
const clineResult = await dialog.choice<ClineModelSelectorResult>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<string>) => (
|
||||
content: (ctx: ChoiceContext<ClineModelSelectorResult>) => (
|
||||
<ClineModelSelectorDialogContent
|
||||
{...ctx}
|
||||
currentModel={config.modelId}
|
||||
currentProviderName={providerDisplayName}
|
||||
knownModels={config.knownModels as Record<string, unknown>}
|
||||
loadEntries={async () =>
|
||||
buildClineModelEntries(await fetchClineRecommendedModels())
|
||||
buildClineModelEntries(await fetchClineRecommendedModels(), {
|
||||
includeClinePass: isClinePassEnabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
@@ -323,6 +333,7 @@ export function useModelSelector(opts: {
|
||||
currentModel={config.modelId}
|
||||
currentProviderName={providerDisplayName}
|
||||
models={modelOptions}
|
||||
allowCustomModel={false}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -332,6 +343,7 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
config.modelId = browseResult;
|
||||
config.providerId = "cline";
|
||||
const browseModel = modelOptions.find(
|
||||
(m: ModelOption) => m.key === browseResult,
|
||||
);
|
||||
@@ -368,9 +380,28 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
config.modelId = clineResult;
|
||||
if (config.providerId !== clineResult.providerId) {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const resolved = await resolveProviderConfig(
|
||||
clineResult.providerId,
|
||||
{
|
||||
loadLatestOnInit: true,
|
||||
loadPrivateOnAuth: true,
|
||||
failOnError: false,
|
||||
},
|
||||
manager.getProviderConfig(clineResult.providerId, {
|
||||
includeKnownModels: false,
|
||||
}),
|
||||
);
|
||||
modelOptions = buildModelOptions(
|
||||
resolved?.knownModels as Record<string, Llms.ModelInfo>,
|
||||
);
|
||||
config.knownModels = resolved?.knownModels;
|
||||
}
|
||||
config.modelId = clineResult.modelId;
|
||||
config.providerId = clineResult.providerId;
|
||||
const selectedModel = modelOptions.find(
|
||||
(m: ModelOption) => m.key === clineResult,
|
||||
(m: ModelOption) => m.key === clineResult.modelId,
|
||||
);
|
||||
if (selectedModel?.supportsReasoning) {
|
||||
const currentLevel: ThinkingLevel = config.reasoningEffort
|
||||
@@ -413,6 +444,7 @@ export function useModelSelector(opts: {
|
||||
currentModel={config.modelId}
|
||||
currentProviderName={providerDisplayName}
|
||||
models={modelOptions}
|
||||
allowCustomModel={config.providerId !== "cline-pass"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -15,12 +15,15 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { getCliFeatureFlagsService } from "../../../utils/feature-flags";
|
||||
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { getCliTelemetryService } from "../../../utils/telemetry";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
type ClineModelPickerEntry,
|
||||
type ClineModelProviderId,
|
||||
resolveClineModelEntryProviderId,
|
||||
useClineRecommendedModels,
|
||||
} from "../../components/model-selector/cline-model-picker";
|
||||
import {
|
||||
@@ -153,6 +156,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
const createCustomModelItem = useCallback(
|
||||
(_search: string, filteredItems: SearchableItem[]) => {
|
||||
if (activeProviderId === "cline-pass") return undefined;
|
||||
if (filteredItems.some((item) => item.key === CUSTOM_MODEL_ID_ACTION)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -163,24 +167,36 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
searchText: "create custom model id manual entry",
|
||||
} satisfies SearchableItem;
|
||||
},
|
||||
[],
|
||||
[activeProviderId],
|
||||
);
|
||||
|
||||
const modelList = useSearchableList(modelItems, createCustomModelItem);
|
||||
|
||||
// Cline featured model picker
|
||||
const recommended = useClineRecommendedModels();
|
||||
const isClinePassEnabled =
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass");
|
||||
const clineEntries: ClineModelPickerEntry[] = useMemo(
|
||||
() => (recommended.data ? buildClineModelEntries(recommended.data) : []),
|
||||
[recommended.data],
|
||||
() =>
|
||||
recommended.data
|
||||
? buildClineModelEntries(recommended.data, {
|
||||
includeClinePass: isClinePassEnabled,
|
||||
})
|
||||
: [],
|
||||
[recommended.data, isClinePassEnabled],
|
||||
);
|
||||
const [clineModelSelected, setClineModelSelected] = useState(0);
|
||||
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [clineKnownModels, setClineKnownModels] = useState<
|
||||
Record<string, unknown> | undefined
|
||||
>(undefined);
|
||||
const [clineModelSelected, setClineModelSelected] = useState(0);
|
||||
useEffect(() => {
|
||||
setClineModelSelected((selected) =>
|
||||
Math.min(selected, Math.max(0, clineEntries.length - 1)),
|
||||
);
|
||||
}, [clineEntries.length]);
|
||||
const [clineModelReasoningIds, setClineModelReasoningIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
getLocalProviderModels("cline")
|
||||
@@ -536,16 +552,16 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
}, [customModelId, completeModelSelection]);
|
||||
|
||||
const saveClineModelSelection = useCallback(
|
||||
(modelId: string, modelName: string) => {
|
||||
const existing =
|
||||
providerSettingsManager.getProviderSettings(activeProviderId);
|
||||
(modelId: string, modelName: string, providerId: ClineModelProviderId) => {
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
providerSettingsManager.saveProviderSettings(
|
||||
{
|
||||
...(existing ?? { provider: activeProviderId }),
|
||||
...(existing ?? { provider: providerId }),
|
||||
model: modelId,
|
||||
},
|
||||
{ setLastUsed: true },
|
||||
);
|
||||
setActiveProviderId(providerId);
|
||||
setSelectedModelId(modelId);
|
||||
if (clineModelReasoningIds.has(modelId)) {
|
||||
setSelectedModelName(modelName);
|
||||
@@ -555,7 +571,28 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setStep("done");
|
||||
}
|
||||
},
|
||||
[activeProviderId, clineModelReasoningIds, providerSettingsManager],
|
||||
[clineModelReasoningIds, providerSettingsManager],
|
||||
);
|
||||
|
||||
const selectClineModelEntry = useCallback(
|
||||
(entry: ClineModelPickerEntry | undefined) => {
|
||||
if (!entry) return;
|
||||
if (entry.kind === "model") {
|
||||
const providerId = resolveClineModelEntryProviderId(entry) ?? "cline";
|
||||
if (providerId === "cline-pass") {
|
||||
setActiveProviderId(providerId);
|
||||
setActiveProviderName("Cline Pass");
|
||||
setStep("model_picker");
|
||||
loadModelsForProvider(providerId);
|
||||
return;
|
||||
}
|
||||
saveClineModelSelection(entry.model.id, entry.model.name, providerId);
|
||||
return;
|
||||
}
|
||||
setStep("model_picker");
|
||||
loadModelsForProvider(activeProviderId);
|
||||
},
|
||||
[activeProviderId, loadModelsForProvider, saveClineModelSelection],
|
||||
);
|
||||
|
||||
const saveThinkingLevel = useCallback(
|
||||
@@ -645,7 +682,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
startDeviceCodeFlow,
|
||||
selectProvider,
|
||||
loadModelsForProvider,
|
||||
saveClineModelSelection,
|
||||
selectClineModelEntry,
|
||||
saveCodexCliConfig,
|
||||
saveByoConfig,
|
||||
saveModelSelection,
|
||||
@@ -683,6 +720,12 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setCustomModelId(value);
|
||||
setCustomModelError("");
|
||||
},
|
||||
handleClineModelEntrySelect: (
|
||||
_entryIndex: number,
|
||||
entry: ClineModelPickerEntry,
|
||||
) => {
|
||||
selectClineModelEntry(entry);
|
||||
},
|
||||
handleModelItemSelect: selectModelItem,
|
||||
menuSelected,
|
||||
modelItems,
|
||||
|
||||
@@ -43,7 +43,7 @@ export function useOnboardingKeyboard(input: {
|
||||
startDeviceCodeFlow: (providerId: OnboardingOAuthProviderId) => void;
|
||||
selectProvider: (providerId: string) => void;
|
||||
loadModelsForProvider: (providerId: string) => void;
|
||||
saveClineModelSelection: (modelId: string, modelName: string) => void;
|
||||
selectClineModelEntry: (entry: ClineModelPickerEntry | undefined) => void;
|
||||
saveCodexCliConfig: () => void;
|
||||
saveByoConfig: () => void;
|
||||
saveModelSelection: () => void;
|
||||
@@ -208,14 +208,9 @@ export function useOnboardingKeyboard(input: {
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
const entry = input.clineEntries[input.clineModelSelected];
|
||||
if (!entry) return;
|
||||
if (entry.kind === "model") {
|
||||
input.saveClineModelSelection(entry.model.id, entry.model.name);
|
||||
} else {
|
||||
input.setStep("model_picker");
|
||||
input.loadModelsForProvider(input.activeProviderId);
|
||||
}
|
||||
input.selectClineModelEntry(
|
||||
input.clineEntries[input.clineModelSelected],
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -438,6 +438,7 @@ export function OnboardingClineModelScreen(props: {
|
||||
compact: boolean;
|
||||
contentWidth: number;
|
||||
mouse: MouseTrackerState;
|
||||
onEntrySelect: (entryIndex: number, entry: ClineModelPickerEntry) => void;
|
||||
recommendedLoading: boolean;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
@@ -459,6 +460,7 @@ export function OnboardingClineModelScreen(props: {
|
||||
selected={props.clineModelSelected}
|
||||
loading={props.recommendedLoading}
|
||||
knownModels={props.clineKnownModels}
|
||||
onEntrySelect={props.onEntrySelect}
|
||||
/>
|
||||
|
||||
<text fg="gray" paddingX={1}>
|
||||
|
||||
@@ -115,6 +115,7 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
clineModelSelected={state.clineModelSelected}
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
onEntrySelect={state.handleClineModelEntrySelect}
|
||||
mouse={mouse}
|
||||
recommendedLoading={state.recommendedLoading}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ClineRecommendedModel {
|
||||
export interface ClineRecommendedModelsData {
|
||||
recommended: ClineRecommendedModel[];
|
||||
free: ClineRecommendedModel[];
|
||||
clinePass: ClineRecommendedModel[];
|
||||
}
|
||||
|
||||
export interface FetchClineRecommendedModelsOptions {
|
||||
@@ -26,6 +27,7 @@ export interface FetchClineRecommendedModelsOptions {
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export const FALLBACK_CLINE_RECOMMENDED_MODELS: ClineRecommendedModelsData = {
|
||||
clinePass: [],
|
||||
recommended: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
@@ -72,6 +74,10 @@ function cloneRecommendedModels(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineRecommendedModelsData {
|
||||
return {
|
||||
clinePass: data.clinePass.map((m) => ({
|
||||
...m,
|
||||
tags: [...m.tags],
|
||||
})),
|
||||
recommended: data.recommended.map((model) => ({
|
||||
...model,
|
||||
tags: [...model.tags],
|
||||
@@ -104,14 +110,21 @@ function normalizeResponse(raw: unknown): ClineRecommendedModelsData | null {
|
||||
? data.recommended
|
||||
: [];
|
||||
const freeRaw = Array.isArray(data.free) ? data.free : [];
|
||||
const clinePassRaw = Array.isArray(data.clinePass) ? data.clinePass : [];
|
||||
|
||||
const recommended = recommendedRaw
|
||||
.map(normalizeModel)
|
||||
.filter((model): model is ClineRecommendedModel => model !== null);
|
||||
const free = freeRaw
|
||||
.map(normalizeModel)
|
||||
.filter((model): model is ClineRecommendedModel => model !== null);
|
||||
if (recommended.length === 0 && free.length === 0) return null;
|
||||
return { recommended, free };
|
||||
const clinePass = clinePassRaw
|
||||
.map(normalizeModel)
|
||||
.filter((model): model is ClineRecommendedModel => model !== null);
|
||||
if (recommended.length === 0 && free.length === 0 && clinePass.length === 0)
|
||||
return null;
|
||||
|
||||
return { recommended, free, clinePass };
|
||||
}
|
||||
|
||||
function getConfiguredApiBaseUrl(
|
||||
|
||||
Reference in New Issue
Block a user