Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tui-gateway-model-picker.md
Original file line number Diff line number Diff line change
@@ -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.
127 changes: 18 additions & 109 deletions packages/tui/src/component/dialog-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
186 changes: 186 additions & 0 deletions packages/tui/src/kilocode/model-picker.ts
Original file line number Diff line number Diff line change
@@ -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)))),
Comment thread
johnnyeric marked this conversation as resolved.
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]
}
13 changes: 9 additions & 4 deletions packages/tui/src/ui/dialog-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
}}
Expand Down
Loading
Loading