Compare commits

...

16 Commits

Author SHA1 Message Date
BarreiroT 5bef219d31 Remove expandable sections 2026-06-17 15:22:33 -03:00
BarreiroT 506b2fd958 Merge branch 'main' into cli-cline-pass-flow 2026-06-17 14:41:14 -03:00
BarreiroT a60a408374 Merge remote-tracking branch 'origin/main' into cli-cline-pass-flow 2026-06-16 22:30:24 -03:00
BarreiroT 00ffea0c38 Build models 2026-06-16 20:54:14 -03:00
Saoud Rizwan 1ac089ae76 Merge branch 'main' into cli-cline-pass-flow 2026-06-16 16:46:20 -07:00
Saoud Rizwan 310bd67149 style: format Cline recommended models fallback 2026-06-16 16:45:28 -07:00
Tomás Barreiro 466e87e78b Update sdk/packages/core/src/services/llms/cline-recommended-models.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-17 01:38:06 +02:00
BarreiroT 64399c1589 update the model data when the provider doesn't match 2026-06-16 20:25:32 -03:00
BarreiroT f28eb8fd8a fix focus when expanding a section 2026-06-16 19:37:19 -03:00
BarreiroT 9734688c8b fix clinepass model list 2026-06-16 19:27:52 -03:00
BarreiroT 6d82700d41 Do not show the count on the mode list 2026-06-16 19:23:08 -03:00
BarreiroT 63d411d91f Do not allow custom model on cline pass 2026-06-16 19:21:18 -03:00
BarreiroT e2150cd73a gate the models behind the feature flag 2026-06-16 19:21:04 -03:00
BarreiroT 9ab8a55414 Map clinePass models to clinePass and the rest to cline 2026-06-16 19:21:04 -03:00
BarreiroT d07a147964 fix label 2026-06-16 19:21:04 -03:00
BarreiroT 662552cded Make sections expandable and add ClinePass 2026-06-16 19:21:04 -03:00
11 changed files with 706 additions and 267 deletions
@@ -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) {
+38 -6
View File
@@ -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"}
/>
),
});
+56 -13
View File
@@ -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(