From d4d7c9b0ae763e748bf9ab91e9a6217c5b3293d2 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 16:08:25 +0000 Subject: [PATCH] =?UTF-8?q?feat(opentui-v2):=20Phase=205c=20=E2=80=94=20mo?= =?UTF-8?q?del=20picker=20+=20skills=20hub=20(generic=20Picker=20overlay)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reusable generic picker (titled ` 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, diff --git a/ui-tui-opentui-v2/src/test/slash.test.ts b/ui-tui-opentui-v2/src/test/slash.test.ts index c064632c7e5..b342cd7941d 100644 --- a/ui-tui-opentui-v2/src/test/slash.test.ts +++ b/ui-tui-opentui-v2/src/test/slash.test.ts @@ -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) => 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) => 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) => 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 ` + 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 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: {} } : {} diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index 3ff1d6d35dd..46b069f9958 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -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 ; §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 ``), 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={ <> - }> - {sessions => } - - } - > - - + }> + + + + + {sessions => } + + + {p => ( + { + p().onPick(value) + closePicker() + }} + onClose={closePicker} + /> + )} + + } > diff --git a/ui-tui-opentui-v2/src/view/overlays/picker.tsx b/ui-tui-opentui-v2/src/view/overlays/picker.tsx new file mode 100644 index 00000000000..abb848a5169 --- /dev/null +++ b/ui-tui-opentui-v2/src/view/overlays/picker.tsx @@ -0,0 +1,52 @@ +/** + * Picker — a generic titled ` { + 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 }} + /> + ↑↓ select · Enter choose · Esc cancel + + ) +}