mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
opentui(v6): picker v2.1 — provider search, availability toggle, native input, manual refresh
This commit is contained in:
parent
7ad05a3129
commit
daa4412378
7 changed files with 512 additions and 76 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 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<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 =>
|
||||
|
|
|
|||
|
|
@ -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)`. */
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue