diff --git a/.changeset/tui-gateway-model-picker.md b/.changeset/tui-gateway-model-picker.md new file mode 100644 index 00000000000..2ea0f32ccd3 --- /dev/null +++ b/.changeset/tui-gateway-model-picker.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep recently used Kilo Gateway models visible in the TUI picker, and find them when filtering by kilo. diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index d41ebc59392..f2561f34d8f 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -2,16 +2,16 @@ import { useTerminalDimensions } from "@opentui/solid" // kilocode_change import { createEffect, createMemo, createSignal, Show } from "solid-js" // kilocode_change import { useLocal } from "../context/local" import { useSync } from "../context/sync" -import { map, pipe, flatMap, entries, filter, sortBy, take, groupBy } from "remeda" // kilocode_change +import { map, pipe, sortBy, take } from "remeda" // kilocode_change import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" import type { Model } from "@kilocode/sdk/v2" // kilocode_change -import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" import { ModelInfoPanel } from "@/kilocode/components/model-info-panel" // kilocode_change import { FreeModelDisclosure } from "@/kilocode/components/free-model-disclosure" // kilocode_change +import { buildModelPickerOptions, rankProviderOptions } from "../kilocode/model-picker" // kilocode_change export function DialogModel(props: { providerID?: string }) { const local = useLocal() @@ -73,99 +73,22 @@ export function DialogModel(props: { providerID?: string }) { } // kilocode_change end + // kilocode_change start - option building lives in kilocode/model-picker so the + // Kilo Gateway grouping/search rules can be unit tested const options = createMemo(() => { const needle = query().trim() - // kilocode_change: removed showSections guard — sections are always built; empty ones are hidden naturally - const favorites = connected() ? local.model.favorite() : [] - const recents = local.model.recent() - - function toOptions(items: typeof favorites, category: string) { - if (!showExtra()) return [] // kilocode_change - return items.flatMap((item) => { - const provider = sync.data.provider.find((provider) => provider.id === item.providerID) - if (!provider) return [] - const model = provider.models[item.modelID] - if (!model) return [] - return [ - { - key: item, - value: { providerID: provider.id, modelID: model.id }, - title: model.name ?? item.modelID, - description: provider.name, - category, - disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: footer(provider.id, model), // kilocode_change - onSelect: () => { - onSelect(provider.id, model.id) // kilocode_change - }, - }, - ] - }) - } - - const favoriteOptions = toOptions(favorites, "Favorites") - const recentOptions = toOptions( - recents.filter( - (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), - ), - "Recent", - ) - - const providerOptions = pipe( - sync.data.provider, - sortBy( - (provider) => provider.id !== "opencode", - (provider) => provider.name, - ), - flatMap((provider) => - pipe( - provider.models, - entries(), - filter(([_, info]) => info.status !== "deprecated"), - filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), - map(([model, info]) => ({ - value: { providerID: provider.id, modelID: model }, - title: info.name ?? model, - releaseDate: info.release_date, - description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) - ? "(Favorite)" - : undefined, - // kilocode_change start - category: connected() - ? provider.id === "kilo" && info.recommendedIndex !== undefined - ? "Recommended" - : provider.name - : undefined, - // kilocode_change end - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: footer(provider.id, info), // kilocode_change - onSelect() { - onSelect(provider.id, model) // kilocode_change - }, - })), - filter((option) => { - // kilocode_change start - only dedupe favorites/recents when those sections are visible - if (showExtra()) { - if ( - favorites.some( - (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, - ) - ) - return false - if ( - recents.some( - (item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID, - ) - ) - return false - } - // kilocode_change end - return true - }), - (options) => sortModelOptions(options, props.providerID !== undefined, kiloRank()), // kilocode_change - ), - ), - ) + const modelOptions = buildModelPickerOptions({ + providers: sync.data.provider, + favorites: connected() ? local.model.favorite() : [], + recents: local.model.recent(), + connected: connected(), + showExtra: showExtra(), + providerID: props.providerID, + query: needle, + footer, + onSelect, + sort: (items) => sortModelOptions(items, props.providerID !== undefined, kiloRank()), + }) const popularProviders = !connected() ? pipe( @@ -178,23 +101,9 @@ export function DialogModel(props: { providerID?: string }) { ) : [] - // kilocode_change start - Filter per-section to preserve group headers while typing - if (needle) { - const rank = (items: U[]) => - fuzzysort.go(needle, items, { keys: ["title", "category"] }).map((x) => x.obj) - // rank within each provider category to preserve category order - const rankedProviders = pipe( - providerOptions, - groupBy((x) => x.category ?? ""), - entries(), - flatMap(([_, items]) => rank(items)), - ) - return [...rank(favoriteOptions), ...rank(recentOptions), ...rankedProviders, ...rank(popularProviders)] - } - // kilocode_change end - - return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] + return [...modelOptions, ...(needle ? rankProviderOptions(needle, popularProviders) : popularProviders)] }) + // kilocode_change end const provider = createMemo(() => props.providerID ? sync.data.provider.find((item) => item.id === props.providerID) : null, diff --git a/packages/tui/src/kilocode/model-picker.ts b/packages/tui/src/kilocode/model-picker.ts new file mode 100644 index 00000000000..7f1eeec8118 --- /dev/null +++ b/packages/tui/src/kilocode/model-picker.ts @@ -0,0 +1,186 @@ +// kilocode_change - new file +// +// Pure option builder for the TUI model picker, extracted from +// `component/dialog-model.tsx` so the Kilo Gateway grouping/search rules are +// testable without mounting the dialog. +// +// Two rules here exist because the TUI used to hide live Kilo Gateway models: +// 1. Recently used models are no longer stripped from their provider section. +// Selecting a Kilo sonnet once used to remove it from "Recommended" / +// "Kilo Gateway" entirely, leaving those sections looking empty. This +// matches the VS Code selector, which also keeps recents in place. +// 2. Search matches the provider name and the provider/model ids, not just +// the title and the section header, so typing `kilo` finds +// "Anthropic Claude Sonnet 4.5" under the "Recommended" section. +import * as fuzzysort from "fuzzysort" +import { entries, filter, flatMap, groupBy, map, pipe, sortBy } from "remeda" + +export const KILO_PROVIDER_ID = "kilo" +export const RECOMMENDED_CATEGORY = "Recommended" + +export interface ModelPickerRef { + providerID: string + modelID: string +} + +export interface ModelPickerModel { + id: string + name?: string + status?: string + /** sub-provider the model is routed through, not the catalog provider id */ + providerID?: string + release_date?: string | number + recommendedIndex?: number +} + +export interface ModelPickerProvider { + id: string + name: string + models: Record +} + +export interface ModelPickerOption { + key?: ModelPickerRef + value: ModelPickerRef + title: string + description?: string + category?: string + releaseDate: string | number + disabled: boolean + footer?: string + /** extra search haystacks — kept flat so fuzzysort can key off them */ + providerName: string + providerID: string + modelID: string + onSelect: () => void +} + +const MODEL_SEARCH_KEYS = ["title", "category", "providerName", "providerID", "modelID"] +const PROVIDER_SEARCH_KEYS = ["title", "category"] + +function rank(needle: string, items: readonly T[], keys: string[]): T[] { + return fuzzysort.go(needle, items as T[], { keys }).map((result) => result.obj) +} + +export function rankModelOptions(needle: string, items: readonly T[]): T[] { + return rank(needle, items, MODEL_SEARCH_KEYS) +} + +export function rankProviderOptions( + needle: string, + items: readonly T[], +): T[] { + return rank(needle, items, PROVIDER_SEARCH_KEYS) +} + +function sameRef(left: ModelPickerRef, right: ModelPickerRef) { + return left.providerID === right.providerID && left.modelID === right.modelID +} + +export interface BuildModelPickerOptionsInput { + providers: readonly ModelPickerProvider[] + favorites?: readonly ModelPickerRef[] + recents?: readonly ModelPickerRef[] + /** true once the user is signed in — drives the "Recommended" section */ + connected?: boolean + /** true when the favorites/recents sections are rendered */ + showExtra?: boolean + /** set when the dialog is scoped to a single provider */ + providerID?: string + query?: string + footer?: (providerID: string, model: M) => string | undefined + onSelect?: (providerID: string, modelID: string) => void + /** applied per provider section, before search ranking */ + sort?: (options: ModelPickerOption[]) => ModelPickerOption[] +} + +export function buildModelPickerOptions( + input: BuildModelPickerOptionsInput, +): ModelPickerOption[] { + const favorites = input.favorites ?? [] + const recents = input.recents ?? [] + const connected = input.connected ?? false + const showExtra = input.showExtra ?? false + const sort = input.sort ?? ((options: ModelPickerOption[]) => options) + const needle = (input.query ?? "").trim() + + const build = ( + provider: ModelPickerProvider, + modelID: string, + model: M, + extra: Partial, + ): ModelPickerOption => ({ + value: { providerID: provider.id, modelID }, + title: model.name ?? modelID, + releaseDate: model.release_date ?? "", + disabled: provider.id === "opencode" && modelID.includes("-nano"), + footer: input.footer?.(provider.id, model), + providerName: provider.name, + providerID: provider.id, + modelID, + onSelect: () => input.onSelect?.(provider.id, modelID), + ...extra, + }) + + function toOptions(items: readonly ModelPickerRef[], category: string) { + if (!showExtra) return [] + return items.flatMap((item) => { + const provider = input.providers.find((provider) => provider.id === item.providerID) + if (!provider) return [] + const model = provider.models[item.modelID] + if (!model) return [] + return [build(provider, item.modelID, model, { key: item, description: provider.name, category })] + }) + } + + const favoriteOptions = toOptions(favorites, "Favorites") + const recentOptions = toOptions( + recents.filter((item) => !favorites.some((favorite) => sameRef(favorite, item))), + "Recent", + ) + + const providerOptions = pipe( + input.providers, + sortBy( + (provider) => provider.id !== "opencode", + (provider) => provider.name, + ), + flatMap((provider) => + pipe( + provider.models, + entries(), + filter(([_, model]) => model.status !== "deprecated"), + filter(([_, model]) => (input.providerID ? model.providerID === input.providerID : true)), + map(([modelID, model]) => + build(provider, modelID, model, { + description: favorites.some((item) => sameRef(item, { providerID: provider.id, modelID })) + ? "(Favorite)" + : undefined, + category: connected + ? provider.id === KILO_PROVIDER_ID && model.recommendedIndex !== undefined + ? RECOMMENDED_CATEGORY + : provider.name + : undefined, + }), + ), + // Favorites are pinned by hand and get their own section, so they are + // deduped out of the provider section. Recents are not: a model must + // stay visible under its provider (and under "Recommended") even right + // after it was used, otherwise those sections look empty. + filter((option) => !(showExtra && favorites.some((item) => sameRef(item, option.value)))), + sort, + ), + ), + ) + + if (!needle) return [...favoriteOptions, ...recentOptions, ...providerOptions] + + // rank within each category so section headers survive filtering + const rankedProviders = pipe( + providerOptions, + groupBy((option) => option.category ?? ""), + entries(), + flatMap(([_, items]) => rankModelOptions(needle, items)), + ) + return [...rankModelOptions(needle, favoriteOptions), ...rankModelOptions(needle, recentOptions), ...rankedProviders] +} diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 4435553d562..951e904a77d 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -349,7 +349,8 @@ export function DialogSelect(props: DialogSelectProps) { } if (y < 0) { scroll.scrollBy(y) - if (isDeepEqual(flat()[0].value, selected()?.value)) { + if (flat()[0] === selected()) { + // kilocode_change - reference identity; duplicate values are legal (see `active`) scroll.scrollTo(0) } } @@ -654,7 +655,11 @@ export function DialogSelect(props: DialogSelectProps) { {(option) => { - const active = createMemo(() => !props.locked && isDeepEqual(option.value, selected()?.value)) + // kilocode_change start - match the selected row by reference, not by value: the + // model picker legitimately lists the same model twice (Recent + its provider + // section), and value equality would light up / target both rows + const active = createMemo(() => !props.locked && option === selected()) + // kilocode_change end const current = createMemo(() => isDeepEqual(option.value, props.current)) return ( (props: DialogSelectProps) { onMouseOver={() => { if (props.locked) return if (store.input !== "mouse") return - const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) + const index = flat().indexOf(option) // kilocode_change - see `active` above if (index === -1) return moveTo(index) }} onMouseDown={() => { if (props.locked) return - const index = flat().findIndex((x) => isDeepEqual(x.value, option.value)) + const index = flat().indexOf(option) // kilocode_change - see `active` above if (index === -1) return moveTo(index) }} diff --git a/packages/tui/test/kilocode/model-picker.test.ts b/packages/tui/test/kilocode/model-picker.test.ts new file mode 100644 index 00000000000..7f24cc54853 --- /dev/null +++ b/packages/tui/test/kilocode/model-picker.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test" +import { + buildModelPickerOptions, + RECOMMENDED_CATEGORY, + type ModelPickerProvider, + type ModelPickerRef, +} from "../../src/kilocode/model-picker" + +const KILO: ModelPickerProvider = { + id: "kilo", + name: "Kilo Gateway", + models: { + "anthropic/claude-sonnet-4-5": { + id: "anthropic/claude-sonnet-4-5", + name: "Anthropic Claude Sonnet 4.5", + release_date: "2025-09-29", + recommendedIndex: 0, + }, + "anthropic/claude-sonnet-4": { + id: "anthropic/claude-sonnet-4", + name: "Anthropic Claude Sonnet 4", + release_date: "2025-05-22", + recommendedIndex: 1, + }, + "openai/gpt-5": { + id: "openai/gpt-5", + name: "OpenAI GPT 5", + release_date: "2025-08-07", + }, + }, +} + +const BEDROCK: ModelPickerProvider = { + id: "amazon-bedrock", + name: "Amazon Bedrock", + models: { + "anthropic.claude-sonnet-4-20250514-v1:0": { + id: "anthropic.claude-sonnet-4-20250514-v1:0", + name: "Claude Sonnet 4", + release_date: "2025-05-22", + }, + }, +} + +const providers = [KILO, BEDROCK] + +const sonnet45: ModelPickerRef = { providerID: "kilo", modelID: "anthropic/claude-sonnet-4-5" } +const sonnet4: ModelPickerRef = { providerID: "kilo", modelID: "anthropic/claude-sonnet-4" } +const bedrockSonnet: ModelPickerRef = { + providerID: "amazon-bedrock", + modelID: "anthropic.claude-sonnet-4-20250514-v1:0", +} + +function build(input: { recents?: ModelPickerRef[]; favorites?: ModelPickerRef[]; query?: string } = {}) { + return buildModelPickerOptions({ + providers, + connected: true, + showExtra: true, + ...input, + }) +} + +const inCategory = (options: ReturnType, category: string) => + options.filter((option) => option.category === category).map((option) => option.modelID) + +describe("model picker options", () => { + test("keeps recommended Kilo models in their section after they are used", () => { + const options = build({ recents: [sonnet45] }) + + expect(inCategory(options, "Recent")).toEqual([sonnet45.modelID]) + expect(inCategory(options, RECOMMENDED_CATEGORY)).toEqual([sonnet45.modelID, sonnet4.modelID]) + }) + + test("finds a recently used recommended Kilo model when filtering by provider name", () => { + const options = build({ recents: [sonnet45], query: "kilo" }) + const recommended = inCategory(options, RECOMMENDED_CATEGORY) + + expect(recommended).toContain(sonnet45.modelID) + expect(recommended).toContain(sonnet4.modelID) + expect(inCategory(options, "Kilo Gateway")).toContain("openai/gpt-5") + }) + + test("filtering by provider name does not leak other providers", () => { + const options = build({ query: "kilo" }) + + expect(options.every((option) => option.providerID === "kilo")).toBe(true) + }) + + test("keeps the Kilo Gateway section populated after a Bedrock model is used", () => { + const options = build({ recents: [bedrockSonnet] }) + + expect(inCategory(options, "Recent")).toEqual([bedrockSonnet.modelID]) + expect(inCategory(options, "Kilo Gateway")).toEqual(["openai/gpt-5"]) + expect(inCategory(options, RECOMMENDED_CATEGORY)).toEqual([sonnet45.modelID, sonnet4.modelID]) + expect(inCategory(options, "Amazon Bedrock")).toEqual([bedrockSonnet.modelID]) + }) + + test("still matches model titles", () => { + const options = build({ query: "gpt 5" }) + + expect(options.map((option) => option.modelID)).toEqual(["openai/gpt-5"]) + }) + + test("favorites stay pinned to their own section", () => { + const options = build({ favorites: [sonnet45] }) + + expect(inCategory(options, "Favorites")).toEqual([sonnet45.modelID]) + expect(inCategory(options, RECOMMENDED_CATEGORY)).toEqual([sonnet4.modelID]) + }) + + test("drops the extra sections when they are not rendered", () => { + const options = buildModelPickerOptions({ + providers, + connected: false, + showExtra: false, + recents: [sonnet45], + favorites: [sonnet4], + query: "sonnet", + }) + + expect(options.every((option) => option.category === undefined)).toBe(true) + expect(options.map((option) => option.modelID)).toContain(sonnet45.modelID) + expect(options.map((option) => option.modelID)).toContain(sonnet4.modelID) + }) +})