opentui(v6): picker v2.1 — provider search, availability toggle, native input, manual refresh

This commit is contained in:
alt-glitch 2026-06-10 21:30:55 +05:30
parent 7ad05a3129
commit daa4412378
7 changed files with 512 additions and 76 deletions

View file

@ -76,7 +76,8 @@ export function fuzzyFilter<T>(query: string, items: readonly T[], fieldsOf: (it
}
/** 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. */
* `index` is the item's position in the flat ARROW-TRAVERSAL order; `-1` marks
* a non-selectable item row (rendered dimmed, skipped by traversal). */
export type PickerRow<T> = { kind: 'header'; label: string } | { kind: 'item'; item: T; index: number }
/**
@ -84,11 +85,14 @@ export type PickerRow<T> = { kind: 'header'; label: string } | { kind: 'item'; i
* 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).
* without a group render headerless (e.g. the skills picker). Items failing
* `selectableOf` (picker v2.1: unconfigured-provider hint rows) still RENDER
* (index `-1`) but never enter `flat`, so traversal skips them.
*/
export function buildPickerRows<T>(
items: readonly T[],
groupOf: (item: T) => string | undefined
groupOf: (item: T) => string | undefined,
selectableOf: (item: T) => boolean = () => true
): { rows: PickerRow<T>[]; flat: T[] } {
const order: string[] = []
const buckets = new Map<string, T[]>()
@ -107,8 +111,12 @@ export function buildPickerRows<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)
if (selectableOf(item)) {
rows.push({ index: flat.length, item, kind: 'item' })
flat.push(item)
} else {
rows.push({ index: -1, item, kind: 'item' })
}
}
}
return { flat, rows }

View file

@ -158,13 +158,20 @@ const openSwitcher: ClientHandler = async (_arg, ctx) => {
}
/**
* 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 .
* Flatten `model.options` into grouped picker rows (Epic 7; v2.1 availability):
* group = the provider's display ("lab") name, haystacks = slug + lab name (so
* `oai`/`copilot`/`anthropic` fuzzy-match the whole group), 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 .
*
* UNCONFIGURED providers (`authenticated: false` skeleton rows the gateway
* sends them via `build_models_payload(include_unconfigured=True,
* picker_hints=True)`, with `key_env`/`warning` setup hints) become one
* `unavailable` hint row each (`no API key — set <ENV_VAR>`): hidden by
* default, revealed dimmed + non-selectable by the picker's Ctrl+U toggle.
*/
export function mapModelOptions(opts: unknown): PickerItem[] {
if (!opts || typeof opts !== 'object') return []
@ -174,9 +181,26 @@ export function mapModelOptions(opts: unknown): PickerItem[] {
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
if (!p || typeof p !== 'object') continue
const slug = readStr(p, 'slug') ?? readStr(p, 'name') ?? ''
const lab = readStr(p, 'name') ?? slug
if ((p as { authenticated?: unknown }).authenticated === false) {
// Unconfigured provider → one dimmed hint row under its own group header.
// Identity (slug + display name) is the haystack so a provider-name query
// still narrows to the group; the hint text itself is not searched.
const keyEnv = readStr(p, 'key_env')
const item: PickerItem = {
group: lab || slug,
label: keyEnv ? `no API key — set ${keyEnv}` : (readStr(p, 'warning') ?? 'not configured'),
unavailable: true,
value: slug || lab
}
const hay = [slug, lab].filter(Boolean)
if (hay.length) item.haystacks = hay
items.push(item)
continue
}
if ((p as { authenticated?: unknown }).authenticated !== true) continue
// 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).
@ -218,15 +242,40 @@ function mapSkills(result: unknown): PickerItem[] {
return items
}
/** 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(() => {})
/** Re-fetch `model.options` and update the cached picker rows. Resolves with
* the fresh rows (the open picker swaps them in live Ctrl+R, picker v2.1);
* rejections are the CALLER's to handle (background callers fire-and-forget). */
function refreshModelItems(ctx: SlashContext): Promise<PickerItem[]> {
return ctx.request('model.options', { session_id: ctx.sessionId() }).then(opts => {
const items = mapModelOptions(opts)
if (items.length) ctx.setModelItems(items)
return items
})
}
/**
* The open picker's manual-refresh seam (picker v2.1 Ctrl+R). Whoever opens a
* picker registers (or clears) the catalog re-fetch here; the mounted Picker
* triggers it via `runPickerRefresh` and swaps in the resolved rows live. A
* module slot rather than a Picker prop because the AppPicker prop plumbing
* carries only the PickerState basics; the seam keeps the overlay generic for
* the upcoming resume-session picker (register a `session.list` re-fetch).
*/
let activePickerRefresh: (() => Promise<PickerItem[]>) | undefined
/** Register (or clear, with `undefined`) the open picker's catalog re-fetch. */
export function registerPickerRefresh(fn: (() => Promise<PickerItem[]>) | undefined): void {
activePickerRefresh = fn
}
/** Whether a refresh is registered (the picker's footer hint is gated on it). */
export function canRefreshPicker(): boolean {
return activePickerRefresh !== undefined
}
/** Run the registered catalog re-fetch; undefined when none is registered. */
export function runPickerRefresh(): Promise<PickerItem[]> | undefined {
return activePickerRefresh?.()
}
/** Switch the model via the server (shared by `/model <name>` and the picker pick).
@ -235,7 +284,7 @@ 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)
void refreshModelItems(ctx).catch(() => {})
} catch (error) {
ctx.pushSystem(`/model ${name}: ${error instanceof Error ? error.message : 'switch failed'}`)
}
@ -249,15 +298,19 @@ const modelCmd: ClientHandler = async (arg, ctx) => {
await switchModel(ctx, arg.trim())
return
}
const open = (items: PickerItem[]) =>
const open = (items: PickerItem[]) => {
// Ctrl+R in the open picker re-fetches the catalog (and re-syncs the cache).
registerPickerRefresh(() => refreshModelItems(ctx))
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) {
// Unavailable hint rows alone are not a usable catalog — keep the notice.
if (!items.some(i => !i.unavailable)) {
ctx.pushSystem('No models available (no authenticated providers).')
return
}
@ -272,6 +325,7 @@ const skillsCmd: ClientHandler = async (_arg, ctx) => {
ctx.pushSystem('No skills found.')
return
}
registerPickerRefresh(undefined) // no Ctrl+R catalog re-fetch for skills (yet)
ctx.openPicker({
items,
onPick: name =>

View file

@ -111,6 +111,10 @@ export interface PickerItem {
haystacks?: string[]
/** Marks the currently-active row (rendered with a ✓). */
current?: boolean
/** Unavailable row (e.g. an unconfigured provider's `no API key set `
* hint): hidden by default, revealed dimmed + NON-selectable by the
* picker's Ctrl+U toggle; traversal skips it (picker v2.1). */
unavailable?: boolean
}
/** An open generic picker overlay: a titled list whose pick runs `onPick(value)`. */

View file

@ -208,6 +208,31 @@ describe('buildPickerRows — grouping + traversal order', () => {
const { rows } = buildPickerRows(sorted, r => r.lab)
expect(rows[0]).toEqual({ kind: 'header', label: 'OpenAI' })
})
test('non-selectable items (picker v2.1 unconfigured rows) render with index -1, stay out of flat', () => {
// an unconfigured "provider hint" row sits BETWEEN two configured groups
const mixed = [
{ lab: 'Anthropic', label: 'claude-sonnet-4', provider: 'anthropic' },
{ lab: 'Mistral', label: 'no API key — set MISTRAL_API_KEY', provider: 'mistral' },
{ lab: 'OpenAI', label: 'gpt-5', provider: 'openai' }
]
const { flat, rows } = buildPickerRows(
mixed,
r => r.lab,
r => !r.label.startsWith('no API key')
)
// hint row RENDERS (with its header) but is index -1 and absent from flat —
// so ↑↓ traversal (which walks flat) skips it entirely.
expect(rows.map(r => (r.kind === 'header' ? `# ${r.label}` : `${r.index}:${r.item.label}`))).toEqual([
'# Anthropic',
'0:claude-sonnet-4',
'# Mistral',
'-1:no API key — set MISTRAL_API_KEY',
'# OpenAI',
'1:gpt-5'
])
expect(flat.map(f => f.label)).toEqual(['claude-sonnet-4', 'gpt-5'])
})
})
describe('visibleRows — selection-following window', () => {

View file

@ -1,19 +1,28 @@
/**
* 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
* Picker overlay tests (Epic 7 model picker v2; picker v2.1) headless frames
* with a simulated keyboard through the REAL component: provider group headers
* render, typing into the NATIVE `<input>` filters live (fuzzy, incl.
* provider-field matches; backspace + Alt+Backspace word-delete come from the
* input), 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.
*
* v2.1: unconfigured-provider rows are hidden by default; Ctrl+U reveals them
* dimmed + non-selectable (env-var hint, traversal skips them); Ctrl+R runs
* the registered catalog re-fetch exactly once and swaps the rows live.
*/
import { describe, expect, test } from 'vitest'
import { afterEach, describe, expect, test } from 'vitest'
import { registerPickerRefresh } from '../logic/slash.ts'
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'
// the Ctrl+R seam is module-level state — never leak it across tests
afterEach(() => registerPickerRefresh(undefined))
/** A grouped model catalog: current = claude-sonnet-4 under Anthropic. */
const ITEMS: PickerItem[] = [
{
@ -168,3 +177,186 @@ describe('Picker — traversal across groups + pick + close', () => {
}
})
})
describe('Picker — native input editing (v2.1)', () => {
test('Alt+Backspace word-deletes the last query term (native input editing)', async () => {
const h = await mountPicker()
try {
await h.probe.keys.typeText('claude opus')
await h.probe.settle()
let frame = await h.probe.waitForFrame(f => !f.includes('claude-sonnet-4'))
expect(frame).toContain('claude-opus-4') // multi-term AND narrowed to one model
h.probe.keys.pressBackspace({ meta: true }) // word-delete `opus` → query `claude `
await h.probe.settle()
frame = await h.probe.waitForFrame(f => f.includes('claude-sonnet-4'))
expect(frame).toContain('claude-opus-4')
expect(frame).not.toContain('hermes-4-405b') // `claude` still filters
} finally {
h.probe.destroy()
}
})
})
/** v2.1 catalog: configured groups (one with a display name slug) plus two
* UNCONFIGURED providers (`unavailable` hint rows), one of them sitting
* BETWEEN configured groups so traversal must skip across it. */
const MIXED: 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: 'Mistral',
haystacks: ['mistral', 'Mistral'],
label: 'no API key — set MISTRAL_API_KEY',
unavailable: true,
value: 'mistral'
},
{ group: 'OpenAI', haystacks: ['openai', 'OpenAI'], label: 'gpt-5', value: 'gpt-5 --provider openai' },
{
group: 'GitHub Copilot',
haystacks: ['copilot', 'GitHub Copilot'],
label: 'no API key — set GITHUB_TOKEN',
unavailable: true,
value: 'copilot'
}
]
describe('Picker — unconfigured providers (Ctrl+U toggle, v2.1)', () => {
test('hidden by default; Ctrl+U reveals dimmed env-var hints; Ctrl+U again hides', async () => {
const h = await mountPicker(MIXED)
try {
let frame = h.probe.frame()
expect(frame).not.toContain('Mistral')
expect(frame).not.toContain('GITHUB_TOKEN')
expect(frame).toContain('Ctrl+U show unconfigured') // footer-hinted
h.probe.keys.pressKey('u', { ctrl: true })
await h.probe.settle()
frame = await h.probe.waitForFrame(f => f.includes('Mistral'))
expect(frame).toContain('no API key — set MISTRAL_API_KEY')
expect(frame).toContain('GitHub Copilot')
expect(frame).toContain('no API key — set GITHUB_TOKEN')
expect(frame).toContain('Ctrl+U hide unconfigured')
h.probe.keys.pressKey('u', { ctrl: true })
await h.probe.settle()
frame = await h.probe.waitForFrame(f => !f.includes('Mistral'))
expect(frame).not.toContain('GITHUB_TOKEN')
} finally {
h.probe.destroy()
}
})
test('revealed rows are non-selectable: ↓↓ skips the Mistral hint; ↓ again wraps past the Copilot hint', async () => {
const h = await mountPicker(MIXED)
try {
h.probe.keys.pressKey('u', { ctrl: true })
await h.probe.settle()
await h.probe.waitForFrame(f => f.includes('Mistral'))
// selection reset to the top selectable (claude-sonnet-4) on toggle
expect(h.probe.frame()).toContain(' claude-sonnet-4')
h.probe.keys.pressArrow('down')
h.probe.keys.pressArrow('down') // opus → (skip Mistral hint) → gpt-5
await h.probe.settle()
expect(h.probe.frame()).toContain(' gpt-5')
h.probe.keys.pressArrow('down') // (skip trailing Copilot hint) → wrap to top
await h.probe.settle()
expect(h.probe.frame()).toContain(' claude-sonnet-4')
h.probe.keys.pressEnter()
await h.probe.settle()
expect(h.picked).toEqual(['claude-sonnet-4 --provider anthropic']) // never a hint row
} finally {
h.probe.destroy()
}
})
test('provider DISPLAY-NAME query narrows to the group in both views', async () => {
// configured-only view: `research` matches only via the display name
const items: PickerItem[] = [
...MIXED,
{
group: 'Nous Research',
haystacks: ['nous', 'Nous Research'],
label: 'hermes-4-405b',
value: 'hermes-4-405b --provider nous'
}
]
const h = await mountPicker(items)
try {
await h.probe.keys.typeText('research')
await h.probe.settle()
let frame = await h.probe.waitForFrame(f => !f.includes('claude-sonnet-4'))
expect(frame).toContain('hermes-4-405b')
expect(frame).toContain('Nous Research')
expect(frame).not.toContain('gpt-5')
// toggled view: `github` matches the UNCONFIGURED GitHub Copilot group
for (let i = 0; i < 8; i++) h.probe.keys.pressBackspace()
h.probe.keys.pressKey('u', { ctrl: true })
await h.probe.settle()
await h.probe.keys.typeText('github')
await h.probe.settle()
frame = await h.probe.waitForFrame(f => f.includes('GITHUB_TOKEN') && !f.includes('claude-sonnet-4'))
expect(frame).toContain('GitHub Copilot')
expect(frame).not.toContain('hermes-4-405b')
expect(frame).not.toContain('(no matches)') // hint rows alone are still matches
} finally {
h.probe.destroy()
}
})
})
describe('Picker — manual catalog refresh (Ctrl+R, v2.1)', () => {
test('Ctrl+R runs the registered re-fetch ONCE, shows refreshing…, swaps the rows live', async () => {
let calls = 0
let resolveFetch: (items: PickerItem[]) => void = () => {}
registerPickerRefresh(() => {
calls++
return new Promise<PickerItem[]>(resolve => (resolveFetch = resolve))
})
const h = await mountPicker()
try {
expect(h.probe.frame()).toContain('Ctrl+R refresh') // footer-hinted
h.probe.keys.pressKey('r', { ctrl: true })
await h.probe.settle()
const pendingFrame = await h.probe.waitForFrame(f => f.includes('refreshing…'))
expect(pendingFrame).toContain('claude-sonnet-4') // old rows stay while pending
expect(calls).toBe(1)
resolveFetch([
{
current: true,
group: 'Anthropic',
haystacks: ['anthropic', 'Anthropic'],
label: 'claude-fresh-5',
value: 'claude-fresh-5 --provider anthropic'
}
])
await h.probe.settle()
const frame = await h.probe.waitForFrame(f => f.includes('claude-fresh-5'))
expect(frame).not.toContain('claude-sonnet-4') // live swap, picker stays open
expect(frame).not.toContain('refreshing…')
expect(calls).toBe(1)
} finally {
h.probe.destroy()
}
})
test('Ctrl+R without a registered re-fetch is a silent no-op (skills picker)', async () => {
const h = await mountPicker()
try {
expect(h.probe.frame()).not.toContain('Ctrl+R refresh')
h.probe.keys.pressKey('r', { ctrl: true })
await h.probe.settle()
expect(h.probe.frame()).toContain('claude-sonnet-4') // unchanged, no crash
} finally {
h.probe.destroy()
}
})
})

View file

@ -2,7 +2,7 @@
* Slash dispatch test (spec §5 Layer 3/4). Pure logic: parse + the dispatch
* ladder (client slash.exec command.dispatch) against a fake SlashContext.
*/
import { describe, expect, test } from 'vitest'
import { afterEach, describe, expect, test } from 'vitest'
import {
dispatchSlash,
@ -10,13 +10,20 @@ import {
parseSlash,
planCompletion,
readReplaceFrom,
registerPickerRefresh,
runPickerRefresh,
type SlashContext
} from '../logic/slash.ts'
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. */
// the picker-refresh seam is module-level state — never leak it across tests
afterEach(() => registerPickerRefresh(undefined))
/** A `model.options` payload: two authed providers + two unconfigured skeleton
* rows (the gateway sends them with `include_unconfigured=True,
* picker_hints=True`: empty models + `key_env`/`warning` setup hints). */
const MODEL_OPTIONS = {
model: 'claude-sonnet-4.6',
provider: 'anthropic',
@ -27,8 +34,23 @@ const MODEL_OPTIONS = {
name: 'Anthropic',
slug: 'anthropic'
},
{
authenticated: false,
key_env: 'OPENAI_API_KEY',
models: [],
name: 'OpenAI API',
slug: 'openai-api',
warning: 'paste OPENAI_API_KEY to activate'
},
{ authenticated: true, models: ['hermes-4-405b'], name: 'Nous Research', slug: 'nous' },
{ authenticated: false, models: ['gpt-5.4'], name: 'OpenAI', slug: 'openai' }
{
authenticated: false,
key_env: '',
models: [],
name: 'OpenAI Codex',
slug: 'openai-codex',
warning: 'run `hermes model` to configure (oauth_external)'
}
]
}
@ -215,20 +237,21 @@ describe('dispatchSlash — client commands', () => {
await dispatchSlash('/model', p.ctx)
expect(p.pickers).toHaveLength(1)
expect(p.pickers[0]!.title).toBe('Switch model')
// 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([
// authenticated providers' models are the SELECTABLE rows; values carry the
// explicit provider so a pick under a different provider switches both.
const selectable = p.pickers[0]!.items.filter(i => !i.unavailable)
expect(selectable.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'])
expect(selectable.map(i => i.group)).toEqual(['Anthropic', 'Anthropic', 'Nous Research'])
expect(selectable[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()
expect(selectable[0]!.current).toBe(true)
expect(selectable[0]!.label).toBe('claude-sonnet-4.6')
expect(selectable[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))
@ -237,6 +260,58 @@ describe('dispatchSlash — client commands', () => {
).toBe(true)
})
test('/model maps UNCONFIGURED providers to dimmed hint rows (key_env → env-var hint, else warning)', async () => {
const p = makeCtx(async method => (method === 'model.options' ? MODEL_OPTIONS : { output: 'switched' }))
await dispatchSlash('/model', p.ctx)
const unavailable = p.pickers[0]!.items.filter(i => i.unavailable)
expect(unavailable).toHaveLength(2)
// api_key provider → the `no API key — set <ENV_VAR>` hint as the row label
expect(unavailable[0]).toEqual({
group: 'OpenAI API',
haystacks: ['openai-api', 'OpenAI API'],
label: 'no API key — set OPENAI_API_KEY',
unavailable: true,
value: 'openai-api'
})
// oauth provider (no key_env) → the gateway's own warning text
expect(unavailable[1]!.group).toBe('OpenAI Codex')
expect(unavailable[1]!.label).toBe('run `hermes model` to configure (oauth_external)')
// payload (canonical) order is preserved — unconfigured rows interleave
expect(p.pickers[0]!.items.map(i => i.group)).toEqual([
'Anthropic',
'Anthropic',
'OpenAI API',
'Nous Research',
'OpenAI Codex'
])
})
test('/model with ONLY unconfigured providers keeps the no-models notice', async () => {
const p = makeCtx(async () => ({
providers: [{ authenticated: false, key_env: 'XAI_API_KEY', models: [], name: 'xAI', slug: 'xai' }]
}))
await dispatchSlash('/model', p.ctx)
expect(p.pickers).toHaveLength(0)
expect(p.system).toEqual(['No models available (no authenticated providers).'])
})
test('/model registers the picker refresh seam; running it does ONE RPC and re-syncs the cache', async () => {
const p = makeCtx(async method => (method === 'model.options' ? MODEL_OPTIONS : { output: 'switched' }))
await dispatchSlash('/model', p.ctx)
const opened = p.calls.filter(c => c.method === 'model.options').length // 1 (uncached open)
const refreshed = await runPickerRefresh()
expect(p.calls.filter(c => c.method === 'model.options')).toHaveLength(opened + 1)
expect(refreshed!.filter(i => !i.unavailable)).toHaveLength(3)
expect(p.modelCache.value).toEqual(refreshed) // cache re-synced for the next open
})
test('/skills clears the picker refresh seam (Ctrl+R is a no-op there)', async () => {
registerPickerRefresh(() => Promise.resolve([]))
const p = makeCtx(async () => ({ skills: { General: ['memory'] } }))
await dispatchSlash('/skills', p.ctx)
expect(runPickerRefresh()).toBeUndefined()
})
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')
@ -265,7 +340,7 @@ describe('dispatchSlash — client commands', () => {
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
expect(p.modelCache.value).toHaveLength(5) // first open seeded the cache (3 models + 2 unconfigured hints)
// 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')

View file

@ -1,21 +1,34 @@
/**
* 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.
* Picker the generic fuzzy picker overlay (spec §2b; Epic 7 model picker v2;
* picker v2.1). Powers /model and /skills: a native `<input>` query line (real
* editing word-delete, home/end, kill-line come free) filters live across
* label AND group AND extra haystacks (provider slug / lab name `son4` finds
* claude-sonnet-4, `copilot` narrows to the GitHub Copilot group); 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 + fallback).
*
* Everything heavy is memoized off (query, items): score group window, so
* keystrokes re-score at most once and unrelated store updates don't.
* v2.1 (direct user feedback):
* - The input stays focused the whole time; /Enter/Ctrl+U/Ctrl+R are handled
* by the GLOBAL key handler (which the renderer runs BEFORE routing to the
* focused renderable composer pattern) with `preventDefault` so the input
* never also applies them (Ctrl+U is natively kill-to-line-start!).
* - `unavailable` rows (unconfigured providers, `no API key — set <ENV_VAR>`)
* are hidden by default; Ctrl+U reveals them dimmed + NON-selectable and
* traversal skips them (buildPickerRows index -1).
* - Ctrl+R re-fetches the catalog via the seam registered by the opener
* (logic/slash.ts registerPickerRefresh) and swaps the rows in live, with a
* transient `refreshing…` note also self-heals a stale .
*
* Everything heavy is memoized off (query, toggle, items): keystrokes re-score
* at most once and unrelated store updates don't.
*/
import type { BoxRenderable } from '@opentui/core'
import type { BoxRenderable, InputRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { createEffect, createMemo, createSignal, For, on, onMount, Show } from 'solid-js'
import { createEffect, createMemo, createSignal, For, on, Show } from 'solid-js'
import { buildPickerRows, fuzzyFilter, visibleRows, type FuzzyField } from '../../logic/fuzzy.ts'
import { canRefreshPicker, runPickerRefresh } from '../../logic/slash.ts'
import type { PickerItem } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
@ -24,9 +37,11 @@ import { useTheme } from '../theme.tsx'
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). */
* group (lab name), description and any extra haystacks (provider slug).
* Unavailable rows match on provider IDENTITY only (group + haystacks) their
* hint label (`no API key — …`) must not make `api`/`set` match every row. */
function fieldsOf(item: PickerItem): FuzzyField[] {
const fields: FuzzyField[] = [{ text: item.label, weight: 2 }]
const fields: FuzzyField[] = item.unavailable ? [] : [{ 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 })
@ -41,18 +56,34 @@ export function Picker(props: {
}) {
const theme = useTheme()
let rootRef: BoxRenderable | undefined
// 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())
let inputRef: InputRenderable | undefined
// Esc/Ctrl+C close via the native keymap, scoped focus-within to the root box
// (the focused `<input>` is a descendant, so the layer stays active).
useCloseLayer(
() => rootRef,
() => props.onClose()
)
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))
// Ctrl+U availability toggle: unavailable (unconfigured-provider) rows are
// out of the pool by default; toggled on they join — dimmed, non-selectable.
const [showAll, setShowAll] = createSignal(false)
// Ctrl+R live-refreshed rows override the (static) opener snapshot.
const [live, setLive] = createSignal<PickerItem[] | undefined>(undefined)
const [refreshing, setRefreshing] = createSignal(false)
const items = () => live() ?? props.items
const hasUnavailable = createMemo(() => items().some(it => it.unavailable))
// pool → score → group → window, all memoized: typing re-scores once; nothing else does.
const pool = createMemo(() => (showAll() ? items() : items().filter(it => !it.unavailable)))
const filtered = createMemo(() => fuzzyFilter(query(), pool(), fieldsOf))
const grouped = createMemo(() =>
buildPickerRows(
filtered(),
it => it.group,
it => !it.unavailable
)
)
// Start on the current (✓) item; reset to the top match whenever the filter changes.
const [sel, setSel] = createSignal(
@ -69,30 +100,59 @@ export function Picker(props: {
if (item) props.onPick(item.value)
}
/** Ctrl+R: run the opener-registered catalog re-fetch, swap the rows in live. */
const refresh = () => {
if (refreshing()) return
const pending = runPickerRefresh()
if (!pending) return
setRefreshing(true)
pending
.then(fresh => {
if (fresh.length) setLive(fresh)
})
.catch(() => {})
.finally(() => setRefreshing(false))
}
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).
// keeps close working even when focus never landed.
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) return props.onClose()
// Picker chords are consumed BEFORE the focused input sees them
// (preventDefault) — Ctrl+U would otherwise kill-to-line-start, Enter would
// fire the input's own submit, ↑↓ would move its cursor.
const count = grouped().flat.length
if (key.name === 'return') return pick(grouped().flat[sel()])
if (key.name === 'return') {
key.preventDefault()
return pick(grouped().flat[sel()])
}
if (key.name === 'up' || (key.ctrl && key.name === 'p')) {
key.preventDefault()
if (count) setSel(s => (s - 1 + count) % count)
return
}
if (key.name === 'down' || (key.ctrl && key.name === 'n')) {
key.preventDefault()
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)
if (key.ctrl && key.name === 'u') {
key.preventDefault()
setShowAll(v => !v)
return
}
if (key.ctrl && key.name === 'r') {
key.preventDefault()
refresh()
return
}
// everything else (printables, backspace, word-delete, home/end…) belongs
// to the focused native input.
})
return (
<box
ref={el => (rootRef = el)}
focusable
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
@ -102,10 +162,21 @@ export function Picker(props: {
</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>
<input
ref={el => (inputRef = el)}
focused
onInput={setQuery}
onMouseDown={() => inputRef?.focus()}
placeholder="type to filter"
placeholderColor={theme().color.muted}
textColor={theme().color.text}
cursorColor={theme().color.accent}
backgroundColor="transparent"
focusedBackgroundColor="transparent"
style={{ flexGrow: 1, minWidth: 0 }}
/>
<Show when={refreshing()}>
<text fg={theme().color.muted}>refreshing</text>
</Show>
</box>
<Show when={win().above > 0}>
@ -117,6 +188,9 @@ export function Picker(props: {
<text fg={theme().color.label}>
<b>{row.label}</b>
</text>
) : row.index === -1 ? (
// unavailable (unconfigured provider) — dimmed hint, never selectable
<text fg={theme().color.muted}>{` ${row.item.label}`}</text>
) : (
<text
bg={row.index === sel() ? theme().color.selectionBg : 'transparent'}
@ -136,13 +210,17 @@ export function Picker(props: {
)
}
</For>
<Show when={grouped().flat.length === 0}>
<Show when={filtered().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>
<text fg={theme().color.muted}>
{`↑↓ select · Enter pick${
hasUnavailable() ? ` · Ctrl+U ${showAll() ? 'hide' : 'show'} unconfigured` : ''
}${canRefreshPicker() ? ' · Ctrl+R refresh' : ''} · Esc close`}
</text>
</box>
)
}