opentui(v6): model picker v2 — fuzzy search + provider groups + instant open

This commit is contained in:
alt-glitch 2026-06-10 20:50:17 +05:30
commit 91df325458
9 changed files with 808 additions and 60 deletions

View file

@ -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))
}
}

View file

@ -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)

View 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) }
}

View file

@ -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. */

View file

@ -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,

View 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)
})
})

View 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()
}
})
})

View file

@ -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 () => {

View file

@ -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>
)
}