diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 3395a0c3387a..6e557221cc3d 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { useI18n } from '@/i18n' import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { currentPickerSelection } from '@/lib/model-status-label' +import { modelSearchText } from '@/lib/model-search-text' import { normalize } from '@/lib/text' import type { ModelOptionProvider, ModelPricing } from '@/types/hermes' @@ -172,7 +173,7 @@ function ModelResults({ const matches = (provider: ModelOptionProvider, model: string) => !q || - model.toLowerCase().includes(q) || + modelSearchText(model).toLowerCase().includes(q) || provider.name.toLowerCase().includes(q) || provider.slug.toLowerCase().includes(q) diff --git a/apps/desktop/src/lib/model-search-text.ts b/apps/desktop/src/lib/model-search-text.ts new file mode 100644 index 000000000000..a370182abe26 --- /dev/null +++ b/apps/desktop/src/lib/model-search-text.ts @@ -0,0 +1,28 @@ +/** + * Extra tokens used only for model-picker search ranking. + * + * Wire IDs stay unchanged — some providers report short or brand-less ids + * (Kimi Coding's flagship is literally `k3`) that users still search for by + * the familiar `kimi-…` naming of sibling models. + * + * Keep in sync with ui-tui/src/lib/model-search-text.ts, + * web/src/lib/model-search-text.ts, and hermes_cli/model_search.py. + */ +const MODEL_SEARCH_ALIASES: Record = { + k3: ['kimi-k3', 'kimi'] +} + +/** Haystack for fuzzy/substring model search; never changes the wire id. */ +export function modelSearchText(model: string): string { + const id = model.trim() + if (!id) { + return model + } + + const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()] + if (!aliases?.length) { + return id + } + + return `${id} ${aliases.join(' ')}` +} diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 0400e7678ffa..98b354715f80 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -7341,6 +7341,22 @@ def _prompt_model_selection( desc_lines.append(f" ── {unavailable_footer} ──") description = "\n".join(desc_lines) if desc_lines else None + # Search haystacks keep pricing labels visible while adding aliases + # for brand-less wire ids (e.g. Kimi Coding `k3` ↔ query "kimi"). + from hermes_cli.model_search import model_search_text + + model_search_labels = [] + for mid in ordered: + label = _label(mid) + haystack = model_search_text(mid) + # model_search_text always starts with the wire id; only append when + # aliases add tokens beyond the bare id already in the label. + model_search_labels.append( + label if haystack == mid else f"{label} {haystack}" + ) + model_search_labels.append("Enter custom model name") + model_search_labels.append("Skip (keep current)") + idx = curses_radiolist( "Select default model:", choices, @@ -7348,6 +7364,7 @@ def _prompt_model_selection( cancel_returns=-1, description=description, searchable=True, + search_labels=model_search_labels, ) if idx < 0: return None diff --git a/hermes_cli/curses_ui.py b/hermes_cli/curses_ui.py index 09f5821541b6..301b2295594c 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -723,6 +723,7 @@ def curses_radiolist( cancel_returns: int | None = None, description: str | None = None, searchable: bool = False, + search_labels: List[str] | None = None, ) -> int: """Curses single-select radio list. Returns the selected index. @@ -741,6 +742,8 @@ def curses_radiolist( searchable: When true, ``/`` opens a type-to-filter prompt. The returned value is always the original item index, not a filtered row position. + search_labels: Optional haystacks for type-to-filter (length must + match ``items``). Defaults to the display labels when omitted. """ if cancel_returns is None: cancel_returns = selected @@ -812,7 +815,11 @@ def curses_radiolist( fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns), cancel_value=cancel_returns, searchable=searchable, - search_labels=plain_labels if searchable else None, + search_labels=( + list(search_labels) + if searchable and search_labels is not None + else (plain_labels if searchable else None) + ), ) diff --git a/hermes_cli/model_search.py b/hermes_cli/model_search.py new file mode 100644 index 000000000000..74b110fdd5f6 --- /dev/null +++ b/hermes_cli/model_search.py @@ -0,0 +1,30 @@ +"""Picker-only search aliases for model ids. + +Wire IDs stay unchanged. Some providers report short or brand-less ids +(Kimi Coding's flagship is literally ``k3``) that users still search for by +the familiar ``kimi-…`` naming of sibling models. + +Keep in sync with ``ui-tui/src/lib/model-search-text.ts`` and +``web/src/lib/model-search-text.ts``. +""" + +from __future__ import annotations + +# Lowercased wire id → extra tokens appended to the search haystack only. +_MODEL_SEARCH_ALIASES: dict[str, tuple[str, ...]] = { + "k3": ("kimi-k3", "kimi"), +} + + +def model_search_text(model: str) -> str: + """Return the haystack used for fuzzy/substring model search. + + Never changes the wire id passed to the provider. + """ + mid = (model or "").strip() + if not mid: + return model or "" + aliases = _MODEL_SEARCH_ALIASES.get(mid.lower()) + if not aliases: + return mid + return f"{mid} {' '.join(aliases)}" diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index e52d12718922..df95434c4a30 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -6,6 +6,7 @@ import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' import type { GatewayClient } from '../gatewayClient.js' import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js' import { fuzzyRank } from '../lib/fuzzy.js' +import { modelSearchText } from '../lib/model-search-text.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' @@ -136,7 +137,9 @@ export function ModelPicker({ return allModels } - return fuzzyRank(allModels, filter, m => m).map(r => r.item) + // modelSearchText adds aliases for brand-less wire ids (e.g. Kimi + // Coding `k3` still matches a "kimi" query). + return fuzzyRank(allModels, filter, modelSearchText).map(r => r.item) }, [allModels, filter, stage]) const models = filteredModels diff --git a/ui-tui/src/lib/model-search-text.ts b/ui-tui/src/lib/model-search-text.ts new file mode 100644 index 000000000000..3495e315ceb4 --- /dev/null +++ b/ui-tui/src/lib/model-search-text.ts @@ -0,0 +1,28 @@ +/** + * Extra tokens used only for model-picker search ranking. + * + * Wire IDs stay unchanged — some providers report short or brand-less ids + * (Kimi Coding's flagship is literally `k3`) that users still search for by + * the familiar `kimi-…` naming of sibling models. + * + * Keep in sync with web/src/lib/model-search-text.ts and + * hermes_cli/model_search.py. + */ +const MODEL_SEARCH_ALIASES: Record = { + k3: ['kimi-k3', 'kimi'], +} + +/** Haystack for fuzzy/substring model search; never changes the wire id. */ +export function modelSearchText(model: string): string { + const id = model.trim() + if (!id) { + return model + } + + const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()] + if (!aliases?.length) { + return id + } + + return `${id} ${aliases.join(' ')}` +} diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index 1b280123c19a..20256cbd6a33 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -12,6 +12,7 @@ import { createPortal } from "react-dom"; import { cn, themedBody } from "@/lib/utils"; import { fuzzyRank } from "@/lib/fuzzy"; import { queryMatchesProviderOnly } from "@/lib/model-picker-filter"; +import { modelSearchText } from "@/lib/model-search-text"; /** * Two-stage model picker modal. @@ -246,16 +247,18 @@ export function ModelPickerDialog(props: Props) { ); // Fuzzy-ranked models carrying the matched character positions so the model - // list can highlight why each entry matched. + // list can highlight why each entry matched. modelSearchText adds aliases + // for brand-less wire ids (e.g. Kimi Coding `k3` ↔ search "kimi"). const filteredModels = useMemo( () => fuzzyRank( models, queryMatchesSelectedProviderOnly ? "" : trimmedQuery, - (m) => m, + modelSearchText, ).map((r) => ({ model: r.item, - positions: r.positions, + // Positions may land in alias suffixes — keep only in-id highlights. + positions: r.positions.filter((i) => i >= 0 && i < r.item.length), })), [models, trimmedQuery, queryMatchesSelectedProviderOnly], ); diff --git a/web/src/lib/model-search-text.ts b/web/src/lib/model-search-text.ts new file mode 100644 index 000000000000..471d80c472c0 --- /dev/null +++ b/web/src/lib/model-search-text.ts @@ -0,0 +1,28 @@ +/** + * Extra tokens used only for model-picker search ranking. + * + * Wire IDs stay unchanged — some providers report short or brand-less ids + * (Kimi Coding's flagship is literally `k3`) that users still search for by + * the familiar `kimi-…` naming of sibling models. + * + * Keep in sync with ui-tui/src/lib/model-search-text.ts and + * hermes_cli/model_search.py. Behavioural tests live in the TUI package. + */ +const MODEL_SEARCH_ALIASES: Record = { + k3: ["kimi-k3", "kimi"], +}; + +/** Haystack for fuzzy/substring model search; never changes the wire id. */ +export function modelSearchText(model: string): string { + const id = model.trim(); + if (!id) { + return model; + } + + const aliases = MODEL_SEARCH_ALIASES[id.toLowerCase()]; + if (!aliases?.length) { + return id; + } + + return `${id} ${aliases.join(" ")}`; +}