fix(tui): keep Kilo Gateway models visible in the model picker (#13170)

* 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.

* chore: add changeset for TUI gateway picker

* fix(tui): identify the selected picker row by reference

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.
This commit is contained in:
Johnny Eric Amancio
2026-08-18 13:25:46 +02:00
committed by GitHub
parent 1b0e9404b3
commit 3acb1ec386
5 changed files with 343 additions and 113 deletions
+5
View File
@@ -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.
+18 -109
View File
@@ -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 = <U extends { title: string; category?: string }>(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,
+186
View File
@@ -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<M extends ModelPickerModel = ModelPickerModel> {
id: string
name: string
models: Record<string, M>
}
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<T>(needle: string, items: readonly T[], keys: string[]): T[] {
return fuzzysort.go(needle, items as T[], { keys }).map((result) => result.obj)
}
export function rankModelOptions<T extends ModelPickerOption>(needle: string, items: readonly T[]): T[] {
return rank(needle, items, MODEL_SEARCH_KEYS)
}
export function rankProviderOptions<T extends { title: string; category?: string }>(
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<M extends ModelPickerModel> {
providers: readonly ModelPickerProvider<M>[]
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<M extends ModelPickerModel>(
input: BuildModelPickerOptionsInput<M>,
): 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<M>,
modelID: string,
model: M,
extra: Partial<ModelPickerOption>,
): 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]
}
+9 -4
View File
@@ -349,7 +349,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
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<T>(props: DialogSelectProps<T>) {
</Show>
<For each={options}>
{(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 (
<box
@@ -673,13 +678,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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)
}}
@@ -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<typeof build>, 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)
})
})