Merge pull request #12943 from Kilo-Org/improve-model-search-ranking

feat(vscode): improve model search ranking
This commit is contained in:
Marius
2026-08-06 14:34:20 +02:00
committed by GitHub
37 changed files with 466 additions and 30 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Improve model search relevance with provider-aware results and personalized usage suggestions.
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:89fa6a619fc2089fd0bb03dabe191394d3dcf3423ba066f1e8f2e2c6aac38838
size 1085
+4
View File
@@ -89,6 +89,7 @@ import {
} from "./services/autocomplete/settings"
import { routeEarlyMessage } from "./kilo-provider/early-message"
import * as ModelState from "./kilo-provider/model-state"
import { handleModelUsageMessage } from "./kilo-provider/model-usage"
import { handleForkSession } from "./kilo-provider/fork-session"
import { openConfig } from "./kilo-provider/open-config"
import {
@@ -1025,6 +1026,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
copy: (text) => vscode.env.clipboard.writeText(text),
openSessions: (ids) => this.trackOpenSessions(ids),
speechToTextModels: () => this.fetchAndSendSpeechToTextModels(),
modelUsage: (msg) => handleModelUsageMessage(msg, this.extensionContext, (value) => this.postMessage(value)),
})
) {
return
@@ -4057,6 +4059,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Clear globalState items that are not part of the configuration
await this.extensionContext?.globalState.update("variantSelections", undefined)
await this.extensionContext?.globalState.update("recentModels", undefined)
await this.extensionContext?.globalState.update("modelUsage", undefined)
await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", undefined)
await this.extensionContext?.globalState.update("kilo.agentMigrationBannerDismissed", undefined)
await this.extensionContext?.globalState.update("kilo.marketplace.dismissedSuggestions", undefined)
@@ -4074,6 +4077,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Re-send globalState items to the webview
this.postMessage({ type: "variantsLoaded", variants: {} })
this.postMessage({ type: "recentsLoaded", recents: [] })
this.postMessage({ type: "modelUsageLoaded", usage: {} })
// Re-fetch notifications to reflect cleared dismissed IDs
await this.fetchAndSendNotifications()
@@ -6,6 +6,7 @@ import type { SuggestionContext } from "./handlers/suggestion"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { buildChatSettingsMessage } from "./chat-settings"
import { buildThroughputSettingMessage } from "./throughput-settings"
import { handleModelUsageMessage, type ModelUsageMessage } from "./model-usage"
type Ctx = {
question: SuggestionContext
@@ -18,6 +19,7 @@ type Ctx = {
copy: (text: string) => PromiseLike<void>
openSessions: (ids: string[]) => void
speechToTextModels: () => Promise<void>
modelUsage: (message: ModelUsageMessage) => Promise<void>
}
export async function routeEarlyMessage(
@@ -42,6 +44,10 @@ export async function routeEarlyMessage(
)
return true
}
if (message.type === "recordModelUsage" || message.type === "requestModelUsage") {
await ctx.modelUsage(message as ModelUsageMessage)
return true
}
await routeSuggestionWebviewMessage(ctx.question, message)
if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true
if (message.type === "exportSessionTranscript") {
@@ -0,0 +1,65 @@
const LIMIT = 200
export type ModelUsageMap = Record<string, { count: number; lastUsed: number }>
export type ModelUsageMessage =
| { type: "recordModelUsage"; providerID: string; modelID: string }
| { type: "requestModelUsage" }
function valid(value: unknown): value is { count: number; lastUsed: number } {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
const item = value as Record<string, unknown>
const count = item.count
const lastUsed = item.lastUsed
return (
typeof count === "number" &&
Number.isFinite(count) &&
count > 0 &&
typeof lastUsed === "number" &&
Number.isFinite(lastUsed)
)
}
export function validateModelUsage(raw: unknown): ModelUsageMap {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}
const entries = Object.entries(raw as Record<string, unknown>).flatMap(([key, value]) =>
valid(value) ? [[key, value] as const] : [],
)
return Object.fromEntries(
entries
.sort(([, a], [, b]) => b.lastUsed - a.lastUsed)
.slice(0, LIMIT)
.map(([key, value]) => [
key,
{
count: Math.floor(value.count),
lastUsed: value.lastUsed,
},
]),
)
}
export function recordModelUsage(raw: unknown, providerID: unknown, modelID: unknown, now = Date.now()): ModelUsageMap {
if (typeof providerID !== "string" || !providerID || typeof modelID !== "string" || !modelID) {
return validateModelUsage(raw)
}
const usage = validateModelUsage(raw)
const key = `${providerID}/${modelID}`
const current = usage[key] ?? { count: 0, lastUsed: 0 }
usage[key] = { count: current.count + 1, lastUsed: now }
return validateModelUsage(usage)
}
export async function handleModelUsageMessage(
message: ModelUsageMessage,
context:
| { globalState: { get: (key: string) => unknown; update: (key: string, value: unknown) => Thenable<void> } }
| undefined,
post: (message: unknown) => void,
): Promise<void> {
const current = context?.globalState.get("modelUsage")
const usage =
message.type === "recordModelUsage"
? recordModelUsage(current, message.providerID, message.modelID)
: validateModelUsage(current)
if (message.type === "recordModelUsage") await context?.globalState.update("modelUsage", usage)
post({ type: "modelUsageLoaded", usage })
}
@@ -80,17 +80,18 @@ test("auto efficient details show server description and model choices", async (
await expect(preview).not.toContainText("openai/gpt-5.5")
})
test("typing a provider initial moves the active descendant to matching results", async ({ page }) => {
test("search uses a flat relevance-ranked result list with provider labels", async ({ page }) => {
await load(page, "shared--model-selector-accessible")
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
await combobox.fill("N")
await combobox.fill("nov")
const nova = page.getByRole("treeitem", { name: "Nova" })
await expect(nova).toBeVisible()
await expect(combobox).toHaveAttribute("aria-activedescendant", await nova.getAttribute("id"))
await expect(page.getByRole("treeitem", { name: "NVIDIA" })).toHaveAttribute("aria-expanded", "true")
await expect(page.locator(".model-selector-group-label").filter({ hasText: "NVIDIA" })).toHaveCount(0)
await expect(nova).toContainText("NVIDIA")
})
test("provider groups collapse, expand, and skip their model rows", async ({ page }) => {
@@ -141,12 +142,13 @@ test("active descendant always identifies a visible tree item", async ({ page })
await active()
await combobox.fill("N")
await active()
await combobox.press("ArrowLeft")
await combobox.press("ArrowDown")
await combobox.press("ArrowLeft")
await active()
await combobox.fill("no matching model")
await active()
await expect(combobox).toHaveAttribute(
"aria-activedescendant",
await page.getByRole("treeitem", { name: "Use default model" }).getAttribute("id"),
)
})
test("expanded preview waits for explicit pointer selection", async ({ page }) => {
@@ -13,7 +13,10 @@ import {
isAuto,
autoSummary,
autoChoices,
rankModelSearch,
mostUsedModels,
} from "../../webview-ui/src/components/shared/model-selector-utils"
import type { EnrichedModel } from "../../webview-ui/src/context/provider"
const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" }
@@ -169,6 +172,51 @@ describe("autoSummary", () => {
})
})
const SEARCH_MODELS: EnrichedModel[] = [
{ id: "solar-pro", name: "Solar Pro", providerID: "nvidia", providerName: "NVIDIA" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", providerID: "openai", providerName: "OpenAI" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", providerID: "kilo", providerName: "Kilo" },
{ id: "gpt-5.6", name: "GPT-5.6", providerID: "anthropic", providerName: "Anthropic" },
]
describe("rankModelSearch", () => {
it("prefers an exact model token over a longer prefix match", () => {
expect(
rankModelSearch(SEARCH_MODELS, "sol")
.slice(0, 2)
.map((model) => model.name),
).toEqual(["GPT-5.6 Sol", "GPT-5.6 Sol"])
})
it("keeps provider variants together and uses usage to order equivalent variants", () => {
const result = rankModelSearch(SEARCH_MODELS, "sol", {
usage: { "kilo/gpt-5.6-sol": { count: 4, lastUsed: 10 }, "openai/gpt-5.6-sol": { count: 1, lastUsed: 20 } },
})
expect(result.slice(0, 2).map((model) => model.providerID)).toEqual(["kilo", "openai"])
})
it("does not let usage make a weaker model beat an exact match", () => {
const result = rankModelSearch(SEARCH_MODELS, "sol", {
usage: { "nvidia/solar-pro": { count: 1000, lastUsed: 100 } },
})
expect(result[0]?.name).toBe("GPT-5.6 Sol")
})
})
describe("mostUsedModels", () => {
it("orders suggestions by personal count and excludes favorites", () => {
const result = mostUsedModels(
SEARCH_MODELS,
{
"nvidia/solar-pro": { count: 2, lastUsed: 20 },
"openai/gpt-5.6-sol": { count: 5, lastUsed: 10 },
},
new Set(["openai/gpt-5.6-sol"]),
)
expect(result.map((model) => model.providerID)).toEqual(["nvidia"])
})
})
describe("isDataCollectedModel", () => {
it("uses only explicit prompt training metadata", () => {
expect(isDataCollectedModel({ mayTrainOnYourPrompts: true })).toBe(true)
@@ -0,0 +1,20 @@
import { describe, expect, it } from "bun:test"
import { recordModelUsage, validateModelUsage } from "../../src/kilo-provider/model-usage"
describe("model usage history", () => {
it("increments a model and updates its last-used timestamp", () => {
expect(recordModelUsage({ "openai/gpt": { count: 2, lastUsed: 10 } }, "openai", "gpt", 20)).toEqual({
"openai/gpt": { count: 3, lastUsed: 20 },
})
})
it("drops malformed entries and caps persisted history", () => {
const raw = Object.fromEntries(
Array.from({ length: 205 }, (_, index) => [`provider/model-${index}`, { count: 1, lastUsed: index }]),
)
const result = validateModelUsage({ ...raw, invalid: { count: 0, lastUsed: 1 } })
expect(Object.keys(result)).toHaveLength(200)
expect(result["provider/model-204"]).toEqual({ count: 1, lastUsed: 204 })
expect(result.invalid).toBeUndefined()
})
})
@@ -45,9 +45,10 @@ import {
autoSummary,
buildTriggerLabel,
sanitizeName,
mostUsedModels,
rankModelSearch,
} from "./model-selector-utils"
import { ModelPreview } from "./ModelPreview"
import { searchMatch } from "../../utils/search-match"
// ---------------------------------------------------------------------------
// Row / group key helpers — single source of truth for key formatting
@@ -57,6 +58,7 @@ const CLEAR_KEY = "clear"
const FAVORITES_KEY = "favorites"
const AUTO_KEY = "auto"
const RECOMMENDED_KEY = "recommended"
const MOST_USED_KEY = "most-used"
function modelKey(providerID: string, modelID: string) {
return `${providerID}/${modelID}`
@@ -82,7 +84,7 @@ interface ModelRow {
interface ModelGroup {
key: string
label: string
label?: string
rows: ModelRow[]
}
@@ -161,6 +163,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const expanded = vscode.getModelSelectorExpanded
const setExpanded = vscode.setModelSelectorExpanded
const [search, setSearch] = createSignal("")
const hasSearch = () => search().trim().length > 0
const [selectedKey, setSelectedKey] = createSignal(CLEAR_KEY)
const [browsing, setBrowsing] = createSignal(false)
const [navigating, setNavigating] = createSignal(false)
@@ -234,9 +237,11 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
if (!q) {
return visibleModels()
}
return visibleModels().filter(
(m) => searchMatch(q, m.name) || searchMatch(q, m.id) || searchMatch(q, m.providerName),
)
return rankModelSearch(visibleModels(), q, {
usage: session?.modelUsageHistory(),
favorites: new Set(session?.favoriteModels().map((item) => modelKey(item.providerID, item.modelID))),
recent: session?.recentModels(),
})
})
// Live set of favorited keys — drives star icon visual state (filled vs outline).
@@ -249,7 +254,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const favoriteModels = createMemo(() => {
if (props.favorites === false) return []
if (!session || search()) return []
if (!session || hasSearch()) return []
const map = new Map(visibleModels().map((m) => [modelKey(m.providerID, m.id), m]))
const list = session
.favoriteModels()
@@ -267,13 +272,30 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const groups = createMemo<ModelGroup[]>(() => {
const autos: EnrichedModel[] = []
const recommended: EnrichedModel[] = []
const mostUsed: EnrichedModel[] = []
const map = new Map<string, EnrichedModel[]>()
if (!hasSearch() && session) {
mostUsed.push(
...mostUsedModels(
visibleModels().filter((model) => !isAuto(model) && model.recommendedIndex === undefined),
session.modelUsageHistory(),
favoriteKeys(),
),
)
}
for (const m of filtered()) {
if (isAuto(m)) {
autos.push(m)
continue
}
if (
!hasSearch() &&
mostUsed.some((item) => modelKey(item.providerID, item.id) === modelKey(m.providerID, m.id))
) {
continue
}
if (m.recommendedIndex !== undefined) {
recommended.push(m)
continue
@@ -328,6 +350,18 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
})
}
if (mostUsed.length > 0) {
result.push({
key: MOST_USED_KEY,
label: language.t("model.group.mostUsed"),
rows: mostUsed.map((m) => ({
key: rowKey("model", m.providerID, m.id),
kind: "model",
model: m,
})),
})
}
const rest: ModelGroup[] = [...map.entries()]
.sort(([a], [b]) => providerSortKey(a) - providerSortKey(b))
.map(([id, list]) => {
@@ -343,11 +377,24 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
}
})
if (hasSearch()) {
if (filtered().length === 0) return []
return [
{
key: "search-results",
rows: filtered().map((m) => ({
key: rowKey("model", m.providerID, m.id),
kind: "model",
model: m,
})),
},
]
}
return [...result, ...rest]
})
// Collapse state is honored even during search so users can skip past
// large providers (e.g. Kilo Gateway) without scrolling through every match.
// Search results are flattened so matching provider variants stay adjacent.
const isGroupOpen = (key: string) => !collapsed().has(key)
function toggleGroup(key: string) {
@@ -366,7 +413,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const rows = createMemo<ModelRow[]>(() => {
const c = collapsed()
const list = groups().flatMap((g) => (c.has(g.key) ? [] : g.rows))
const list = groups().flatMap((g) => (hasSearch() || !c.has(g.key) ? g.rows : []))
if (!props.allowClear) return list
return [{ key: CLEAR_KEY, kind: "clear" }, ...list]
})
@@ -375,6 +422,10 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const result: ModelNode[] = []
if (props.allowClear) result.push({ key: CLEAR_KEY, kind: "row", row: { key: CLEAR_KEY, kind: "clear" } })
for (const group of groups()) {
if (hasSearch()) {
result.push(...group.rows.map((row) => ({ key: row.key, kind: "row" as const, row, group })))
continue
}
result.push({ key: groupKey(group.key), kind: "group", group })
if (!isGroupOpen(group.key)) continue
result.push(...group.rows.map((row) => ({ key: row.key, kind: "row" as const, row, group })))
@@ -398,7 +449,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
if (!m) return props.allowClear ? CLEAR_KEY : defaultKey()
const key = modelKey(m.providerID, m.id)
const favorite = favoriteKey(m)
if (!search() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite
if (!hasSearch() && favoriteKeys().has(key) && rowMap().has(favorite)) return favorite
return canonicalKey(m)
}
const chosen = (row: ModelRow) => {
@@ -461,15 +512,17 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const match = list[0]
const first = match ? canonicalKey(match) : null
const next =
canon && rowMap().has(canon)
? canon
: first && rowMap().has(first)
? first
: props.allowClear
? CLEAR_KEY
: defaultKey()
hasSearch() && first
? first
: canon && rowMap().has(canon)
? canon
: first && rowMap().has(first)
? first
: props.allowClear
? CLEAR_KEY
: defaultKey()
setSelectedKey(next)
setBrowsing(!!search() && nodeMap().has(next))
setBrowsing(hasSearch() && (!!first || props.allowClear === true))
setNavigating(false)
setPreActiveKey(next)
setPreviewKey(next)
@@ -612,9 +665,10 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
}
function horizontal(step: -1 | 1) {
if (hasSearch()) return
const node = nodeMap().get(selectedKey())
if (!node) return
if (node.kind === "group" && node.group) {
if (node.kind === "group" && node.group && node.group.label) {
if (step === -1 && isGroupOpen(node.group.key)) {
toggleGroup(node.group.key)
return
@@ -925,7 +979,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
<path d="M4 6l4 5 4-5H4z" />
</svg>
<span>{group.label}</span>
<Show when={!shown() && !!search()}>
<Show when={!shown() && hasSearch()}>
<span class="model-selector-group-match-dot" aria-hidden="true" />
</Show>
</div>
@@ -957,7 +1011,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const hovered = () => isSelected(row.key)
const preActive = () => isPreActive(row.key)
const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id))
const showProvider = () => row.kind === "favorite"
const showProvider = () => row.kind === "favorite" || hasSearch()
const showSelect = () => expanded() && preActive() && !isActive(model)
const starLabel = () =>
`${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}`
@@ -1,5 +1,6 @@
import type { ModelSelection } from "../../types/messages"
import type { ModelSelection, ModelUsageMap } from "../../types/messages"
import type { EnrichedModel } from "../../context/provider"
import { searchMatch } from "../../utils/search-match"
import {
KILO_PROVIDER_ID as KILO_GATEWAY_ID,
PROVIDER_PRIORITY as PROVIDER_ORDER,
@@ -68,6 +69,140 @@ export function freeDataLabel(_free: string, data: string): string {
return data
}
export function modelSelectionKey(providerID: string, modelID: string): string {
return `${providerID}/${modelID}`
}
function collapse(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "")
}
function words(value: string): string[] {
return value
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean)
}
function tokenScore(token: string, value: string): number {
const list = words(value)
if (list.includes(token)) return 1000
if (list.some((word) => word.startsWith(token))) return 700
if (collapse(value).includes(collapse(token))) return 400
if (searchMatch(token, value)) return 250
return -1
}
function matchScore(model: EnrichedModel, query: string): number | undefined {
const name = stripSubProviderPrefix(sanitizeName(model.name))
const tokens = words(query)
if (tokens.length === 0) return 0
const scores = tokens.map((token) => {
const modelScore = Math.max(tokenScore(token, name), tokenScore(token, model.id))
const providerScore = modelScore < 0 ? tokenScore(token, model.providerName) : -1
return { modelScore, providerScore }
})
if (scores.some((score) => score.modelScore < 0 && score.providerScore < 0)) return undefined
const modelScore = scores.reduce((sum, score) => sum + Math.max(score.modelScore, 0), 0)
const providerScore = scores.reduce((sum, score) => sum + Math.max(score.providerScore, 0), 0)
const exact = collapse(query) === collapse(name) || collapse(query) === collapse(model.id)
return modelScore + Math.floor(providerScore / 10) + (exact ? 5000 : 0)
}
function logicalModelKey(model: EnrichedModel): string {
return collapse(stripSubProviderPrefix(sanitizeName(model.name))) || collapse(model.id)
}
function usageFor(model: EnrichedModel, usage: ModelUsageMap | undefined) {
return usage?.[modelSelectionKey(model.providerID, model.id)] ?? { count: 0, lastUsed: 0 }
}
export interface ModelSearchOptions {
usage?: ModelUsageMap
favorites?: ReadonlySet<string>
recent?: readonly ModelSelection[]
}
/**
* Ranks matching models globally instead of sorting each provider independently.
* Exact model tokens beat prefixes such as "sol" in "solar", while personal
* usage only breaks ties between similarly relevant matches.
*/
export function rankModelSearch(
models: readonly EnrichedModel[],
query: string,
options: ModelSearchOptions = {},
): EnrichedModel[] {
const groups = new Map<
string,
{
key: string
score: number
count: number
lastUsed: number
items: Array<{ model: EnrichedModel; score: number; count: number; lastUsed: number }>
}
>()
const recent = new Map(
(options.recent ?? []).map((item, index) => [modelSelectionKey(item.providerID, item.modelID), index]),
)
for (const model of models) {
const score = matchScore(model, query)
if (score === undefined) continue
const usage = usageFor(model, options.usage)
const key = logicalModelKey(model)
const group = groups.get(key) ?? { key, score, count: 0, lastUsed: 0, items: [] }
group.score = Math.max(group.score, score)
group.count += usage.count
group.lastUsed = Math.max(group.lastUsed, usage.lastUsed)
group.items.push({ model, score, count: usage.count, lastUsed: usage.lastUsed })
groups.set(key, group)
}
return [...groups.values()]
.sort((a, b) => b.score - a.score || b.count - a.count || b.lastUsed - a.lastUsed || a.key.localeCompare(b.key))
.flatMap((group) =>
group.items
.sort(
(a, b) =>
b.score - a.score ||
b.count - a.count ||
b.lastUsed - a.lastUsed ||
(options.favorites?.has(modelSelectionKey(b.model.providerID, b.model.id)) ? 1 : 0) -
(options.favorites?.has(modelSelectionKey(a.model.providerID, a.model.id)) ? 1 : 0) ||
(recent.get(modelSelectionKey(a.model.providerID, a.model.id)) ?? Infinity) -
(recent.get(modelSelectionKey(b.model.providerID, b.model.id)) ?? Infinity) ||
providerSortKey(a.model.providerID) - providerSortKey(b.model.providerID) ||
a.model.providerName.localeCompare(b.model.providerName) ||
a.model.name.localeCompare(b.model.name) ||
a.model.id.localeCompare(b.model.id),
)
.map((item) => item.model),
)
}
export function mostUsedModels(
models: readonly EnrichedModel[],
usage: ModelUsageMap | undefined,
favorites: ReadonlySet<string> = new Set(),
limit = 5,
): EnrichedModel[] {
return models
.filter((model) => {
const item = usageFor(model, usage)
return item.count > 0 && !favorites.has(modelSelectionKey(model.providerID, model.id))
})
.sort((a, b) => {
const left = usageFor(a, usage)
const right = usageFor(b, usage)
return right.count - left.count || right.lastUsed - left.lastUsed || a.name.localeCompare(b.name)
})
.slice(0, limit)
}
// Strips trailing "(free)" parenthesized suffix from model display names, e.g.
// "Llama 3 (free)" → "Llama 3". A separate "Free" label/tag is rendered
// elsewhere, so preserve bare trailing "Free" words (e.g. "Kilo Auto Free").
@@ -40,6 +40,7 @@ import type {
SuggestionRequest,
TodoItem,
ModelSelection,
ModelUsageMap,
ContextUsage,
AgentInfo,
SkillInfo,
@@ -122,6 +123,7 @@ interface SessionStore {
variantSelections: Record<string, string> // session/agent scoped variant key -> variant name
recentModels: ModelSelection[]
favoriteModels: ModelSelection[]
modelUsageHistory: ModelUsageMap
modelUsage: Record<string, { requestID: string; data?: SessionModelUsage }>
}
@@ -244,6 +246,8 @@ interface SessionContextValue {
selectVariant: (value: string, sessionID?: string) => void
// Model favorites
recentModels: Accessor<ModelSelection[]>
modelUsageHistory: Accessor<ModelUsageMap>
favoriteModels: Accessor<ModelSelection[]>
toggleFavorite: (providerID: string, modelID: string) => void
@@ -511,7 +515,6 @@ export const SessionProvider: ParentComponent = (props) => {
)
}
// Store for sessions, messages, parts, todos, modelSelections, agentSelections
const [store, setStore] = createStore<SessionStore>({
sessions: {},
messages: {},
@@ -524,6 +527,7 @@ export const SessionProvider: ParentComponent = (props) => {
variantSelections: {},
recentModels: [],
favoriteModels: [],
modelUsageHistory: {},
modelUsage: {},
})
const [modelUsageReady, setModelUsageReady] = createSignal(false)
@@ -632,6 +636,14 @@ export const SessionProvider: ParentComponent = (props) => {
vscode.postMessage({ type: "persistRecents", recents: updated })
}
function recordModelUsage(providerID?: string, modelID?: string) {
if (!providerID || !modelID) return
const key = `${providerID}/${modelID}`
const current = store.modelUsageHistory[key] ?? { count: 0, lastUsed: 0 }
setStore("modelUsageHistory", key, { count: current.count + 1, lastUsed: Date.now() })
vscode.postMessage({ type: "recordModelUsage", providerID, modelID })
}
function applyModel(agentName: string, selection: ModelSelection, sessionID?: string) {
pushRecent(selection)
if (sessionID) {
@@ -965,6 +977,12 @@ export const SessionProvider: ParentComponent = (props) => {
vscode.postMessage({ type: "requestRecents" })
onCleanup(unsubRecents)
const unsubModelUsage = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "modelUsageLoaded") return
setStore("modelUsageHistory", message.usage)
})
vscode.postMessage({ type: "requestModelUsage" })
onCleanup(unsubModelUsage)
// Load persisted favorite models from extension globalState
const unsubFavorites = vscode.onMessage((message: ExtensionMessage) => {
if (message.type !== "favoritesLoaded") return
@@ -2288,6 +2306,8 @@ export const SessionProvider: ParentComponent = (props) => {
const messageID = Identifier.ascending("message")
const sid = origin === undefined ? currentSessionID() : (origin ?? undefined)
const selection = providerID && modelID ? { providerID, modelID } : selected(sid)
recordModelUsage(selection?.providerID, selection?.modelID)
const preview = sid?.startsWith("cloud:")
? sid.slice("cloud:".length)
: origin === undefined
@@ -2364,6 +2384,8 @@ export const SessionProvider: ParentComponent = (props) => {
// Cloud previews need import-then-command; post importAndSend with command metadata
const sid = origin === undefined ? currentSessionID() : (origin ?? undefined)
const selection = providerID && modelID ? { providerID, modelID } : selected(sid)
recordModelUsage(selection?.providerID, selection?.modelID)
const preview = sid?.startsWith("cloud:")
? sid.slice("cloud:".length)
: origin === undefined
@@ -3025,6 +3047,8 @@ export const SessionProvider: ParentComponent = (props) => {
allMessages,
allParts,
allStatusMap,
recentModels: () => store.recentModels,
modelUsageHistory: () => store.modelUsageHistory,
favoriteModels: () => store.favoriteModels,
toggleFavorite,
variantList,
+1
View File
@@ -158,6 +158,7 @@ export const dict = {
"model.group.auto": "النماذج التلقائية",
"model.group.recommended": "موصى به",
"model.group.favorites": "المفضلة",
"model.group.mostUsed": "الأكثر استخدامًا",
"model.favorite.add": "إضافة إلى المفضلة",
"model.favorite.remove": "إزالة من المفضلة",
"model.preview.label.released": "الإصدار",
+1
View File
@@ -162,6 +162,7 @@ export const dict = {
"model.group.auto": "Modelos automáticos",
"model.group.recommended": "Recomendado",
"model.group.favorites": "Favoritos",
"model.group.mostUsed": "Mais usados",
"model.favorite.add": "Adicionar aos favoritos",
"model.favorite.remove": "Remover dos favoritos",
"model.preview.label.released": "Lançado",
+1
View File
@@ -163,6 +163,7 @@ export const dict = {
"model.group.auto": "Automatski modeli",
"model.group.recommended": "Preporučeno",
"model.group.favorites": "Favoriti",
"model.group.mostUsed": "Najčešće korišteni",
"model.favorite.add": "Dodaj u favorite",
"model.favorite.remove": "Ukloni iz favorita",
"model.preview.label.released": "Objavljeno",
+1
View File
@@ -162,6 +162,7 @@ export const dict = {
"model.group.auto": "Automatiske modeller",
"model.group.recommended": "Anbefalet",
"model.group.favorites": "Favoritter",
"model.group.mostUsed": "Mest brugte",
"model.favorite.add": "Føj til favoritter",
"model.favorite.remove": "Fjern fra favoritter",
@@ -169,6 +169,7 @@ export const dict = {
"model.group.auto": "Automatische Modelle",
"model.group.recommended": "Empfohlen",
"model.group.favorites": "Favoriten",
"model.group.mostUsed": "Am häufigsten verwendet",
"model.favorite.add": "Zu Favoriten hinzufügen",
"model.favorite.remove": "Aus Favoriten entfernen",
@@ -162,6 +162,7 @@ export const dict = {
"model.group.auto": "Auto Models",
"model.group.recommended": "Recommended",
"model.group.favorites": "Favorites",
"model.group.mostUsed": "Most used",
"model.favorite.add": "Add to favorites",
"model.favorite.remove": "Remove from favorites",
"model.preview.label.released": "Released",
+1
View File
@@ -164,6 +164,7 @@ export const dict = {
"model.group.auto": "Modelos automáticos",
"model.group.recommended": "Recomendado",
"model.group.favorites": "Favoritos",
"model.group.mostUsed": "Más usados",
"model.favorite.add": "Añadir a favoritos",
"model.favorite.remove": "Eliminar de favoritos",
+1
View File
@@ -163,6 +163,7 @@ export const dict = {
"model.group.auto": "مدل‌های خودکار",
"model.group.recommended": "پیشنهادی",
"model.group.favorites": "موردعلاقه‌ها",
"model.group.mostUsed": "پراستفاده‌ترین",
"model.favorite.add": "افزودن به موردعلاقه‌ها",
"model.favorite.remove": "حذف از موردعلاقه‌ها",
"model.preview.label.released": "منتشر شده",
+1
View File
@@ -163,6 +163,7 @@ export const dict = {
"model.group.auto": "Modèles automatiques",
"model.group.recommended": "Recommandé",
"model.group.favorites": "Favoris",
"model.group.mostUsed": "Les plus utilisés",
"model.favorite.add": "Ajouter aux favoris",
"model.favorite.remove": "Retirer des favoris",
+1
View File
@@ -120,6 +120,7 @@ export const dict = {
"model.group.auto": "Modelli automatici",
"model.group.recommended": "Consigliati",
"model.group.favorites": "Preferiti",
"model.group.mostUsed": "Più usati",
"model.favorite.add": "Aggiungi ai preferiti",
"model.favorite.remove": "Rimuovi dai preferiti",
"model.preview.label.released": "Rilasciato",
+1
View File
@@ -162,6 +162,7 @@ export const dict = {
"model.group.auto": "自動モデル",
"model.group.recommended": "推奨",
"model.group.favorites": "お気に入り",
"model.group.mostUsed": "よく使うモデル",
"model.favorite.add": "お気に入りに追加",
"model.favorite.remove": "お気に入りから削除",
+1
View File
@@ -166,6 +166,7 @@ export const dict = {
"model.group.auto": "자동 모델",
"model.group.recommended": "추천",
"model.group.favorites": "즐겨찾기",
"model.group.mostUsed": "가장 많이 사용됨",
"model.favorite.add": "즐겨찾기에 추가",
"model.favorite.remove": "즐겨찾기에서 제거",
+1
View File
@@ -164,6 +164,7 @@ export const dict = {
"model.group.auto": "Automatische modellen",
"model.group.recommended": "Aanbevolen",
"model.group.favorites": "Favorieten",
"model.group.mostUsed": "Meest gebruikt",
"model.favorite.add": "Toevoegen aan favorieten",
"model.favorite.remove": "Verwijderen uit favorieten",
"model.preview.label.released": "Uitgebracht",
+1
View File
@@ -165,6 +165,7 @@ export const dict = {
"model.group.auto": "Automatiske modeller",
"model.group.recommended": "Anbefalt",
"model.group.favorites": "Favoritter",
"model.group.mostUsed": "Mest brukt",
"model.favorite.add": "Legg til i favoritter",
"model.favorite.remove": "Fjern fra favoritter",
"model.preview.label.released": "Utgitt",
+1
View File
@@ -162,6 +162,7 @@ export const dict = {
"model.group.auto": "Modele automatyczne",
"model.group.recommended": "Zalecane",
"model.group.favorites": "Ulubione",
"model.group.mostUsed": "Najczęściej używane",
"model.favorite.add": "Dodaj do ulubionych",
"model.favorite.remove": "Usuń z ulubionych",
"model.preview.label.released": "Wydano",
+1
View File
@@ -162,6 +162,7 @@ export const dict = {
"model.group.auto": "Автоматические модели",
"model.group.recommended": "Рекомендуемые",
"model.group.favorites": "Избранное",
"model.group.mostUsed": "Часто используемые",
"model.favorite.add": "Добавить в избранное",
"model.favorite.remove": "Удалить из избранного",
"model.preview.label.released": "Выпущена",
+1
View File
@@ -161,6 +161,7 @@ export const dict = {
"model.group.auto": "โมเดลอัตโนมัติ",
"model.group.recommended": "แนะนำ",
"model.group.favorites": "รายการโปรด",
"model.group.mostUsed": "ใช้บ่อยที่สุด",
"model.favorite.add": "เพิ่มในรายการโปรด",
"model.favorite.remove": "ลบออกจากรายการโปรด",
"model.preview.label.released": "เปิดตัว",
+1
View File
@@ -162,6 +162,7 @@ export const dict = {
"model.group.auto": "Otomatik Modeller",
"model.group.recommended": "Önerilen",
"model.group.favorites": "Favoriler",
"model.group.mostUsed": "En çok kullanılan",
"model.favorite.add": "Favorilere ekle",
"model.favorite.remove": "Favorilerden çıkar",
"model.preview.label.released": "Yayınlanma",
+1
View File
@@ -163,6 +163,7 @@ export const dict = {
"model.group.auto": "Автоматичні моделі",
"model.group.recommended": "Рекомендовані",
"model.group.favorites": "Обране",
"model.group.mostUsed": "Найчастіше використовувані",
"model.favorite.add": "Додати до обраного",
"model.favorite.remove": "Видалити з обраного",
"model.preview.label.released": "Випущено",
+1
View File
@@ -158,6 +158,7 @@ export const dict = {
"model.group.auto": "自动模型",
"model.group.recommended": "推荐",
"model.group.favorites": "收藏夹",
"model.group.mostUsed": "最常用",
"model.favorite.add": "添加到收藏夹",
"model.favorite.remove": "从收藏夹中移除",
+1
View File
@@ -158,6 +158,7 @@ export const dict = {
"model.group.auto": "自動模型",
"model.group.recommended": "推薦",
"model.group.favorites": "我的最愛",
"model.group.mostUsed": "最常用",
"model.favorite.add": "加入我的最愛",
"model.favorite.remove": "從我的最愛中移除",
@@ -254,6 +254,8 @@ export function mockSessionValue(overrides?: {
revertSession: noop,
unrevertSession: noop,
favoriteModels: () => [],
recentModels: () => [],
modelUsageHistory: () => ({}),
toggleFavorite: noop,
variantList: () => [],
currentVariant: () => undefined,
@@ -131,6 +131,28 @@ export const ModelSelectorSelectedFavorite: Story = {
},
}
export const ModelSelectorMostUsed: Story = {
name: "ModelSelector - most used suggestions",
render: () => {
const session = {
...mockSessionValue(),
modelUsageHistory: () => ({
"kilo/alpha": { count: 3, lastUsed: 100 },
"kilo/bravo": { count: 12, lastUsed: 200 },
"nvidia/nova": { count: 7, lastUsed: 300 },
}),
}
return (
<StoryProviders>
<SessionContext.Provider value={session as any}>
<AccessibleModelSelector />
</SessionContext.Provider>
</StoryProviders>
)
},
}
const LARGE_MODELS: EnrichedModel[] = Array.from({ length: 600 }, (_, i) => {
const id = String(i).padStart(3, "0")
const provider = `provider-${i % 12}`
@@ -18,7 +18,7 @@ import type { AgentManagerSidebarTarget } from "./webview-messages"
import type { PermissionRequest } from "./permissions"
import type { AnacondaDesktopExtensionMessage } from "../../../../src/shared/anaconda-desktop-messages"
import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions"
import type { ModelSelection, Provider, ProviderAuthState } from "./providers"
import type { ModelSelection, ModelUsageMap, Provider, ProviderAuthState } from "./providers"
import type { SpeechToTextModelDef } from "../../../../src/speech-to-text/models"
import type { AgentInfo, AgentRequirementResult, SkillInfo, SlashCommandInfo } from "./agents"
import type {
@@ -920,6 +920,11 @@ export interface RecentsLoadedMessage {
recents: ModelSelection[]
}
export interface ModelUsageLoadedMessage {
type: "modelUsageLoaded"
usage: ModelUsageMap
}
// Persisted model-selector expand/collapse preference (extension → webview)
export interface ModelSelectorExpandedLoadedMessage {
type: "modelSelectorExpandedLoaded"
@@ -1298,6 +1303,7 @@ export type ExtensionMessage =
| MessagesLoadedMessage
| SessionModelUsageLoadedMessage
| SessionModelUsageChangedMessage
| ModelUsageLoadedMessage
| MessageCreatedMessage
| SessionsLoadedMessage
| CloudSessionsLoadedMessage
@@ -53,6 +53,13 @@ export interface ModelSelection {
modelID: string
}
export interface ModelUsage {
count: number
lastUsed: number
}
export type ModelUsageMap = Record<string, ModelUsage>
export type ProviderAuthState = "api" | "oauth" | "wellknown"
export interface ProviderConfig {
@@ -1242,6 +1242,16 @@ export interface RequestRecentsMessage {
type: "requestRecents"
}
export interface RecordModelUsageMessage {
type: "recordModelUsage"
providerID: string
modelID: string
}
export interface RequestModelUsageMessage {
type: "requestModelUsage"
}
export interface PersistModelSelectorExpandedRequest {
type: "persistModelSelectorExpanded"
value: boolean
@@ -1559,6 +1569,8 @@ export type WebviewMessage =
| FetchCustomProviderModelsMessage
| PersistRecentsRequest
| RequestRecentsMessage
| RecordModelUsageMessage
| RequestModelUsageMessage
| PersistModelSelectorExpandedRequest
| RequestModelSelectorExpandedMessage
| ToggleFavoriteRequest