mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 13:41:53 +00:00
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:
parent
ba10594322
commit
d4d7c9b0ae
6 changed files with 253 additions and 26 deletions
|
|
@ -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: () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {} } : {}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
52
ui-tui-opentui-v2/src/view/overlays/picker.tsx
Normal file
52
ui-tui-opentui-v2/src/view/overlays/picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue