feat(opentui-v2): Phase 5c — model picker + skills hub (generic Picker overlay)

A reusable generic picker (titled <select> + onPick) powers two more first-class
overlays (spec §2b):

- view/overlays/picker.tsx + store picker/openPicker/closePicker + PickerItem.
- /model: bare → model.options → a picker of authenticated providers' models
  (current marked ✓), pick switches via `slash.exec model <name>`; `/model <name>`
  switches directly without the picker.
- /skills: skills.manage {action:list} → a picker flattened from
  {category: names[]}; picking inspects (skills.manage inspect) → the pager.
- view/App.tsx: the input zone is now a <Switch> — prompt → switcher → picker →
  composer (overlays replace, never stack, so the composer remounts/refocuses).

Verified: bun run check green (47 tests / 7 files) — /model bare→picker (auth
filtered, current marked, pick→slash.exec), /model <name> direct, /skills flatten.
LIVE tmux: /model → picker listing 8 models (anthropic/claude-opus-4.8 ▶, nous,
…), Esc closed clean; /skills → hub listing skills w/ category descriptions.
5 of 6 first-class overlays done (prompts, pager, session switcher, model picker,
skills hub) — completions dropdown remains. Smoke P5c + matrix updated.
(Note: model.options is ~5s server-side; a loading indicator is a polish TODO.)
This commit is contained in:
alt-glitch 2026-06-08 16:08:25 +00:00
parent ba10594322
commit d4d7c9b0ae
6 changed files with 253 additions and 26 deletions

View file

@ -168,6 +168,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
.tail(200)
.map(e => `${e.scope}: ${e.msg}`),
openPager: (title, text) => store.openPager(title, text),
openPicker: picker => store.openPicker(picker),
openSwitcher: sessions => store.openSwitcher(sessions),
pushSystem: text => store.pushSystem(text),
quit: () => {

View file

@ -11,7 +11,7 @@
* (exec/plugin system · alias re-dispatch · skill/send submit a turn ·
* prefill notice). Long output routes to the pager (Phase 5a).
*/
import type { SessionItem } from './store.ts'
import type { PickerItem, PickerState, SessionItem } from './store.ts'
export interface ParsedSlash {
name: string
@ -47,6 +47,8 @@ export interface SlashContext {
readonly listSessions: () => Promise<SessionItem[]>
/** Open the session switcher with the given rows. */
readonly openSwitcher: (sessions: SessionItem[]) => void
/** Open a generic picker (model picker, skills hub). */
readonly openPicker: (picker: PickerState) => void
}
function readStr(value: unknown, key: string): string | undefined {
@ -66,6 +68,8 @@ function present(ctx: SlashContext, title: string, text: string): void {
const CLIENT_HELP = [
'/help — list commands',
'/model [name] — switch model (picker if bare)',
'/skills — browse skills',
'/sessions, /resume — switch/resume a session',
'/clear, /new — clear the transcript (confirm)',
'/logs — recent engine log lines',
@ -82,13 +86,89 @@ const openSwitcher: ClientHandler = async (_arg, ctx) => {
else ctx.pushSystem('No sessions to resume.')
}
/** Flatten `model.options` (authenticated providers' models) into picker rows; mark the current. */
function mapModelOptions(opts: unknown): PickerItem[] {
if (!opts || typeof opts !== 'object') return []
const providers = (opts as { providers?: unknown }).providers
if (!Array.isArray(providers)) return []
const current = readStr(opts, 'model')
const items: PickerItem[] = []
for (const p of providers) {
if (!p || typeof p !== 'object' || (p as { authenticated?: unknown }).authenticated !== true) continue
const slug = readStr(p, 'slug') ?? readStr(p, 'name') ?? ''
const models = (p as { models?: unknown }).models
if (!Array.isArray(models)) continue
for (const m of models) {
if (typeof m === 'string') items.push({ description: slug, label: m === current ? `${m}` : m, value: m })
}
}
return items
}
/** Flatten `skills.manage {action:'list'}` ({skills: Record<category, names[]>}) into picker rows. */
function mapSkills(result: unknown): PickerItem[] {
if (!result || typeof result !== 'object') return []
const skills = (result as { skills?: unknown }).skills
if (!skills || typeof skills !== 'object') return []
const items: PickerItem[] = []
for (const [category, names] of Object.entries(skills as { [k: string]: unknown })) {
if (!Array.isArray(names)) continue
for (const n of names) if (typeof n === 'string') items.push({ description: category, label: n, value: n })
}
return items
}
/** Switch the model via the server (shared by `/model <name>` and the picker pick). */
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}`)
} catch (error) {
ctx.pushSystem(`/model ${name}: ${error instanceof Error ? error.message : 'switch failed'}`)
}
}
/** `/model` — bare opens the model picker; `/model <name>` switches directly. */
const modelCmd: ClientHandler = async (arg, ctx) => {
if (arg.trim()) {
await switchModel(ctx, arg.trim())
return
}
const items = mapModelOptions(await ctx.request('model.options', {}))
if (!items.length) {
ctx.pushSystem('No models available (no authenticated providers).')
return
}
ctx.openPicker({ items, onPick: name => void switchModel(ctx, name), title: 'Switch model' })
}
/** `/skills` — open the skills hub; picking a skill shows its info in the pager. */
const skillsCmd: ClientHandler = async (_arg, ctx) => {
const items = mapSkills(await ctx.request('skills.manage', { action: 'list' }))
if (!items.length) {
ctx.pushSystem('No skills found.')
return
}
ctx.openPicker({
items,
onPick: name =>
void ctx
.request('skills.manage', { action: 'inspect', query: name })
.then(info => ctx.openPager(`Skill: ${name}`, readStr(info, 'info') || JSON.stringify(info, null, 2)))
.catch(() => ctx.pushSystem(`/skills: could not inspect ${name}`)),
title: 'Skills'
})
}
/** The TUI-only client commands (run in-process, never hit the gateway). */
const CLIENT: Record<string, ClientHandler> = {
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
exit: (_arg, ctx) => ctx.quit(),
model: modelCmd,
resume: openSwitcher,
session: openSwitcher,
sessions: openSwitcher,
skills: skillsCmd,
switch: openSwitcher,
help: async (_arg, ctx) => {
// Prefer the live catalog; fall back to the client list if it's unavailable.

View file

@ -72,6 +72,20 @@ export interface SessionItem {
messageCount: number
}
/** A row in a generic `<select>` picker (model picker, skills hub, …). */
export interface PickerItem {
label: string
description?: string
value: string
}
/** An open generic picker overlay: a titled list whose pick runs `onPick(value)`. */
export interface PickerState {
title: string
items: PickerItem[]
onPick: (value: string) => void
}
export interface StoreState {
ready: boolean
messages: Message[]
@ -82,6 +96,8 @@ export interface StoreState {
pager: PagerState | undefined
/** The open session switcher (replaces the composer while set); undefined when none. */
switcher: SessionItem[] | undefined
/** The open generic picker (model/skills/…); undefined when none. */
picker: PickerState | undefined
}
const LRU_LIMIT = 1000
@ -99,7 +115,8 @@ export function createSessionStore() {
theme: DEFAULT_THEME,
prompt: undefined,
pager: undefined,
switcher: undefined
switcher: undefined,
picker: undefined
})
// Monotonic part id (stable `key` per part so a new tool part below a streaming
@ -213,6 +230,16 @@ export function createSessionStore() {
setState('switcher', undefined)
}
/** Open the generic picker (model picker, skills hub, …). */
function openPicker(picker: PickerState) {
setState('picker', picker)
}
/** Close the generic picker. */
function closePicker() {
setState('picker', undefined)
}
/** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */
function apply(event: GatewayEvent): void {
if (buffering) {
@ -379,6 +406,8 @@ export function createSessionStore() {
closePager,
openSwitcher,
closeSwitcher,
openPicker,
closePicker,
hydrate,
beginBuffer,
commitSnapshot,

View file

@ -5,7 +5,7 @@
import { describe, expect, test } from 'bun:test'
import { dispatchSlash, parseSlash, type SlashContext } from '../logic/slash.ts'
import type { SessionItem } from '../logic/store.ts'
import type { PickerItem, SessionItem } from '../logic/store.ts'
const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }]
@ -26,6 +26,7 @@ interface Probe {
confirmed: Array<{ message: string; onConfirm: () => void }>
paged: Array<{ title: string; text: string }>
switched: SessionItem[][]
pickers: Array<{ title: string; items: PickerItem[]; onPick: (value: string) => void }>
quit: { value: boolean }
cleared: { value: boolean }
}
@ -37,6 +38,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
const confirmed: Probe['confirmed'] = []
const paged: Probe['paged'] = []
const switched: Probe['switched'] = []
const pickers: Probe['pickers'] = []
const quit = { value: false }
const cleared = { value: false }
const ctx: SlashContext = {
@ -45,6 +47,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
listSessions: () => Promise.resolve(FAKE_SESSIONS),
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
openPager: (title, text) => paged.push({ text, title }),
openPicker: p => pickers.push(p),
openSwitcher: sessions => switched.push(sessions),
pushSystem: text => system.push(text),
quit: () => (quit.value = true),
@ -55,7 +58,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
sessionId: () => 'sid-1',
submit: text => submitted.push(text)
}
return { calls, cleared, confirmed, ctx, paged, quit, submitted, switched, system }
return { calls, cleared, confirmed, ctx, paged, pickers, quit, submitted, switched, system }
}
describe('dispatchSlash — client commands', () => {
@ -92,6 +95,55 @@ describe('dispatchSlash — client commands', () => {
expect(p2.switched).toHaveLength(1)
})
test('/model (bare) opens a picker of authenticated providers models; pick switches', async () => {
const p = makeCtx(async method => {
if (method === 'model.options')
return {
model: 'claude-sonnet-4.6',
providers: [
{
authenticated: true,
models: ['claude-sonnet-4.6', 'claude-opus-4.6'],
name: 'Anthropic',
slug: 'anthropic'
},
{ authenticated: false, models: ['gpt-5.4'], name: 'OpenAI', slug: 'openai' }
]
}
return { output: 'switched' }
})
await dispatchSlash('/model', p.ctx)
expect(p.pickers).toHaveLength(1)
expect(p.pickers[0]!.title).toBe('Switch model')
// only the authenticated provider's models; current is marked
expect(p.pickers[0]!.items.map(i => i.value)).toEqual(['claude-sonnet-4.6', 'claude-opus-4.6'])
expect(p.pickers[0]!.items[0]!.label).toContain('✓')
// picking switches via slash.exec `model <name>`
p.pickers[0]!.onPick('claude-opus-4.6')
await Promise.resolve()
expect(p.calls.some(c => c.method === 'slash.exec' && c.params.command === 'model claude-opus-4.6')).toBe(true)
})
test('/model <name> switches directly without opening the picker', async () => {
const p = makeCtx(async () => ({ output: 'ok' }))
await dispatchSlash('/model anthropic/claude-opus-4.6', p.ctx)
expect(p.pickers).toHaveLength(0)
expect(p.calls[0]).toEqual({
method: 'slash.exec',
params: { command: 'model anthropic/claude-opus-4.6', session_id: 'sid-1' }
})
})
test('/skills opens a picker flattened from skills.manage list', async () => {
const p = makeCtx(async method =>
method === 'skills.manage' ? { skills: { media: ['ffmpeg', 'whisper'], web: ['firecrawl'] } } : {}
)
await dispatchSlash('/skills', p.ctx)
expect(p.pickers).toHaveLength(1)
expect(p.pickers[0]!.title).toBe('Skills')
expect(p.pickers[0]!.items.map(i => i.value).sort()).toEqual(['ffmpeg', 'firecrawl', 'whisper'])
})
test('/help renders the gateway catalog', async () => {
const p = makeCtx(async method =>
method === 'commands.catalog' ? { pairs: [['/model', 'switch model']], canon: {} } : {}

View file

@ -1,25 +1,25 @@
/**
* App the Solid view shell (spec v4 §2 `view/App.tsx`). Header + a content zone
* that is either the PAGER overlay (long slash output) or the normal
* transcript + input zone; the input zone swaps the composer for a blocking-prompt
* overlay when one is active. Fully themed via the ThemeProvider (§7.5).
* transcript + input zone; the input zone is one of: blocking prompt, session
* switcher, generic picker (model/skills), or the composer. Fully themed (§7.5).
*
* header flexShrink:0 (top chrome line)
* content flexGrow:1, minHeight:0 Pager OR (transcript + input zone)
* transcript flexGrow:1, minHeight:0 (the one <scrollbox>; §8 #2 gotchas)
* input zone flexShrink:0 (Composer, OR PromptOverlay when blocked)
* input zone flexShrink:0 (PromptOverlay | SessionSwitcher | Picker | Composer)
*
* Overlays REPLACE rather than stack: a blocking prompt replaces the composer
* (§8 #6 deadlock fix); the pager replaces transcript+composer. Replacing (not
* hiding) means the composer remounts + refocuses when an overlay closes, and the
* key that closed the overlay can't leak into it (the close is deferred a tick).
* Overlays REPLACE rather than stack (a `<Switch>`), so the composer remounts +
* refocuses when an overlay closes; the key that closed an overlay can't leak
* into it because the close is deferred a tick.
*/
import { Show } from 'solid-js'
import { Match, Show, Switch } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { Composer } from './composer.tsx'
import { Header } from './header.tsx'
import { Pager } from './overlays/pager.tsx'
import { Picker } from './overlays/picker.tsx'
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
import { PromptOverlay } from './prompts/promptOverlay.tsx'
import { Transcript } from './transcript.tsx'
@ -41,10 +41,12 @@ export function App(props: AppProps) {
const blocked = () => props.store.state.prompt !== undefined
const pager = () => props.store.state.pager
const switcher = () => props.store.state.switcher
const picker = () => props.store.state.picker
// Defer the close so the key that closed an overlay (Esc/q/Enter) can't land in
// the freshly-remounted composer.
const closePager = () => setTimeout(() => props.store.closePager(), 0)
const closeSwitcher = () => setTimeout(() => props.store.closeSwitcher(), 0)
const closePicker = () => setTimeout(() => props.store.closePicker(), 0)
const resume = (id: string) => {
;(props.onResume ?? NOOP_RESUME)(id)
closeSwitcher()
@ -58,20 +60,31 @@ export function App(props: AppProps) {
fallback={
<>
<Transcript store={props.store} />
<Show
when={blocked()}
fallback={
<Show when={switcher()} fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
</Show>
}
>
<PromptOverlay
store={props.store}
onRespond={props.onRespond ?? NOOP_RESPOND}
sessionId={props.sessionId ?? NO_SESSION}
/>
</Show>
<Switch fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
<Match when={blocked()}>
<PromptOverlay
store={props.store}
onRespond={props.onRespond ?? NOOP_RESPOND}
sessionId={props.sessionId ?? NO_SESSION}
/>
</Match>
<Match when={switcher()}>
{sessions => <SessionSwitcher sessions={sessions()} onPick={resume} onClose={closeSwitcher} />}
</Match>
<Match when={picker()}>
{p => (
<Picker
title={p().title}
items={p().items}
onPick={value => {
p().onPick(value)
closePicker()
}}
onClose={closePicker}
/>
)}
</Match>
</Switch>
</>
}
>

View file

@ -0,0 +1,52 @@
/**
* Picker a generic titled `<select>` overlay (spec §2b). Powers the model
* picker (/model) and skills hub (/skills); the chosen value runs `onPick`.
* Native select nav (/j/k/Enter); a small useKeyboard adds Esc/Ctrl+C close.
* Replaces the composer while open.
*/
import { useKeyboard } from '@opentui/solid'
import { createMemo } from 'solid-js'
import type { PickerItem } from '../../logic/store.ts'
import { useTheme } from '../theme.tsx'
export function Picker(props: {
title: string
items: PickerItem[]
onPick: (value: string) => void
onClose: () => void
}) {
const theme = useTheme()
useKeyboard(key => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) props.onClose()
})
const options = createMemo(() =>
props.items.map(it => ({ description: it.description ?? '', name: it.label, value: it.value }))
)
return (
<box
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.accent}>
<b>{props.title}</b>
</text>
<select
focused
options={options()}
onSelect={(_index, option) => {
if (option) props.onPick(String(option.value))
}}
backgroundColor={theme().color.statusBg}
selectedBackgroundColor={theme().color.selectionBg}
textColor={theme().color.text}
selectedTextColor={theme().color.text}
descriptionColor={theme().color.muted}
style={{ height: Math.min(16, Math.max(2, options().length * 2)), marginTop: 1 }}
/>
<text fg={theme().color.muted}> select · Enter choose · Esc cancel</text>
</box>
)
}