mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 13:41:53 +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
|
* TODO(upstream): file/track an OpenTUI issue to widen these FFI params to i32
|
||||||
* (or clamp in core) — then this shim can be deleted.
|
* (or clamp in core) — then this shim can be deleted.
|
||||||
*/
|
*/
|
||||||
import { OptimizedBuffer } from '@opentui/core'
|
import { OptimizedBuffer, TextBufferView } from '@opentui/core'
|
||||||
|
|
||||||
let installed = false
|
let installed = false
|
||||||
|
|
||||||
|
|
@ -84,4 +84,17 @@ export function installFfiCoordSafety(): void {
|
||||||
if (x < 0 || y < 0) return
|
if (x < 0 || y < 0) return
|
||||||
origDrawChar.call(this, char, x, y, ...rest)
|
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 { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts'
|
||||||
import { createPasteStore } from '../logic/pastes.ts'
|
import { createPasteStore } from '../logic/pastes.ts'
|
||||||
import { mapResumeHistory, mapSessionList } from '../logic/resume.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 { createSessionStore, type SessionStore } from '../logic/store.ts'
|
||||||
import { App } from '../view/App.tsx'
|
import { App } from '../view/App.tsx'
|
||||||
import { ThemeProvider } from '../view/theme.tsx'
|
import { ThemeProvider } from '../view/theme.tsx'
|
||||||
|
|
@ -170,6 +177,17 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp
|
||||||
store.pushUser(prompt)
|
store.pushUser(prompt)
|
||||||
yield* gateway.request('prompt.submit', { session_id: sid, text: 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) }))))
|
}).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")`. */
|
/** 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
|
return true
|
||||||
},
|
},
|
||||||
listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList),
|
listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList),
|
||||||
|
modelItems: () => store.state.modelItems,
|
||||||
|
setModelItems: items => store.setModelItems(items),
|
||||||
logTail: () =>
|
logTail: () =>
|
||||||
getLog()
|
getLog()
|
||||||
.tail(200)
|
.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
|
readonly openPicker: (picker: PickerState) => void
|
||||||
/** Open the agents dashboard (/agents, /tasks). */
|
/** Open the agents dashboard (/agents, /tasks). */
|
||||||
readonly openDashboard: () => void
|
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 {
|
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.')
|
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 []
|
if (!opts || typeof opts !== 'object') return []
|
||||||
const providers = (opts as { providers?: unknown }).providers
|
const providers = (opts as { providers?: unknown }).providers
|
||||||
if (!Array.isArray(providers)) return []
|
if (!Array.isArray(providers)) return []
|
||||||
const current = readStr(opts, 'model')
|
const current = readStr(opts, 'model')
|
||||||
|
const currentProvider = readStr(opts, 'provider')
|
||||||
const items: PickerItem[] = []
|
const items: PickerItem[] = []
|
||||||
for (const p of providers) {
|
for (const p of providers) {
|
||||||
if (!p || typeof p !== 'object' || (p as { authenticated?: unknown }).authenticated !== true) continue
|
if (!p || typeof p !== 'object' || (p as { authenticated?: unknown }).authenticated !== true) continue
|
||||||
const slug = readStr(p, 'slug') ?? readStr(p, 'name') ?? ''
|
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
|
const models = (p as { models?: unknown }).models
|
||||||
if (!Array.isArray(models)) continue
|
if (!Array.isArray(models)) continue
|
||||||
for (const m of models) {
|
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
|
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[] {
|
function mapSkills(result: unknown): PickerItem[] {
|
||||||
if (!result || typeof result !== 'object') return []
|
if (!result || typeof result !== 'object') return []
|
||||||
const skills = (result as { skills?: unknown }).skills
|
const skills = (result as { skills?: unknown }).skills
|
||||||
|
|
@ -180,33 +213,56 @@ function mapSkills(result: unknown): PickerItem[] {
|
||||||
const items: PickerItem[] = []
|
const items: PickerItem[] = []
|
||||||
for (const [category, names] of Object.entries(skills as { [k: string]: unknown })) {
|
for (const [category, names] of Object.entries(skills as { [k: string]: unknown })) {
|
||||||
if (!Array.isArray(names)) continue
|
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
|
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> {
|
async function switchModel(ctx: SlashContext, name: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const r = await ctx.request('slash.exec', { command: `model ${name}`, session_id: ctx.sessionId() })
|
const r = await ctx.request('slash.exec', { command: `model ${name}`, session_id: ctx.sessionId() })
|
||||||
ctx.pushSystem(readStr(r, 'output') || `→ ${name}`)
|
ctx.pushSystem(readStr(r, 'output') || `→ ${name}`)
|
||||||
|
void refreshModelItems(ctx)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.pushSystem(`/model ${name}: ${error instanceof Error ? error.message : 'switch failed'}`)
|
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) => {
|
const modelCmd: ClientHandler = async (arg, ctx) => {
|
||||||
if (arg.trim()) {
|
if (arg.trim()) {
|
||||||
await switchModel(ctx, arg.trim())
|
await switchModel(ctx, arg.trim())
|
||||||
return
|
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) {
|
if (!items.length) {
|
||||||
ctx.pushSystem('No models available (no authenticated providers).')
|
ctx.pushSystem('No models available (no authenticated providers).')
|
||||||
return
|
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. */
|
/** `/skills` — open the skills hub; picking a skill shows its info in the pager. */
|
||||||
|
|
|
||||||
|
|
@ -98,11 +98,19 @@ export interface SessionItem {
|
||||||
messageCount: number
|
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 {
|
export interface PickerItem {
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
value: 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)`. */
|
/** An open generic picker overlay: a titled list whose pick runs `onPick(value)`. */
|
||||||
|
|
@ -194,6 +202,8 @@ export interface StoreState {
|
||||||
subagents: SubagentInfo[]
|
subagents: SubagentInfo[]
|
||||||
/** Whether the agents dashboard overlay is open (/agents). */
|
/** Whether the agents dashboard overlay is open (/agents). */
|
||||||
dashboard: boolean
|
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`);
|
/** 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. */
|
* shown above the composer WHILE a turn runs, cleared on `message.complete`. NOT transcript. */
|
||||||
status: string | undefined
|
status: string | undefined
|
||||||
|
|
@ -204,6 +214,11 @@ export interface StoreState {
|
||||||
hint: string | undefined
|
hint: string | undefined
|
||||||
/** Startup tools/skills/MCP catalog (from `startup.catalog`) for the home panel (item 9). */
|
/** Startup tools/skills/MCP catalog (from `startup.catalog`) for the home panel (item 9). */
|
||||||
catalog: Catalog | undefined
|
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). */
|
/** The current session id (shown in the home panel; updated on create/resume). */
|
||||||
sessionId: string | undefined
|
sessionId: string | undefined
|
||||||
}
|
}
|
||||||
|
|
@ -348,10 +363,12 @@ export function createSessionStore() {
|
||||||
completionFrom: 0,
|
completionFrom: 0,
|
||||||
subagents: [],
|
subagents: [],
|
||||||
dashboard: false,
|
dashboard: false,
|
||||||
|
dashboardAgent: undefined,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
info: {},
|
info: {},
|
||||||
hint: undefined,
|
hint: undefined,
|
||||||
catalog: undefined,
|
catalog: undefined,
|
||||||
|
modelItems: undefined,
|
||||||
sessionId: undefined
|
sessionId: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -475,12 +492,15 @@ export function createSessionStore() {
|
||||||
applied.clear()
|
applied.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open / close the agents dashboard overlay (/agents). */
|
/** Open / close the agents dashboard overlay (/agents). The optional `agentId`
|
||||||
function openDashboard() {
|
* preselects that subagent's row (the tray's Enter — Epic 2.7). */
|
||||||
|
function openDashboard(agentId?: string) {
|
||||||
|
setState('dashboardAgent', agentId)
|
||||||
setState('dashboard', true)
|
setState('dashboard', true)
|
||||||
}
|
}
|
||||||
function closeDashboard() {
|
function closeDashboard() {
|
||||||
setState('dashboard', false)
|
setState('dashboard', false)
|
||||||
|
setState('dashboardAgent', undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */
|
/** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */
|
||||||
|
|
@ -518,6 +538,11 @@ export function createSessionStore() {
|
||||||
setState('picker', undefined)
|
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). */
|
/** Set / clear the transient composer hint ("Ctrl+C again to quit" — item 11). */
|
||||||
function setHint(text: string | undefined): void {
|
function setHint(text: string | undefined): void {
|
||||||
setState('hint', text)
|
setState('hint', text)
|
||||||
|
|
@ -904,6 +929,7 @@ export function createSessionStore() {
|
||||||
closeSwitcher,
|
closeSwitcher,
|
||||||
openPicker,
|
openPicker,
|
||||||
closePicker,
|
closePicker,
|
||||||
|
setModelItems,
|
||||||
setCompletions,
|
setCompletions,
|
||||||
clearCompletions,
|
clearCompletions,
|
||||||
applyInfo,
|
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' }]
|
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', () => {
|
describe('mapCompletions', () => {
|
||||||
test('maps complete.slash items → candidates (display/meta default)', () => {
|
test('maps complete.slash items → candidates (display/meta default)', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -97,6 +113,8 @@ interface Probe {
|
||||||
dashboard: { value: boolean }
|
dashboard: { value: boolean }
|
||||||
copied: number[]
|
copied: number[]
|
||||||
copyN: { value: (n: number) => boolean }
|
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 {
|
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 dashboard = { value: false }
|
||||||
const copied: number[] = []
|
const copied: number[] = []
|
||||||
const copyN: Probe['copyN'] = { value: () => false }
|
const copyN: Probe['copyN'] = { value: () => false }
|
||||||
|
const modelCache: Probe['modelCache'] = { value: undefined }
|
||||||
const ctx: SlashContext = {
|
const ctx: SlashContext = {
|
||||||
clearTranscript: () => (cleared.value = true),
|
clearTranscript: () => (cleared.value = true),
|
||||||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
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),
|
listSessions: () => Promise.resolve(FAKE_SESSIONS),
|
||||||
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
||||||
|
modelItems: () => modelCache.value,
|
||||||
|
setModelItems: items => (modelCache.value = items),
|
||||||
openDashboard: () => (dashboard.value = true),
|
openDashboard: () => (dashboard.value = true),
|
||||||
openPager: (title, text) => paged.push({ text, title }),
|
openPager: (title, text) => paged.push({ text, title }),
|
||||||
openPicker: p => pickers.push(p),
|
openPicker: p => pickers.push(p),
|
||||||
|
|
@ -142,6 +163,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
||||||
copyN,
|
copyN,
|
||||||
ctx,
|
ctx,
|
||||||
dashboard,
|
dashboard,
|
||||||
|
modelCache,
|
||||||
paged,
|
paged,
|
||||||
pickers,
|
pickers,
|
||||||
quit,
|
quit,
|
||||||
|
|
@ -185,33 +207,73 @@ describe('dispatchSlash — client commands', () => {
|
||||||
expect(p2.switched).toHaveLength(1)
|
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 => {
|
const p = makeCtx(async method => {
|
||||||
if (method === 'model.options')
|
if (method === 'model.options') return 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' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return { output: 'switched' }
|
return { output: 'switched' }
|
||||||
})
|
})
|
||||||
await dispatchSlash('/model', p.ctx)
|
await dispatchSlash('/model', p.ctx)
|
||||||
expect(p.pickers).toHaveLength(1)
|
expect(p.pickers).toHaveLength(1)
|
||||||
expect(p.pickers[0]!.title).toBe('Switch model')
|
expect(p.pickers[0]!.title).toBe('Switch model')
|
||||||
// only the authenticated provider's models; current is marked
|
// only AUTHENTICATED providers' models; values carry the explicit provider so
|
||||||
expect(p.pickers[0]!.items.map(i => i.value)).toEqual(['claude-sonnet-4.6', 'claude-opus-4.6'])
|
// a pick under a different provider switches provider+model.
|
||||||
expect(p.pickers[0]!.items[0]!.label).toContain('✓')
|
expect(p.pickers[0]!.items.map(i => i.value)).toEqual([
|
||||||
// picking switches via slash.exec `model <name>`
|
'claude-sonnet-4.6 --provider anthropic',
|
||||||
p.pickers[0]!.onPick('claude-opus-4.6')
|
'claude-opus-4.6 --provider anthropic',
|
||||||
await Promise.resolve()
|
'hermes-4-405b --provider nous'
|
||||||
expect(p.calls.some(c => c.method === 'slash.exec' && c.params.command === 'model claude-opus-4.6')).toBe(true)
|
])
|
||||||
|
// 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 () => {
|
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 — the generic fuzzy picker overlay (spec §2b; Epic 7 model picker v2).
|
||||||
* picker (/model) and skills hub (/skills); the chosen value runs `onPick`.
|
* Powers /model and /skills: one query line filters live across label AND
|
||||||
* Native select nav (↑↓/j/k/Enter); a small useKeyboard adds Esc/Ctrl+C close.
|
* group AND extra haystacks (provider slug / lab name — `son4` finds
|
||||||
* Replaces the composer while open.
|
* 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 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 type { PickerItem } from '../../logic/store.ts'
|
||||||
import { useCloseLayer } from '../keymap.tsx'
|
import { useCloseLayer } from '../keymap.tsx'
|
||||||
import { useTheme } from '../theme.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: {
|
export function Picker(props: {
|
||||||
title: string
|
title: string
|
||||||
items: PickerItem[]
|
items: PickerItem[]
|
||||||
|
|
@ -19,39 +41,108 @@ export function Picker(props: {
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
let rootRef: BoxRenderable | undefined
|
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(
|
useCloseLayer(
|
||||||
() => rootRef,
|
() => rootRef,
|
||||||
() => props.onClose()
|
() => props.onClose()
|
||||||
)
|
)
|
||||||
|
|
||||||
const options = createMemo(() =>
|
const [query, setQuery] = createSignal('')
|
||||||
props.items.map(it => ({ description: it.description ?? '', name: it.label, value: it.value }))
|
// 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 (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={el => (rootRef = el)}
|
ref={el => (rootRef = el)}
|
||||||
|
focusable
|
||||||
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
|
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
|
||||||
border
|
border
|
||||||
>
|
>
|
||||||
<text fg={theme().color.accent}>
|
<box style={{ flexDirection: 'row' }}>
|
||||||
<b>{props.title}</b>
|
<text fg={theme().color.accent}>
|
||||||
</text>
|
<b>{props.title}</b>
|
||||||
<select
|
</text>
|
||||||
focused
|
<text fg={theme().color.label}>{' '}</text>
|
||||||
options={options()}
|
<text fg={theme().color.prompt}>{'> '}</text>
|
||||||
onSelect={(_index, option) => {
|
<text fg={theme().color.text}>{query()}</text>
|
||||||
if (option) props.onPick(String(option.value))
|
<text fg={theme().color.accent}>▍</text>
|
||||||
}}
|
<Show when={!query()}>
|
||||||
backgroundColor={theme().color.statusBg}
|
<text fg={theme().color.muted}>type to filter</text>
|
||||||
selectedBackgroundColor={theme().color.selectionBg}
|
</Show>
|
||||||
textColor={theme().color.text}
|
</box>
|
||||||
selectedTextColor={theme().color.text}
|
<Show when={win().above > 0}>
|
||||||
descriptionColor={theme().color.muted}
|
<text fg={theme().color.muted}>{` ↑ ${win().above} more`}</text>
|
||||||
style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }}
|
</Show>
|
||||||
/>
|
<For each={win().rows}>
|
||||||
<text fg={theme().color.muted}>↑↓ select · Enter choose · Esc cancel</text>
|
{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>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue