From 6742eb9b7dcd30c0290a927659b6065112eaa2bc Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Thu, 13 Aug 2026 18:44:20 +0200 Subject: [PATCH 1/3] fix(tui): keep Kilo Gateway models visible in the model picker Selecting a model removed it from its provider section, so a recently used Kilo sonnet vanished from "Recommended"/"Kilo Gateway" and only survived under "Recent". Search also keyed off title and section header only, so filtering by `kilo` never matched titles like "Anthropic Claude Sonnet 4.5" grouped under "Recommended". Recents now stay in their provider section (favorites are still deduped into their own section) and search additionally keys off the provider name, provider id, and model id, matching the VS Code selector. Option building moves to kilocode/model-picker.ts so the grouping and search rules are unit testable. --- packages/tui/src/component/dialog-model.tsx | 127 ++---------- packages/tui/src/kilocode/model-picker.ts | 186 ++++++++++++++++++ .../tui/test/kilocode/model-picker.test.ts | 125 ++++++++++++ 3 files changed, 329 insertions(+), 109 deletions(-) create mode 100644 packages/tui/src/kilocode/model-picker.ts create mode 100644 packages/tui/test/kilocode/model-picker.test.ts 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/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) + }) +}) From 942d9ce21127766a7f8b8155a8d7cdba0f1ee0aa Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Mon, 17 Aug 2026 13:47:32 +0200 Subject: [PATCH 2/3] chore: add changeset for TUI gateway picker --- .changeset/tui-gateway-model-picker.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tui-gateway-model-picker.md 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. From b2eef2a408e37cb1f573160afe1376aab8431ad0 Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Tue, 18 Aug 2026 11:31:25 +0200 Subject: [PATCH 3/3] fix(tui): identify the selected picker row by reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeping recents in their provider section means a model can legitimately appear twice in the list (once under "Recent", once under its provider or "Recommended" section). DialogSelect resolved the selected row by value equality, so both copies lit up as active whenever either was selected — visible on every picker open, since the current model is usually a recent — and hovering the provider-section copy warped selection and scroll to the "Recent" copy instead. Match the selected row by object reference instead. Rows are always distinct objects, so this is a strict refinement for every other dialog; `current` still uses value equality, which is correct — both rows really are the current model. --- packages/tui/src/ui/dialog-select.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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) }}