diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 37de510c6537a..fcd1e63e14cf3 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 { 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' @@ -171,7 +172,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 0000000000000..a370182abe261 --- /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 13f7cf362f6b9..803c204c1be56 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -6939,6 +6939,22 @@ def _label(mid): 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, @@ -6946,6 +6962,7 @@ def _label(mid): 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 acaa614b0673a..e03a70f473b6d 100644 --- a/hermes_cli/curses_ui.py +++ b/hermes_cli/curses_ui.py @@ -627,6 +627,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. @@ -641,6 +642,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 @@ -709,7 +712,11 @@ def _on_action(action, cursor): fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns), cancel_value=cancel_returns, searchable=searchable, - search_labels=list(items) if searchable else None, + search_labels=( + list(search_labels) + if searchable and search_labels is not None + else (list(items) if searchable else None) + ), ) diff --git a/hermes_cli/model_search.py b/hermes_cli/model_search.py new file mode 100644 index 0000000000000..74b110fdd5f62 --- /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/tests/hermes_cli/test_model_search.py b/tests/hermes_cli/test_model_search.py new file mode 100644 index 0000000000000..1b7728a14a1e1 --- /dev/null +++ b/tests/hermes_cli/test_model_search.py @@ -0,0 +1,28 @@ +"""Picker search aliases for brand-less wire model ids.""" + +from hermes_cli.curses_ui import _filter_indices +from hermes_cli.model_search import model_search_text + + +def test_model_search_text_keeps_ordinary_ids(): + assert model_search_text("kimi-k2.6") == "kimi-k2.6" + assert model_search_text("glm-5.2") == "glm-5.2" + + +def test_model_search_text_adds_kimi_aliases_for_k3(): + assert model_search_text("k3") == "k3 kimi-k3 kimi" + assert model_search_text("K3") == "K3 kimi-k3 kimi" + + +def test_filter_indices_surfaces_k3_for_kimi_query(): + models = ["kimi-k2.6", "kimi-k2.5", "k3", "kimi-for-coding"] + haystacks = [model_search_text(m) for m in models] + ranked = [models[i] for i in _filter_indices(haystacks, "kimi")] + assert "k3" in ranked + + +def test_filter_indices_still_finds_k3_by_wire_id(): + models = ["kimi-k2.6", "k3", "kimi-for-coding"] + haystacks = [model_search_text(m) for m in models] + ranked = [models[i] for i in _filter_indices(haystacks, "k3")] + assert ranked == ["k3"] diff --git a/tests/hermes_cli/test_setup_menu_curses_migration.py b/tests/hermes_cli/test_setup_menu_curses_migration.py index 46b1515bf79e8..6e8e687d017d6 100644 --- a/tests/hermes_cli/test_setup_menu_curses_migration.py +++ b/tests/hermes_cli/test_setup_menu_curses_migration.py @@ -13,9 +13,19 @@ def test_prompt_model_selection_uses_curses_radiolist(): seen = {} - def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False): + def _fake( + title, + items, + *, + selected=0, + cancel_returns=None, + description=None, + searchable=False, + search_labels=None, + ): seen["title"] = title seen["items"] = items + seen["search_labels"] = search_labels return 1 # pick second model with patch("hermes_cli.curses_ui.curses_radiolist", side_effect=_fake), \ @@ -27,6 +37,8 @@ def _fake(title, items, *, selected=0, cancel_returns=None, description=None, se # Items are the models plus the custom/skip entries. assert seen["items"][:2] == ["model-a", "model-b"] assert "Skip (keep current)" in seen["items"] + assert seen["search_labels"] is not None + assert len(seen["search_labels"]) == len(seen["items"]) def test_prompt_model_selection_esc_cancels(): @@ -67,8 +79,18 @@ def test_model_selection_with_pricing_passes_description(): seen = {} - def _fake(title, items, *, selected=0, cancel_returns=None, description=None, searchable=False): + def _fake( + title, + items, + *, + selected=0, + cancel_returns=None, + description=None, + searchable=False, + search_labels=None, + ): seen["description"] = description + seen["search_labels"] = search_labels return len(items) - 1 # Skip pricing = { diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 3be32d2112991..d31ea1f60ac4a 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' @@ -132,7 +133,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.test.ts b/ui-tui/src/lib/model-search-text.test.ts new file mode 100644 index 0000000000000..9f8d3a0c5b36d --- /dev/null +++ b/ui-tui/src/lib/model-search-text.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' + +import { fuzzyRank } from './fuzzy.js' +import { modelSearchText } from './model-search-text.js' + +describe('modelSearchText', () => { + it('keeps ordinary model ids unchanged', () => { + expect(modelSearchText('kimi-k2.6')).toBe('kimi-k2.6') + expect(modelSearchText('glm-5.2')).toBe('glm-5.2') + }) + + it('adds kimi aliases for the bare Kimi Coding k3 wire id', () => { + expect(modelSearchText('k3')).toBe('k3 kimi-k3 kimi') + expect(modelSearchText('K3')).toBe('K3 kimi-k3 kimi') + }) +}) + +describe('model picker search with aliases', () => { + const models = [ + 'kimi-k2.6', + 'kimi-k2.5', + 'k3', + 'kimi-for-coding', + ] + + it('surfaces k3 when the user searches kimi', () => { + const ranked = fuzzyRank(models, 'kimi', modelSearchText).map(r => r.item) + expect(ranked).toContain('k3') + }) + + it('still finds k3 by its wire id', () => { + const ranked = fuzzyRank(models, 'k3', modelSearchText).map(r => r.item) + expect(ranked).toEqual(['k3']) + }) + + it('does not invent k3 for unrelated queries', () => { + const ranked = fuzzyRank(models, 'glm', modelSearchText).map(r => r.item) + expect(ranked).toEqual([]) + }) +}) 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 0000000000000..3495e315ceb4a --- /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 b05e389bdb40c..4cd02c71ab366 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { cn, themedBody } from "@/lib/utils"; import { fuzzyRank } from "@/lib/fuzzy"; +import { modelSearchText } from "@/lib/model-search-text"; /** * Two-stage model picker modal. @@ -227,12 +228,14 @@ 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, trimmedQuery, (m) => m).map((r) => ({ + fuzzyRank(models, trimmedQuery, 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], ); diff --git a/web/src/lib/model-search-text.ts b/web/src/lib/model-search-text.ts new file mode 100644 index 0000000000000..471d80c472c07 --- /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(" ")}`; +}