mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 02:05:57 +00:00
opentui(v6): model picker v2 — fuzzy search + provider groups + instant open
This commit is contained in:
parent
43b096eedb
commit
91df325458
9 changed files with 808 additions and 60 deletions
|
|
@ -28,7 +28,7 @@
|
|||
* TODO(upstream): file/track an OpenTUI issue to widen these FFI params to i32
|
||||
* (or clamp in core) — then this shim can be deleted.
|
||||
*/
|
||||
import { OptimizedBuffer } from '@opentui/core'
|
||||
import { OptimizedBuffer, TextBufferView } from '@opentui/core'
|
||||
|
||||
let installed = false
|
||||
|
||||
|
|
@ -84,4 +84,17 @@ export function installFfiCoordSafety(): void {
|
|||
if (x < 0 || y < 0) return
|
||||
origDrawChar.call(this, char, x, y, ...rest)
|
||||
}
|
||||
|
||||
// Same u32 marshaling on a different entry point: `textBufferViewSetViewport`
|
||||
// takes x/y/width/height as u32, but `TextRenderable.onResize` feeds it the
|
||||
// RAW transient layout size — observed NON-u32 (negative/NaN) mid-relayout
|
||||
// while a shrinking list (the fuzzy picker filtering rows away) reflows. Bun
|
||||
// wraps/coerces, node:ffi throws (`Argument 3 must be a uint32`). Coerce into
|
||||
// the valid quadrant; a zero-sized viewport is the native side's own no-op.
|
||||
const u32 = (v: number) => (Number.isFinite(v) ? Math.max(0, Math.trunc(v)) : 0)
|
||||
const viewProto = TextBufferView.prototype
|
||||
const origSetViewport = viewProto.setViewport
|
||||
viewProto.setViewport = function (this: TextBufferView, x, y, width, height) {
|
||||
origSetViewport.call(this, u32(x), u32(y), u32(width), u32(height))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,14 @@ import { envFlag } from '../logic/env.ts'
|
|||
import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts'
|
||||
import { createPasteStore } from '../logic/pastes.ts'
|
||||
import { mapResumeHistory, mapSessionList } from '../logic/resume.ts'
|
||||
import { dispatchSlash, mapCompletions, planCompletion, readReplaceFrom, type SlashContext } from '../logic/slash.ts'
|
||||
import {
|
||||
dispatchSlash,
|
||||
mapCompletions,
|
||||
mapModelOptions,
|
||||
planCompletion,
|
||||
readReplaceFrom,
|
||||
type SlashContext
|
||||
} from '../logic/slash.ts'
|
||||
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
||||
import { App } from '../view/App.tsx'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
|
|
@ -170,6 +177,17 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp
|
|||
store.pushUser(prompt)
|
||||
yield* gateway.request('prompt.submit', { session_id: sid, text: prompt })
|
||||
}
|
||||
|
||||
// Prefetch the /model catalog (Epic 7 instant open): `model.options` is the
|
||||
// slow RPC (it does network calls — pricing fetch + Nous tier check), so pay
|
||||
// that cost ONCE here in this already-forked bootstrap fiber; `/model` then
|
||||
// paints from memory on the same frame. Best-effort: if this hasn't landed
|
||||
// (or the RPC is missing/old), /model falls back to fetching on first open.
|
||||
const modelOpts = yield* gateway
|
||||
.request<unknown>('model.options', { session_id: sid })
|
||||
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
const modelItems = mapModelOptions(modelOpts)
|
||||
if (modelItems.length) store.setModelItems(modelItems)
|
||||
}).pipe(Effect.catchCause(cause => Effect.sync(() => getLog().warn('bootstrap', 'failed', { cause: String(cause) }))))
|
||||
|
||||
/** The entry Effect. Mirrors opencode `app.tsx:177` `run = Effect.fn("Tui.run")`. */
|
||||
|
|
@ -357,6 +375,8 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
return true
|
||||
},
|
||||
listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList),
|
||||
modelItems: () => store.state.modelItems,
|
||||
setModelItems: items => store.setModelItems(items),
|
||||
logTail: () =>
|
||||
getLog()
|
||||
.tail(200)
|
||||
|
|
|
|||
163
ui-opentui/src/logic/fuzzy.ts
Normal file
163
ui-opentui/src/logic/fuzzy.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* fuzzy.ts — pure fuzzy filtering + grouped presentation for picker overlays
|
||||
* (Epic 7 model picker v2). No deps: a ~subsequence scorer in the spirit of
|
||||
* opencode's fuzzysort usage (multi-key scoring, title weighted 2×, grouped
|
||||
* headers) at the scale we need (≤ a few hundred catalog rows).
|
||||
*
|
||||
* Scoring model (per term, per field): a case-insensitive subsequence match
|
||||
* where consecutive runs, string-prefix and word-boundary starts score high and
|
||||
* gaps/late starts are penalized — so for `son`: `sonnet` (prefix) >
|
||||
* `claude-sonnet` (boundary) > `meson` (scattered). A query is split on
|
||||
* whitespace; EVERY term must match at least one field (best field wins, its
|
||||
* weight applied), so `anthropic son` works across provider+model fields.
|
||||
*/
|
||||
|
||||
/** One searchable field of an item (e.g. model id ×2, provider slug, lab name). */
|
||||
export interface FuzzyField {
|
||||
text: string
|
||||
/** Score multiplier (default 1). The primary label is conventionally 2. */
|
||||
weight?: number
|
||||
}
|
||||
|
||||
/** Word-boundary characters inside catalog-ish ids/names. */
|
||||
const BOUNDARY = new Set([' ', '-', '_', '.', '/', ':', '@', '(', ')'])
|
||||
|
||||
/** Cap on alternative start positions tried for the first term char. */
|
||||
const MAX_STARTS = 8
|
||||
|
||||
/** Greedy subsequence score from a fixed start index; null when it can't match. */
|
||||
function scoreFrom(term: string, hay: string, start: number): number | null {
|
||||
let score = 0
|
||||
let prev = -1
|
||||
let from = start
|
||||
for (let qi = 0; qi < term.length; qi++) {
|
||||
const idx = hay.indexOf(term.charAt(qi), from)
|
||||
if (idx === -1) return null
|
||||
let charScore = 1
|
||||
if (prev !== -1 && idx === prev + 1) charScore += 3 // consecutive run
|
||||
if (idx === 0)
|
||||
charScore += 6 // string prefix
|
||||
else if (BOUNDARY.has(hay.charAt(idx - 1))) charScore += 4 // word boundary
|
||||
if (prev !== -1 && idx > prev + 1) charScore -= Math.min(idx - prev - 1, 3) // gap
|
||||
if (prev === -1) charScore -= Math.min(idx, 4) // late start
|
||||
score += charScore
|
||||
prev = idx
|
||||
from = idx + 1
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
/**
|
||||
* Score one term against one text. Null = the term is not a subsequence.
|
||||
* Greedy from the first occurrence is order-sensitive (`son` in `saturn-sonnet`
|
||||
* must anchor at the second `s`), so try each occurrence of the first term char
|
||||
* (capped) and keep the best.
|
||||
*/
|
||||
export function scoreTerm(term: string, text: string): number | null {
|
||||
const needle = term.toLowerCase()
|
||||
if (!needle) return 0
|
||||
const hay = text.toLowerCase()
|
||||
let best: number | null = null
|
||||
let start = hay.indexOf(needle.charAt(0))
|
||||
for (let tries = 0; start !== -1 && tries < MAX_STARTS; tries++) {
|
||||
const s = scoreFrom(needle, hay, start)
|
||||
if (s !== null && (best === null || s > best)) best = s
|
||||
start = hay.indexOf(needle.charAt(0), start + 1)
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a whitespace-split query against an item's fields. Every term must
|
||||
* match at least one field; each term contributes its best weighted field
|
||||
* score. Empty/blank query scores 0 (matches everything — catalog order).
|
||||
*/
|
||||
export function scoreFields(query: string, fields: readonly FuzzyField[]): number | null {
|
||||
const terms = query.trim().split(/\s+/).filter(Boolean)
|
||||
if (!terms.length) return 0
|
||||
let total = 0
|
||||
for (const term of terms) {
|
||||
let best: number | null = null
|
||||
for (const field of fields) {
|
||||
const s = scoreTerm(term, field.text)
|
||||
if (s === null) continue
|
||||
const weighted = s * (field.weight ?? 1)
|
||||
if (best === null || weighted > best) best = weighted
|
||||
}
|
||||
if (best === null) return null
|
||||
total += best
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter + rank items by query. Empty query → the items in catalog order;
|
||||
* otherwise matches sorted by score (descending), ties keeping catalog order.
|
||||
*/
|
||||
export function fuzzyFilter<T>(query: string, items: readonly T[], fieldsOf: (item: T) => FuzzyField[]): T[] {
|
||||
if (!query.trim()) return [...items]
|
||||
const scored: Array<{ item: T; score: number; at: number }> = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i] as T
|
||||
const score = scoreFields(query, fieldsOf(item))
|
||||
if (score !== null) scored.push({ at: i, item, score })
|
||||
}
|
||||
scored.sort((a, b) => b.score - a.score || a.at - b.at)
|
||||
return scored.map(s => s.item)
|
||||
}
|
||||
|
||||
/** A render row of a grouped picker: a non-selectable group header or an item.
|
||||
* `index` is the item's position in the flat ARROW-TRAVERSAL order. */
|
||||
export type PickerRow<T> = { kind: 'header'; label: string } | { kind: 'item'; item: T; index: number }
|
||||
|
||||
/**
|
||||
* Group items for display (group order = first appearance, so a score-sorted
|
||||
* input puts the best group first). Returns the header+item render rows and
|
||||
* the flat selectable list in traversal order — arrows walk `flat` and thus
|
||||
* cross group boundaries seamlessly; headers are never selectable. Items
|
||||
* without a group render headerless (e.g. the skills picker).
|
||||
*/
|
||||
export function buildPickerRows<T>(
|
||||
items: readonly T[],
|
||||
groupOf: (item: T) => string | undefined
|
||||
): { rows: PickerRow<T>[]; flat: T[] } {
|
||||
const order: string[] = []
|
||||
const buckets = new Map<string, T[]>()
|
||||
for (const item of items) {
|
||||
const group = groupOf(item) ?? ''
|
||||
let bucket = buckets.get(group)
|
||||
if (!bucket) {
|
||||
bucket = []
|
||||
buckets.set(group, bucket)
|
||||
order.push(group)
|
||||
}
|
||||
bucket.push(item)
|
||||
}
|
||||
const rows: PickerRow<T>[] = []
|
||||
const flat: T[] = []
|
||||
for (const group of order) {
|
||||
if (group) rows.push({ kind: 'header', label: group })
|
||||
for (const item of buckets.get(group) ?? []) {
|
||||
rows.push({ index: flat.length, item, kind: 'item' })
|
||||
flat.push(item)
|
||||
}
|
||||
}
|
||||
return { flat, rows }
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice rows to a visible window of at most `cap` rows that keeps the selected
|
||||
* item in view (centered when possible). `above`/`below` are the hidden row
|
||||
* counts for the ↑/↓ "more" indicators.
|
||||
*/
|
||||
export function visibleRows<T>(
|
||||
rows: readonly PickerRow<T>[],
|
||||
selected: number,
|
||||
cap: number
|
||||
): { rows: PickerRow<T>[]; above: number; below: number } {
|
||||
if (rows.length <= cap) return { above: 0, below: 0, rows: [...rows] }
|
||||
const selRow = rows.findIndex(r => r.kind === 'item' && r.index === selected)
|
||||
const anchor = selRow === -1 ? 0 : selRow
|
||||
const start = Math.max(0, Math.min(anchor - Math.floor(cap / 2), rows.length - cap))
|
||||
return { above: start, below: rows.length - (start + cap), rows: rows.slice(start, start + cap) }
|
||||
}
|
||||
|
|
@ -53,6 +53,10 @@ export interface SlashContext {
|
|||
readonly openPicker: (picker: PickerState) => void
|
||||
/** Open the agents dashboard (/agents, /tasks). */
|
||||
readonly openDashboard: () => void
|
||||
/** Cached `/model` picker rows (Epic 7 instant open); undefined until prefetched. */
|
||||
readonly modelItems: () => PickerItem[] | undefined
|
||||
/** Update the cached `/model` picker rows. */
|
||||
readonly setModelItems: (items: PickerItem[]) => void
|
||||
}
|
||||
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
|
|
@ -153,26 +157,55 @@ const openSwitcher: ClientHandler = async (_arg, ctx) => {
|
|||
else ctx.pushSystem('No sessions to resume.')
|
||||
}
|
||||
|
||||
/** Flatten `model.options` (authenticated providers' models) into picker rows; mark the current. */
|
||||
function mapModelOptions(opts: unknown): PickerItem[] {
|
||||
/**
|
||||
* Flatten `model.options` (authenticated providers' models) into grouped picker
|
||||
* rows (Epic 7): group = the provider's display ("lab") name, haystacks = slug +
|
||||
* lab name (so `oai`/`anthropic` fuzzy-match models), value = the FULL switch
|
||||
* arg `<model> --provider <slug>` so picking a model under a different provider
|
||||
* actually switches provider+model (the gateway's `_apply_model_switch` parses
|
||||
* `--provider` via parse_model_flags). The current model is flagged, not baked
|
||||
* into the label, so the fuzzy scorer never matches the ✓.
|
||||
*/
|
||||
export function mapModelOptions(opts: unknown): PickerItem[] {
|
||||
if (!opts || typeof opts !== 'object') return []
|
||||
const providers = (opts as { providers?: unknown }).providers
|
||||
if (!Array.isArray(providers)) return []
|
||||
const current = readStr(opts, 'model')
|
||||
const currentProvider = readStr(opts, 'provider')
|
||||
const items: PickerItem[] = []
|
||||
for (const p of providers) {
|
||||
if (!p || typeof p !== 'object' || (p as { authenticated?: unknown }).authenticated !== true) continue
|
||||
const slug = readStr(p, 'slug') ?? readStr(p, 'name') ?? ''
|
||||
const lab = readStr(p, 'name') ?? slug
|
||||
// The gateway's own normalized "this row is the active provider" flag —
|
||||
// more reliable than comparing `provider` to `slug` (the agent's provider
|
||||
// string can be the API dialect, e.g. an openai-compatible base_url).
|
||||
const rowCurrent = (p as { is_current?: unknown }).is_current === true
|
||||
const models = (p as { models?: unknown }).models
|
||||
if (!Array.isArray(models)) continue
|
||||
for (const m of models) {
|
||||
if (typeof m === 'string') items.push({ description: slug, label: m === current ? `${m} ✓` : m, value: m })
|
||||
if (typeof m !== 'string') continue
|
||||
const item: PickerItem = { label: m, value: slug ? `${m} --provider ${slug}` : m }
|
||||
// current = same model id under the active provider (row flag first,
|
||||
// then the slug comparison, then "no provider known at all").
|
||||
if (m === current && (rowCurrent || currentProvider === slug || !currentProvider)) item.current = true
|
||||
if (lab) item.group = lab
|
||||
const haystacks = [slug, lab].filter(Boolean)
|
||||
if (haystacks.length) item.haystacks = haystacks
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
// Provider matching failed entirely (string-normalization drift) but the
|
||||
// model id is known → flag the first id match so the ✓ never just vanishes.
|
||||
if (current && !items.some(i => i.current)) {
|
||||
const fallback = items.find(i => i.label === current)
|
||||
if (fallback) fallback.current = true
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/** Flatten `skills.manage {action:'list'}` ({skills: Record<category, names[]>}) into picker rows. */
|
||||
/** Flatten `skills.manage {action:'list'}` ({skills: Record<category, names[]>}) into
|
||||
* grouped picker rows (category = group header; also a fuzzy haystack). */
|
||||
function mapSkills(result: unknown): PickerItem[] {
|
||||
if (!result || typeof result !== 'object') return []
|
||||
const skills = (result as { skills?: unknown }).skills
|
||||
|
|
@ -180,33 +213,56 @@ function mapSkills(result: unknown): PickerItem[] {
|
|||
const items: PickerItem[] = []
|
||||
for (const [category, names] of Object.entries(skills as { [k: string]: unknown })) {
|
||||
if (!Array.isArray(names)) continue
|
||||
for (const n of names) if (typeof n === 'string') items.push({ description: category, label: n, value: n })
|
||||
for (const n of names) if (typeof n === 'string') items.push({ group: category, label: n, value: n })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/** Switch the model via the server (shared by `/model <name>` and the picker pick). */
|
||||
/** Re-fetch `model.options` and update the cached picker rows (fire-and-forget). */
|
||||
function refreshModelItems(ctx: SlashContext): Promise<void> {
|
||||
return ctx
|
||||
.request('model.options', { session_id: ctx.sessionId() })
|
||||
.then(opts => {
|
||||
const items = mapModelOptions(opts)
|
||||
if (items.length) ctx.setModelItems(items)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/** Switch the model via the server (shared by `/model <name>` and the picker pick).
|
||||
* A successful switch refreshes the cached rows in the background (fresh ✓). */
|
||||
async function switchModel(ctx: SlashContext, name: string): Promise<void> {
|
||||
try {
|
||||
const r = await ctx.request('slash.exec', { command: `model ${name}`, session_id: ctx.sessionId() })
|
||||
ctx.pushSystem(readStr(r, 'output') || `→ ${name}`)
|
||||
void refreshModelItems(ctx)
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`/model ${name}: ${error instanceof Error ? error.message : 'switch failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `/model` — bare opens the model picker; `/model <name>` switches directly. */
|
||||
/** `/model` — bare opens the model picker; `/model <name>` switches directly.
|
||||
* Opens from the CACHED catalog when present — zero RPCs, same-frame paint
|
||||
* (Epic 7; the catalog is prefetched at bootstrap and refreshed on switch). */
|
||||
const modelCmd: ClientHandler = async (arg, ctx) => {
|
||||
if (arg.trim()) {
|
||||
await switchModel(ctx, arg.trim())
|
||||
return
|
||||
}
|
||||
const items = mapModelOptions(await ctx.request('model.options', {}))
|
||||
const open = (items: PickerItem[]) =>
|
||||
ctx.openPicker({ items, onPick: name => void switchModel(ctx, name), title: 'Switch model' })
|
||||
const cached = ctx.modelItems()
|
||||
if (cached?.length) {
|
||||
open(cached)
|
||||
return
|
||||
}
|
||||
const items = mapModelOptions(await ctx.request('model.options', { session_id: ctx.sessionId() }))
|
||||
if (!items.length) {
|
||||
ctx.pushSystem('No models available (no authenticated providers).')
|
||||
return
|
||||
}
|
||||
ctx.openPicker({ items, onPick: name => void switchModel(ctx, name), title: 'Switch model' })
|
||||
ctx.setModelItems(items)
|
||||
open(items)
|
||||
}
|
||||
|
||||
/** `/skills` — open the skills hub; picking a skill shows its info in the pager. */
|
||||
|
|
|
|||
|
|
@ -98,11 +98,19 @@ export interface SessionItem {
|
|||
messageCount: number
|
||||
}
|
||||
|
||||
/** A row in a generic `<select>` picker (model picker, skills hub, …). */
|
||||
/** A row in the generic picker overlay (model picker, skills hub, …). */
|
||||
export interface PickerItem {
|
||||
label: string
|
||||
description?: string
|
||||
value: string
|
||||
/** Group header this row renders under (e.g. the provider's display name).
|
||||
* Rows without a group render headerless (flat list). */
|
||||
group?: string
|
||||
/** Extra fuzzy-search haystacks beyond label/group/description (e.g. the
|
||||
* provider slug, so `oai` finds openai models). */
|
||||
haystacks?: string[]
|
||||
/** Marks the currently-active row (rendered with a ✓). */
|
||||
current?: boolean
|
||||
}
|
||||
|
||||
/** An open generic picker overlay: a titled list whose pick runs `onPick(value)`. */
|
||||
|
|
@ -194,6 +202,8 @@ export interface StoreState {
|
|||
subagents: SubagentInfo[]
|
||||
/** Whether the agents dashboard overlay is open (/agents). */
|
||||
dashboard: boolean
|
||||
/** Subagent id the dashboard should preselect on open (tray Enter — Epic 2.7). */
|
||||
dashboardAgent: string | undefined
|
||||
/** Transient busy indicator (the kaomoji face/verb from `thinking.delta`/`status.update`);
|
||||
* shown above the composer WHILE a turn runs, cleared on `message.complete`. NOT transcript. */
|
||||
status: string | undefined
|
||||
|
|
@ -204,6 +214,11 @@ export interface StoreState {
|
|||
hint: string | undefined
|
||||
/** Startup tools/skills/MCP catalog (from `startup.catalog`) for the home panel (item 9). */
|
||||
catalog: Catalog | undefined
|
||||
/** Cached `/model` picker rows (mapped `model.options`). Prefetched at session
|
||||
* bootstrap and refreshed after a switch, so `/model` opens INSTANTLY from
|
||||
* memory instead of awaiting the slow RPC (it does network calls: pricing
|
||||
* fetch + Nous tier check) on every open (Epic 7). */
|
||||
modelItems: PickerItem[] | undefined
|
||||
/** The current session id (shown in the home panel; updated on create/resume). */
|
||||
sessionId: string | undefined
|
||||
}
|
||||
|
|
@ -348,10 +363,12 @@ export function createSessionStore() {
|
|||
completionFrom: 0,
|
||||
subagents: [],
|
||||
dashboard: false,
|
||||
dashboardAgent: undefined,
|
||||
status: undefined,
|
||||
info: {},
|
||||
hint: undefined,
|
||||
catalog: undefined,
|
||||
modelItems: undefined,
|
||||
sessionId: undefined
|
||||
})
|
||||
|
||||
|
|
@ -475,12 +492,15 @@ export function createSessionStore() {
|
|||
applied.clear()
|
||||
}
|
||||
|
||||
/** Open / close the agents dashboard overlay (/agents). */
|
||||
function openDashboard() {
|
||||
/** Open / close the agents dashboard overlay (/agents). The optional `agentId`
|
||||
* preselects that subagent's row (the tray's Enter — Epic 2.7). */
|
||||
function openDashboard(agentId?: string) {
|
||||
setState('dashboardAgent', agentId)
|
||||
setState('dashboard', true)
|
||||
}
|
||||
function closeDashboard() {
|
||||
setState('dashboard', false)
|
||||
setState('dashboardAgent', undefined)
|
||||
}
|
||||
|
||||
/** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */
|
||||
|
|
@ -518,6 +538,11 @@ export function createSessionStore() {
|
|||
setState('picker', undefined)
|
||||
}
|
||||
|
||||
/** Cache the mapped `/model` picker rows (instant open — Epic 7). */
|
||||
function setModelItems(items: PickerItem[]) {
|
||||
setState('modelItems', items)
|
||||
}
|
||||
|
||||
/** Set / clear the transient composer hint ("Ctrl+C again to quit" — item 11). */
|
||||
function setHint(text: string | undefined): void {
|
||||
setState('hint', text)
|
||||
|
|
@ -904,6 +929,7 @@ export function createSessionStore() {
|
|||
closeSwitcher,
|
||||
openPicker,
|
||||
closePicker,
|
||||
setModelItems,
|
||||
setCompletions,
|
||||
clearCompletions,
|
||||
applyInfo,
|
||||
|
|
|
|||
147
ui-opentui/src/test/fuzzy.test.ts
Normal file
147
ui-opentui/src/test/fuzzy.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* fuzzy.ts tests (Epic 7) — the pure scorer + filter + grouped-rows helpers
|
||||
* behind the model picker v2: subsequence matching, ranking (prefix >
|
||||
* word-boundary > scattered), multi-field (provider/model/lab), empty query =
|
||||
* catalog order, no-match = empty, header rows non-selectable, and the
|
||||
* flat arrow-traversal order across groups.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { buildPickerRows, fuzzyFilter, scoreFields, scoreTerm, visibleRows, type FuzzyField } from '../logic/fuzzy.ts'
|
||||
|
||||
describe('scoreTerm — subsequence matching', () => {
|
||||
test('matches subsequences (case-insensitive), null when not a subsequence', () => {
|
||||
expect(scoreTerm('son', 'claude-sonnet-4')).not.toBeNull()
|
||||
expect(scoreTerm('son4', 'claude-sonnet-4')).not.toBeNull() // the complaint's example
|
||||
expect(scoreTerm('SON', 'claude-sonnet-4')).not.toBeNull()
|
||||
expect(scoreTerm('xyz', 'claude-sonnet-4')).toBeNull()
|
||||
expect(scoreTerm('sonn5', 'claude-sonnet-4')).toBeNull() // 5 not present after sonn
|
||||
expect(scoreTerm('', 'anything')).toBe(0) // empty term matches everything
|
||||
})
|
||||
|
||||
test('ranking: prefix > word-boundary > scattered', () => {
|
||||
const prefix = scoreTerm('son', 'sonnet')!
|
||||
const boundary = scoreTerm('son', 'claude-sonnet')!
|
||||
const scattered = scoreTerm('son', 'meson')!
|
||||
expect(prefix).toBeGreaterThan(boundary)
|
||||
expect(boundary).toBeGreaterThan(scattered)
|
||||
})
|
||||
|
||||
test('anchors at the BEST occurrence, not greedily at the first', () => {
|
||||
// greedy-from-first-char would match s@0 then o/n far away; the boundary
|
||||
// anchor at the second `s` (start of "sonnet") must win.
|
||||
expect(scoreTerm('son', 'saturn-sonnet')!).toBeGreaterThanOrEqual(scoreTerm('son', 'claude-sonnet')!)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoreFields — multi-field, multi-term', () => {
|
||||
const fields: FuzzyField[] = [
|
||||
{ text: 'claude-sonnet-4', weight: 2 }, // model id (label ×2)
|
||||
{ text: 'anthropic' }, // provider slug
|
||||
{ text: 'Anthropic' } // lab/display name
|
||||
]
|
||||
|
||||
test('a term may match ANY field (provider/model/lab)', () => {
|
||||
expect(scoreFields('son4', fields)).not.toBeNull() // via the model id
|
||||
expect(scoreFields('anthro', fields)).not.toBeNull() // via the provider
|
||||
expect(scoreFields('nope', fields)).toBeNull()
|
||||
})
|
||||
|
||||
test('every whitespace term must match some field (anthropic son works)', () => {
|
||||
expect(scoreFields('anthropic son', fields)).not.toBeNull()
|
||||
expect(scoreFields('anthropic zzz', fields)).toBeNull()
|
||||
})
|
||||
|
||||
test('label matches outrank same-quality group matches (weight 2×)', () => {
|
||||
const labelHit = scoreFields('claude', fields)!
|
||||
const providerHit = scoreFields('claude', [{ text: 'other-model', weight: 2 }, { text: 'claude' }])
|
||||
expect(providerHit).not.toBeNull()
|
||||
expect(labelHit).toBeGreaterThan(providerHit!)
|
||||
})
|
||||
})
|
||||
|
||||
interface Row {
|
||||
label: string
|
||||
provider: string
|
||||
lab: string
|
||||
}
|
||||
const CATALOG: Row[] = [
|
||||
{ lab: 'Anthropic', label: 'claude-sonnet-4', provider: 'anthropic' },
|
||||
{ lab: 'Anthropic', label: 'claude-opus-4', provider: 'anthropic' },
|
||||
{ lab: 'OpenAI', label: 'gpt-5', provider: 'openai' },
|
||||
{ lab: 'Nous Research', label: 'hermes-4-405b', provider: 'nous' }
|
||||
]
|
||||
const rowFields = (r: Row): FuzzyField[] => [{ text: r.label, weight: 2 }, { text: r.provider }, { text: r.lab }]
|
||||
|
||||
describe('fuzzyFilter', () => {
|
||||
test('empty/blank query → catalog order, untouched', () => {
|
||||
expect(fuzzyFilter('', CATALOG, rowFields)).toEqual(CATALOG)
|
||||
expect(fuzzyFilter(' ', CATALOG, rowFields)).toEqual(CATALOG)
|
||||
})
|
||||
|
||||
test('no match → empty', () => {
|
||||
expect(fuzzyFilter('qqqq', CATALOG, rowFields)).toEqual([])
|
||||
})
|
||||
|
||||
test('son4 finds claude-sonnet-4 (under anthropic) first', () => {
|
||||
expect(fuzzyFilter('son4', CATALOG, rowFields)[0]?.label).toBe('claude-sonnet-4')
|
||||
})
|
||||
|
||||
test('oai matches the openai-provider model via the provider field', () => {
|
||||
const hits = fuzzyFilter('oai', CATALOG, rowFields)
|
||||
expect(hits.map(h => h.label)).toContain('gpt-5')
|
||||
})
|
||||
|
||||
test('ties keep catalog order (stable)', () => {
|
||||
const hits = fuzzyFilter('claude', CATALOG, rowFields)
|
||||
expect(hits.map(h => h.label)).toEqual(['claude-sonnet-4', 'claude-opus-4'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPickerRows — grouping + traversal order', () => {
|
||||
test('items group by provider with headers; flat traversal crosses groups', () => {
|
||||
const { flat, rows } = buildPickerRows(CATALOG, r => r.lab)
|
||||
expect(rows.map(r => (r.kind === 'header' ? `# ${r.label}` : r.item.label))).toEqual([
|
||||
'# Anthropic',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4',
|
||||
'# OpenAI',
|
||||
'gpt-5',
|
||||
'# Nous Research',
|
||||
'hermes-4-405b'
|
||||
])
|
||||
// the flat ARROW order is exactly the item rows in render order — so ↓ from
|
||||
// claude-opus-4 lands on gpt-5 (next group) and headers are never selectable.
|
||||
expect(flat.map(f => f.label)).toEqual(['claude-sonnet-4', 'claude-opus-4', 'gpt-5', 'hermes-4-405b'])
|
||||
expect(rows.flatMap(r => (r.kind === 'item' ? [r.index] : []))).toEqual([0, 1, 2, 3])
|
||||
})
|
||||
|
||||
test('ungrouped items render headerless (flat list)', () => {
|
||||
const { rows } = buildPickerRows(CATALOG, () => undefined)
|
||||
expect(rows.every(r => r.kind === 'item')).toBe(true)
|
||||
})
|
||||
|
||||
test('group order = first appearance (score-sorted input → best group first)', () => {
|
||||
const sorted = [CATALOG[2]!, CATALOG[0]!, CATALOG[1]!] // gpt-5 scored best
|
||||
const { rows } = buildPickerRows(sorted, r => r.lab)
|
||||
expect(rows[0]).toEqual({ kind: 'header', label: 'OpenAI' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('visibleRows — selection-following window', () => {
|
||||
const { rows } = buildPickerRows(CATALOG, r => r.lab) // 7 rows
|
||||
|
||||
test('no slicing when everything fits', () => {
|
||||
const w = visibleRows(rows, 0, 12)
|
||||
expect(w.rows).toHaveLength(7)
|
||||
expect(w.above).toBe(0)
|
||||
expect(w.below).toBe(0)
|
||||
})
|
||||
|
||||
test('keeps the selected item in view and reports hidden counts', () => {
|
||||
const w = visibleRows(rows, 3, 4) // last item selected, window of 4
|
||||
expect(w.rows.some(r => r.kind === 'item' && r.index === 3)).toBe(true)
|
||||
expect(w.above + w.below + w.rows.length).toBe(7)
|
||||
expect(w.above).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
170
ui-opentui/src/test/picker.test.tsx
Normal file
170
ui-opentui/src/test/picker.test.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Picker overlay tests (Epic 7 model picker v2) — headless frames with a
|
||||
* simulated keyboard through the REAL component: provider group headers
|
||||
* render, typing filters live (fuzzy, incl. provider-field matches), arrows
|
||||
* traverse the flat item order ACROSS group boundaries (headers skipped),
|
||||
* Enter picks the highlighted value (cross-provider values carry
|
||||
* `--provider`), Esc closes, and a no-match query shows the empty state.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { PickerItem } from '../logic/store.ts'
|
||||
import { DEFAULT_THEME } from '../logic/theme.ts'
|
||||
import { Picker } from '../view/overlays/picker.tsx'
|
||||
import { ThemeProvider } from '../view/theme.tsx'
|
||||
import { renderProbe, type RenderProbe } from './lib/render.ts'
|
||||
|
||||
/** A grouped model catalog: current = claude-sonnet-4 under Anthropic. */
|
||||
const ITEMS: PickerItem[] = [
|
||||
{
|
||||
current: true,
|
||||
group: 'Anthropic',
|
||||
haystacks: ['anthropic', 'Anthropic'],
|
||||
label: 'claude-sonnet-4',
|
||||
value: 'claude-sonnet-4 --provider anthropic'
|
||||
},
|
||||
{
|
||||
group: 'Anthropic',
|
||||
haystacks: ['anthropic', 'Anthropic'],
|
||||
label: 'claude-opus-4',
|
||||
value: 'claude-opus-4 --provider anthropic'
|
||||
},
|
||||
{ group: 'OpenAI', haystacks: ['openai', 'OpenAI'], label: 'gpt-5', value: 'gpt-5 --provider openai' },
|
||||
{
|
||||
group: 'Nous Research',
|
||||
haystacks: ['nous', 'Nous Research'],
|
||||
label: 'hermes-4-405b',
|
||||
value: 'hermes-4-405b --provider nous'
|
||||
}
|
||||
]
|
||||
|
||||
interface Harness {
|
||||
probe: RenderProbe
|
||||
picked: string[]
|
||||
closed: { value: boolean }
|
||||
}
|
||||
|
||||
async function mountPicker(items: PickerItem[] = ITEMS): Promise<Harness> {
|
||||
const picked: string[] = []
|
||||
const closed = { value: false }
|
||||
const probe = await renderProbe(
|
||||
() => (
|
||||
<ThemeProvider theme={() => DEFAULT_THEME}>
|
||||
<Picker title="Switch model" items={items} onPick={v => picked.push(v)} onClose={() => (closed.value = true)} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
// kitty keyboard so a SIMULATED lone Esc parses (see lib/render.ts)
|
||||
{ height: 24, kittyKeyboard: true, width: 70 }
|
||||
)
|
||||
return { closed, picked, probe }
|
||||
}
|
||||
|
||||
describe('Picker — grouped render', () => {
|
||||
test('group headers + items render; the current model carries the ✓', async () => {
|
||||
const h = await mountPicker()
|
||||
try {
|
||||
const frame = h.probe.frame()
|
||||
expect(frame).toContain('Anthropic')
|
||||
expect(frame).toContain('OpenAI')
|
||||
expect(frame).toContain('Nous Research')
|
||||
expect(frame).toContain('claude-sonnet-4 ✓')
|
||||
expect(frame).toContain('hermes-4-405b')
|
||||
// initial selection sits on the CURRENT model
|
||||
expect(frame).toContain('› claude-sonnet-4')
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Picker — fuzzy filtering', () => {
|
||||
test('typing filters live; a provider-field query (oai) keeps only that group', async () => {
|
||||
const h = await mountPicker()
|
||||
try {
|
||||
await h.probe.keys.typeText('oai')
|
||||
await h.probe.settle()
|
||||
const frame = await h.probe.waitForFrame(f => !f.includes('claude-sonnet-4'))
|
||||
expect(frame).toContain('gpt-5')
|
||||
expect(frame).toContain('OpenAI') // its group header survives
|
||||
expect(frame).not.toContain('hermes-4-405b')
|
||||
expect(frame).toContain('› gpt-5') // selection reset to the top match
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('son4 finds claude-sonnet-4; backspace widens the filter again', async () => {
|
||||
const h = await mountPicker()
|
||||
try {
|
||||
await h.probe.keys.typeText('son4')
|
||||
await h.probe.settle()
|
||||
let frame = await h.probe.waitForFrame(f => !f.includes('gpt-5'))
|
||||
expect(frame).toContain('claude-sonnet-4')
|
||||
for (let i = 0; i < 4; i++) h.probe.keys.pressBackspace()
|
||||
await h.probe.settle()
|
||||
frame = await h.probe.waitForFrame(f => f.includes('gpt-5'))
|
||||
expect(frame).toContain('hermes-4-405b')
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('a no-match query shows the empty state; Enter is a no-op', async () => {
|
||||
const h = await mountPicker()
|
||||
try {
|
||||
await h.probe.keys.typeText('zzzz')
|
||||
await h.probe.settle()
|
||||
const frame = await h.probe.waitForFrame(f => f.includes('(no matches)'))
|
||||
expect(frame).not.toContain('claude-sonnet-4')
|
||||
h.probe.keys.pressEnter()
|
||||
await h.probe.settle()
|
||||
expect(h.picked).toEqual([])
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Picker — traversal across groups + pick + close', () => {
|
||||
test('↓↓ from the current item crosses the Anthropic→OpenAI boundary (header skipped); Enter picks cross-provider', async () => {
|
||||
const h = await mountPicker()
|
||||
try {
|
||||
// start: claude-sonnet-4 (flat 0) → ↓ claude-opus-4 (flat 1) → ↓ gpt-5
|
||||
// (flat 2 — FIRST item of the next group; the header row is not a stop)
|
||||
h.probe.keys.pressArrow('down')
|
||||
h.probe.keys.pressArrow('down')
|
||||
await h.probe.settle()
|
||||
expect(h.probe.frame()).toContain('› gpt-5')
|
||||
h.probe.keys.pressEnter()
|
||||
await h.probe.settle()
|
||||
expect(h.picked).toEqual(['gpt-5 --provider openai']) // provider+model switch
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('↑ from the top wraps to the LAST item (across all groups)', async () => {
|
||||
const h = await mountPicker()
|
||||
try {
|
||||
// selection starts on the current item (flat 0)
|
||||
h.probe.keys.pressArrow('up')
|
||||
await h.probe.settle()
|
||||
expect(h.probe.frame()).toContain('› hermes-4-405b')
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test('Esc closes without picking', async () => {
|
||||
const h = await mountPicker()
|
||||
try {
|
||||
h.probe.keys.pressEscape()
|
||||
await h.probe.settle()
|
||||
await h.probe.settle()
|
||||
expect(h.closed.value).toBe(true)
|
||||
expect(h.picked).toEqual([])
|
||||
} finally {
|
||||
h.probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -16,6 +16,22 @@ import type { PickerItem, SessionItem } from '../logic/store.ts'
|
|||
|
||||
const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }]
|
||||
|
||||
/** A `model.options` payload: two authed providers + one unauthenticated. */
|
||||
const MODEL_OPTIONS = {
|
||||
model: 'claude-sonnet-4.6',
|
||||
provider: 'anthropic',
|
||||
providers: [
|
||||
{
|
||||
authenticated: true,
|
||||
models: ['claude-sonnet-4.6', 'claude-opus-4.6'],
|
||||
name: 'Anthropic',
|
||||
slug: 'anthropic'
|
||||
},
|
||||
{ authenticated: true, models: ['hermes-4-405b'], name: 'Nous Research', slug: 'nous' },
|
||||
{ authenticated: false, models: ['gpt-5.4'], name: 'OpenAI', slug: 'openai' }
|
||||
]
|
||||
}
|
||||
|
||||
describe('mapCompletions', () => {
|
||||
test('maps complete.slash items → candidates (display/meta default)', () => {
|
||||
expect(
|
||||
|
|
@ -97,6 +113,8 @@ interface Probe {
|
|||
dashboard: { value: boolean }
|
||||
copied: number[]
|
||||
copyN: { value: (n: number) => boolean }
|
||||
/** The cached /model rows (Epic 7) — seed to simulate a prefetched catalog. */
|
||||
modelCache: { value: PickerItem[] | undefined }
|
||||
}
|
||||
|
||||
function makeCtx(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): Probe {
|
||||
|
|
@ -112,6 +130,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
const dashboard = { value: false }
|
||||
const copied: number[] = []
|
||||
const copyN: Probe['copyN'] = { value: () => false }
|
||||
const modelCache: Probe['modelCache'] = { value: undefined }
|
||||
const ctx: SlashContext = {
|
||||
clearTranscript: () => (cleared.value = true),
|
||||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
||||
|
|
@ -121,6 +140,8 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
},
|
||||
listSessions: () => Promise.resolve(FAKE_SESSIONS),
|
||||
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
||||
modelItems: () => modelCache.value,
|
||||
setModelItems: items => (modelCache.value = items),
|
||||
openDashboard: () => (dashboard.value = true),
|
||||
openPager: (title, text) => paged.push({ text, title }),
|
||||
openPicker: p => pickers.push(p),
|
||||
|
|
@ -142,6 +163,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
copyN,
|
||||
ctx,
|
||||
dashboard,
|
||||
modelCache,
|
||||
paged,
|
||||
pickers,
|
||||
quit,
|
||||
|
|
@ -185,33 +207,73 @@ describe('dispatchSlash — client commands', () => {
|
|||
expect(p2.switched).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('/model (bare) opens a picker of authenticated providers’ models; pick switches', async () => {
|
||||
test('/model (bare) opens a GROUPED picker of authenticated providers’ models; pick switches', async () => {
|
||||
const p = makeCtx(async method => {
|
||||
if (method === 'model.options')
|
||||
return {
|
||||
model: 'claude-sonnet-4.6',
|
||||
providers: [
|
||||
{
|
||||
authenticated: true,
|
||||
models: ['claude-sonnet-4.6', 'claude-opus-4.6'],
|
||||
name: 'Anthropic',
|
||||
slug: 'anthropic'
|
||||
},
|
||||
{ authenticated: false, models: ['gpt-5.4'], name: 'OpenAI', slug: 'openai' }
|
||||
]
|
||||
}
|
||||
if (method === 'model.options') return MODEL_OPTIONS
|
||||
return { output: 'switched' }
|
||||
})
|
||||
await dispatchSlash('/model', p.ctx)
|
||||
expect(p.pickers).toHaveLength(1)
|
||||
expect(p.pickers[0]!.title).toBe('Switch model')
|
||||
// only the authenticated provider's models; current is marked
|
||||
expect(p.pickers[0]!.items.map(i => i.value)).toEqual(['claude-sonnet-4.6', 'claude-opus-4.6'])
|
||||
expect(p.pickers[0]!.items[0]!.label).toContain('✓')
|
||||
// picking switches via slash.exec `model <name>`
|
||||
p.pickers[0]!.onPick('claude-opus-4.6')
|
||||
await Promise.resolve()
|
||||
expect(p.calls.some(c => c.method === 'slash.exec' && c.params.command === 'model claude-opus-4.6')).toBe(true)
|
||||
// only AUTHENTICATED providers' models; values carry the explicit provider so
|
||||
// a pick under a different provider switches provider+model.
|
||||
expect(p.pickers[0]!.items.map(i => i.value)).toEqual([
|
||||
'claude-sonnet-4.6 --provider anthropic',
|
||||
'claude-opus-4.6 --provider anthropic',
|
||||
'hermes-4-405b --provider nous'
|
||||
])
|
||||
// grouped by the provider's display (lab) name; slug+lab are fuzzy haystacks
|
||||
expect(p.pickers[0]!.items.map(i => i.group)).toEqual(['Anthropic', 'Anthropic', 'Nous Research'])
|
||||
expect(p.pickers[0]!.items[2]!.haystacks).toEqual(['nous', 'Nous Research'])
|
||||
// current is FLAGGED (not baked into the label, so fuzzy never matches the ✓)
|
||||
expect(p.pickers[0]!.items[0]!.current).toBe(true)
|
||||
expect(p.pickers[0]!.items[0]!.label).toBe('claude-sonnet-4.6')
|
||||
expect(p.pickers[0]!.items[1]!.current).toBeUndefined()
|
||||
// picking switches via slash.exec `model <model> --provider <slug>`
|
||||
p.pickers[0]!.onPick('claude-opus-4.6 --provider anthropic')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(
|
||||
p.calls.some(c => c.method === 'slash.exec' && c.params.command === 'model claude-opus-4.6 --provider anthropic')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('/model with a CACHED catalog opens instantly — ZERO RPCs on open', async () => {
|
||||
const p = makeCtx(async () => {
|
||||
throw new Error('no RPC expected on open')
|
||||
})
|
||||
p.modelCache.value = [
|
||||
{
|
||||
group: 'Anthropic',
|
||||
haystacks: ['anthropic', 'Anthropic'],
|
||||
label: 'claude-sonnet-4.6',
|
||||
value: 'claude-sonnet-4.6 --provider anthropic'
|
||||
},
|
||||
{
|
||||
group: 'Nous Research',
|
||||
haystacks: ['nous', 'Nous Research'],
|
||||
label: 'hermes-4-405b',
|
||||
value: 'hermes-4-405b --provider nous'
|
||||
}
|
||||
]
|
||||
await dispatchSlash('/model', p.ctx)
|
||||
expect(p.pickers).toHaveLength(1)
|
||||
expect(p.pickers[0]!.items).toHaveLength(2)
|
||||
expect(p.calls).toHaveLength(0) // the whole point: open = memory, not network
|
||||
})
|
||||
|
||||
test('/model uncached fetches ONCE, caches, and a pick refreshes the cache', async () => {
|
||||
const p = makeCtx(async method => (method === 'model.options' ? MODEL_OPTIONS : { output: 'switched' }))
|
||||
await dispatchSlash('/model', p.ctx)
|
||||
expect(p.calls.filter(c => c.method === 'model.options')).toHaveLength(1)
|
||||
expect(p.modelCache.value).toHaveLength(3) // first open seeded the cache
|
||||
// cross-provider pick: switch lands on the gateway, then a background
|
||||
// refresh re-fetches model.options so the cached ✓ stays fresh.
|
||||
p.pickers[0]!.onPick('hermes-4-405b --provider nous')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(
|
||||
p.calls.some(c => c.method === 'slash.exec' && c.params.command === 'model hermes-4-405b --provider nous')
|
||||
).toBe(true)
|
||||
expect(p.calls.filter(c => c.method === 'model.options')).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('/model <name> switches directly without opening the picker', async () => {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,38 @@
|
|||
/**
|
||||
* Picker — a generic titled `<select>` overlay (spec §2b). Powers the model
|
||||
* picker (/model) and skills hub (/skills); the chosen value runs `onPick`.
|
||||
* Native select nav (↑↓/j/k/Enter); a small useKeyboard adds Esc/Ctrl+C close.
|
||||
* Replaces the composer while open.
|
||||
* Picker — the generic fuzzy picker overlay (spec §2b; Epic 7 model picker v2).
|
||||
* Powers /model and /skills: one query line filters live across label AND
|
||||
* group AND extra haystacks (provider slug / lab name — `son4` finds
|
||||
* claude-sonnet-4, `oai` finds openai models); results render GROUPED with
|
||||
* non-selectable headers, and ↑↓ traverse the flat item order seamlessly
|
||||
* ACROSS group boundaries. Enter picks, Esc/Ctrl+C closes (keymap layer),
|
||||
* typing/backspace edits the query (maskedPrompt's own-buffer pattern — no
|
||||
* focused `<input>` feedback loops). Replaces the composer while open.
|
||||
*
|
||||
* Everything heavy is memoized off (query, items): score → group → window, so
|
||||
* keystrokes re-score at most once and unrelated store updates don't.
|
||||
*/
|
||||
import type { BoxRenderable } from '@opentui/core'
|
||||
import { createMemo } from 'solid-js'
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
import { createEffect, createMemo, createSignal, For, on, onMount, Show } from 'solid-js'
|
||||
|
||||
import { buildPickerRows, fuzzyFilter, visibleRows, type FuzzyField } from '../../logic/fuzzy.ts'
|
||||
import type { PickerItem } from '../../logic/store.ts'
|
||||
import { useCloseLayer } from '../keymap.tsx'
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
/** Max visible rows (headers + items) before the window scrolls. */
|
||||
const MAX_ROWS = 12
|
||||
|
||||
/** The fuzzy haystacks of a row: label ×2 (opencode's title weighting), then
|
||||
* group (lab name), description and any extra haystacks (provider slug). */
|
||||
function fieldsOf(item: PickerItem): FuzzyField[] {
|
||||
const fields: FuzzyField[] = [{ text: item.label, weight: 2 }]
|
||||
if (item.group) fields.push({ text: item.group })
|
||||
if (item.description) fields.push({ text: item.description })
|
||||
for (const h of item.haystacks ?? []) fields.push({ text: h })
|
||||
return fields
|
||||
}
|
||||
|
||||
export function Picker(props: {
|
||||
title: string
|
||||
items: PickerItem[]
|
||||
|
|
@ -19,39 +41,108 @@ export function Picker(props: {
|
|||
}) {
|
||||
const theme = useTheme()
|
||||
let rootRef: BoxRenderable | undefined
|
||||
// Native select handles ↑↓/j/k/Enter; the keymap owns Esc/Ctrl+C close.
|
||||
// Esc/Ctrl+C close via the native keymap; the root box is focused on mount so
|
||||
// the focus-within layer is active (the list/query are not focusable).
|
||||
onMount(() => rootRef?.focus())
|
||||
useCloseLayer(
|
||||
() => rootRef,
|
||||
() => props.onClose()
|
||||
)
|
||||
|
||||
const options = createMemo(() =>
|
||||
props.items.map(it => ({ description: it.description ?? '', name: it.label, value: it.value }))
|
||||
const [query, setQuery] = createSignal('')
|
||||
// score → group → window, all memoized: typing re-scores once; nothing else does.
|
||||
const filtered = createMemo(() => fuzzyFilter(query(), props.items, fieldsOf))
|
||||
const grouped = createMemo(() => buildPickerRows(filtered(), it => it.group))
|
||||
|
||||
// Start on the current (✓) item; reset to the top match whenever the filter changes.
|
||||
const [sel, setSel] = createSignal(
|
||||
Math.max(
|
||||
0,
|
||||
grouped().flat.findIndex(it => it.current)
|
||||
)
|
||||
)
|
||||
createEffect(on(filtered, () => setSel(0), { defer: true }))
|
||||
|
||||
const win = createMemo(() => visibleRows(grouped().rows, sel(), MAX_ROWS))
|
||||
|
||||
const pick = (item: PickerItem | undefined) => {
|
||||
if (item) props.onPick(item.value)
|
||||
}
|
||||
|
||||
useKeyboard(key => {
|
||||
// Esc/Ctrl+C also close via the keymap layer above; handling them here too
|
||||
// keeps close working even when focus never landed (maskedPrompt pattern).
|
||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) return props.onClose()
|
||||
const count = grouped().flat.length
|
||||
if (key.name === 'return') return pick(grouped().flat[sel()])
|
||||
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
|
||||
if (count) setSel(s => (s - 1 + count) % count)
|
||||
return
|
||||
}
|
||||
if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
|
||||
if (count) setSel(s => (s + 1) % count)
|
||||
return
|
||||
}
|
||||
if (key.name === 'backspace') return setQuery(q => q.slice(0, -1))
|
||||
// printable → refine the query
|
||||
const ch = key.sequence
|
||||
if (ch.length === 1 && !key.ctrl && !key.meta && ch >= ' ') setQuery(q => q + ch)
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={el => (rootRef = el)}
|
||||
focusable
|
||||
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
|
||||
border
|
||||
>
|
||||
<text fg={theme().color.accent}>
|
||||
<b>{props.title}</b>
|
||||
</text>
|
||||
<select
|
||||
focused
|
||||
options={options()}
|
||||
onSelect={(_index, option) => {
|
||||
if (option) props.onPick(String(option.value))
|
||||
}}
|
||||
backgroundColor={theme().color.statusBg}
|
||||
selectedBackgroundColor={theme().color.selectionBg}
|
||||
textColor={theme().color.text}
|
||||
selectedTextColor={theme().color.text}
|
||||
descriptionColor={theme().color.muted}
|
||||
style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }}
|
||||
/>
|
||||
<text fg={theme().color.muted}>↑↓ select · Enter choose · Esc cancel</text>
|
||||
<box style={{ flexDirection: 'row' }}>
|
||||
<text fg={theme().color.accent}>
|
||||
<b>{props.title}</b>
|
||||
</text>
|
||||
<text fg={theme().color.label}>{' '}</text>
|
||||
<text fg={theme().color.prompt}>{'> '}</text>
|
||||
<text fg={theme().color.text}>{query()}</text>
|
||||
<text fg={theme().color.accent}>▍</text>
|
||||
<Show when={!query()}>
|
||||
<text fg={theme().color.muted}>type to filter</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={win().above > 0}>
|
||||
<text fg={theme().color.muted}>{` ↑ ${win().above} more`}</text>
|
||||
</Show>
|
||||
<For each={win().rows}>
|
||||
{row =>
|
||||
row.kind === 'header' ? (
|
||||
<text fg={theme().color.label}>
|
||||
<b>{row.label}</b>
|
||||
</text>
|
||||
) : (
|
||||
<text
|
||||
bg={row.index === sel() ? theme().color.selectionBg : 'transparent'}
|
||||
onMouseDown={() => pick(row.item)}
|
||||
>
|
||||
<span style={{ fg: row.index === sel() ? theme().color.text : theme().color.muted }}>
|
||||
{row.index === sel() ? '› ' : ' '}
|
||||
</span>
|
||||
<span style={{ fg: theme().color.text }}>{row.item.label}</span>
|
||||
<Show when={row.item.current}>
|
||||
<span style={{ fg: theme().color.ok }}> ✓</span>
|
||||
</Show>
|
||||
<Show when={row.item.description}>
|
||||
<span style={{ fg: theme().color.muted }}> {row.item.description}</span>
|
||||
</Show>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
</For>
|
||||
<Show when={grouped().flat.length === 0}>
|
||||
<text fg={theme().color.muted}> (no matches)</text>
|
||||
</Show>
|
||||
<Show when={win().below > 0}>
|
||||
<text fg={theme().color.muted}>{` ↓ ${win().below} more`}</text>
|
||||
</Show>
|
||||
<text fg={theme().color.muted}>↑↓ move · Enter choose · Esc cancel · type to filter</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue