From 72ee55ed53c1fd6f87b94e4cfc36bcc389a325bf Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 21:30:55 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20picker=20v2.1=20=E2=80=94=20prov?= =?UTF-8?q?ider=20search,=20availability=20toggle,=20native=20input,=20man?= =?UTF-8?q?ual=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-opentui/src/logic/fuzzy.ts | 18 ++- ui-opentui/src/logic/slash.ts | 94 ++++++++--- ui-opentui/src/logic/store.ts | 4 + ui-opentui/src/test/fuzzy.test.ts | 25 +++ ui-opentui/src/test/picker.test.tsx | 204 +++++++++++++++++++++++- ui-opentui/src/test/slash.test.ts | 99 ++++++++++-- ui-opentui/src/view/overlays/picker.tsx | 144 +++++++++++++---- 7 files changed, 512 insertions(+), 76 deletions(-) diff --git a/ui-opentui/src/logic/fuzzy.ts b/ui-opentui/src/logic/fuzzy.ts index 38ed4d04392..0ecdb80078e 100644 --- a/ui-opentui/src/logic/fuzzy.ts +++ b/ui-opentui/src/logic/fuzzy.ts @@ -76,7 +76,8 @@ export function fuzzyFilter(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 = { kind: 'header'; label: string } | { kind: 'item'; item: T; index: number } /** @@ -84,11 +85,14 @@ export type PickerRow = { 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( items: readonly T[], - groupOf: (item: T) => string | undefined + groupOf: (item: T) => string | undefined, + selectableOf: (item: T) => boolean = () => true ): { rows: PickerRow[]; flat: T[] } { const order: string[] = [] const buckets = new Map() @@ -107,8 +111,12 @@ export function buildPickerRows( 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 } diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts index a4c4b83aaf8..66c4f3bc07e 100644 --- a/ui-opentui/src/logic/slash.ts +++ b/ui-opentui/src/logic/slash.ts @@ -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 ` --provider ` 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 ` --provider ` 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 `): 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 { - 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 { + 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 App→Picker 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) | undefined + +/** Register (or clear, with `undefined`) the open picker's catalog re-fetch. */ +export function registerPickerRefresh(fn: (() => Promise) | 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 | undefined { + return activePickerRefresh?.() } /** Switch the model via the server (shared by `/model ` and the picker pick). @@ -235,7 +284,7 @@ async function switchModel(ctx: SlashContext, name: string): Promise { 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 => diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index 3a4ed292235..234659b0d01 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -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)`. */ diff --git a/ui-opentui/src/test/fuzzy.test.ts b/ui-opentui/src/test/fuzzy.test.ts index 09ea1b8fe9f..ef300ac43e4 100644 --- a/ui-opentui/src/test/fuzzy.test.ts +++ b/ui-opentui/src/test/fuzzy.test.ts @@ -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', () => { diff --git a/ui-opentui/src/test/picker.test.tsx b/ui-opentui/src/test/picker.test.tsx index 40094beb732..cb897caf1d9 100644 --- a/ui-opentui/src/test/picker.test.tsx +++ b/ui-opentui/src/test/picker.test.tsx @@ -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 `` 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(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() + } + }) +}) diff --git a/ui-opentui/src/test/slash.test.ts b/ui-opentui/src/test/slash.test.ts index 4b53511b92d..8212a800508 100644 --- a/ui-opentui/src/test/slash.test.ts +++ b/ui-opentui/src/test/slash.test.ts @@ -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 --provider ` 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 ` 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') diff --git a/ui-opentui/src/view/overlays/picker.tsx b/ui-opentui/src/view/overlays/picker.tsx index 96c0f2a3ce2..7a4de4824ba 100644 --- a/ui-opentui/src/view/overlays/picker.tsx +++ b/ui-opentui/src/view/overlays/picker.tsx @@ -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 `` 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 `` 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 `) + * 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 `` 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(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 ( (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: { {' '} {'> '} - {query()} - - - type to filter + (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 }} + /> + + refreshing… 0}> @@ -117,6 +188,9 @@ export function Picker(props: { {row.label} + ) : row.index === -1 ? ( + // unavailable (unconfigured provider) — dimmed hint, never selectable + {` ${row.item.label}`} ) : ( - + (no matches) 0}> {` ↓ ${win().below} more`} - ↑↓ move · Enter choose · Esc cancel · type to filter + + {`↑↓ select · Enter pick${ + hasUnavailable() ? ` · Ctrl+U ${showAll() ? 'hide' : 'show'} unconfigured` : '' + }${canRefreshPicker() ? ' · Ctrl+R refresh' : ''} · Esc close`} + ) }