opentui(v6): model picker provider tabs (nous-first chip strip)

This commit is contained in:
alt-glitch 2026-06-10 23:09:41 +05:30
parent 018c8fb17f
commit b957dc6f72
4 changed files with 330 additions and 13 deletions

View file

@ -245,6 +245,27 @@ export function mapModelOptions(opts: unknown): PickerItem[] {
return items
}
/**
* Provider tab order for the model picker's chip strip (picker v2.2): each
* CONFIGURED provider's group (= lab display name) in catalog order, with
* Nous-identified groups (slug or lab name containing `nous`) hoisted to the
* front. Unconfigured providers (`unavailable` hint rows) get NO tab they
* stay reachable via Ctrl+U under the picker's trailing `All` tab (which the
* picker appends itself; it is not part of this list).
*/
export function buildModelTabs(items: readonly PickerItem[]): string[] {
const seen = new Set<string>()
const nous: string[] = []
const rest: string[] = []
for (const it of items) {
if (it.unavailable || !it.group || seen.has(it.group)) continue
seen.add(it.group)
const identity = [it.group, ...(it.haystacks ?? [])].join(' ').toLowerCase()
;(identity.includes('nous') ? nous : rest).push(it.group)
}
return [...nous, ...rest]
}
/** Flatten `skills.manage {action:'list'}` ({skills: Record<category, names[]>}) into
* grouped picker rows (category = group header; also a fuzzy haystack). */
function mapSkills(result: unknown): PickerItem[] {
@ -295,6 +316,26 @@ export function runPickerRefresh(): Promise<PickerItem[]> | undefined {
return activePickerRefresh?.()
}
/**
* The open picker's tab-strip seam (picker v2.2 provider tabs) same pattern
* as the refresh seam above: whoever opens a picker registers (or clears) a
* tab DERIVATION over the picker's live rows; the mounted Picker re-derives
* through it whenever the rows swap (Ctrl+R), so fresh providers grow chips
* without re-opening. `/model` registers `buildModelTabs`; pickers without
* tabs (skills) clear it and render the classic stripless view.
*/
let activePickerTabs: ((items: readonly PickerItem[]) => string[]) | undefined
/** Register (or clear, with `undefined`) the open picker's tab derivation. */
export function registerPickerTabs(fn: ((items: readonly PickerItem[]) => string[]) | undefined): void {
activePickerTabs = fn
}
/** Derive the open picker's tabs from its rows; [] when no tabs are registered. */
export function pickerTabs(items: readonly PickerItem[]): string[] {
return activePickerTabs?.(items) ?? []
}
/** Switch the model via the server (shared by `/model <name>` and the picker pick).
* A successful switch refreshes the cached rows in the background (fresh ). */
async function switchModel(ctx: SlashContext, name: string): Promise<void> {
@ -318,6 +359,8 @@ const modelCmd: ClientHandler = async (arg, ctx) => {
const open = (items: PickerItem[]) => {
// Ctrl+R in the open picker re-fetches the catalog (and re-syncs the cache).
registerPickerRefresh(() => refreshModelItems(ctx))
// Provider chip strip (picker v2.2): Nous-first configured-provider tabs.
registerPickerTabs(buildModelTabs)
ctx.openPicker({ items, onPick: name => void switchModel(ctx, name), title: 'Switch model' })
}
const cached = ctx.modelItems()
@ -343,6 +386,7 @@ const skillsCmd: ClientHandler = async (_arg, ctx) => {
return
}
registerPickerRefresh(undefined) // no Ctrl+R catalog re-fetch for skills (yet)
registerPickerTabs(undefined) // no tab strip for skills — classic grouped view
ctx.openPicker({
items,
onPick: name =>

View file

@ -13,15 +13,18 @@
*/
import { afterEach, describe, expect, test } from 'vitest'
import { registerPickerRefresh } from '../logic/slash.ts'
import { buildModelTabs, registerPickerRefresh, registerPickerTabs } 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))
// the Ctrl+R + tab-strip seams are module-level state — never leak them across tests
afterEach(() => {
registerPickerRefresh(undefined)
registerPickerTabs(undefined)
})
/** A grouped model catalog: current = claude-sonnet-4 under Anthropic. */
const ITEMS: PickerItem[] = [
@ -360,3 +363,136 @@ describe('Picker — manual catalog refresh (Ctrl+R, v2.1)', () => {
}
})
})
/** The chip-strip line of a frame: the active chip is always bracketed and the
* trailing `All` chip always present, which no other picker line has both of. */
function stripLine(frame: string): string {
return frame.split('\n').find(l => l.includes('All') && l.includes('[ ')) ?? ''
}
describe('Picker — provider tabs (v2.2 chip strip)', () => {
test('chips order Nous-first then catalog order then All; open lands on the CURRENT provider tab with the ✓ selected', async () => {
registerPickerTabs(buildModelTabs)
const h = await mountPicker() // catalog order: Anthropic, OpenAI, Nous Research
try {
const frame = h.probe.frame()
const strip = stripLine(frame)
// Nous hoisted ahead of catalog order; All trails; current (Anthropic) chip active.
expect(strip.indexOf('Nous Research')).toBeGreaterThanOrEqual(0)
expect(strip.indexOf('Nous Research')).toBeLessThan(strip.indexOf('Anthropic'))
expect(strip.indexOf('Anthropic')).toBeLessThan(strip.indexOf('OpenAI'))
expect(strip.indexOf('OpenAI')).toBeLessThan(strip.indexOf('All'))
expect(strip).toContain('[ Anthropic ]')
// rows are tab-filtered to the active provider; ✓ row selected.
expect(frame).toContain(' claude-sonnet-4 ✓')
expect(frame).toContain('claude-opus-4')
expect(frame).not.toContain('gpt-5')
expect(frame).not.toContain('hermes-4-405b')
// footer hints the strip
expect(frame).toContain('Tab provider')
} finally {
h.probe.destroy()
}
})
test('Tab cycles forward and WRAPS (… → OpenAI → All → Nous → …); Shift+Tab cycles back; returning lands on the ✓', async () => {
registerPickerTabs(buildModelTabs)
const h = await mountPicker()
try {
// order: Nous Research, Anthropic(active), OpenAI, All
h.probe.keys.pressTab()
await h.probe.settle()
let frame = h.probe.frame()
expect(stripLine(frame)).toContain('[ OpenAI ]')
expect(frame).toContain(' gpt-5')
expect(frame).not.toContain('claude-sonnet-4')
h.probe.keys.pressTab() // → All: the classic full grouped view
await h.probe.settle()
frame = h.probe.frame()
expect(stripLine(frame)).toContain('[ All ]')
expect(frame).toContain('claude-sonnet-4')
expect(frame).toContain('gpt-5')
expect(frame).toContain('hermes-4-405b')
h.probe.keys.pressTab() // wrap → Nous Research (first chip)
await h.probe.settle()
frame = h.probe.frame()
expect(stripLine(frame)).toContain('[ Nous Research ]')
expect(frame).toContain(' hermes-4-405b')
h.probe.keys.pressTab({ shift: true }) // back → All
await h.probe.settle()
expect(stripLine(h.probe.frame())).toContain('[ All ]')
h.probe.keys.pressTab({ shift: true }) // back → OpenAI
h.probe.keys.pressTab({ shift: true }) // back → Anthropic: ✓ re-selected
await h.probe.settle()
frame = h.probe.frame()
expect(stripLine(frame)).toContain('[ Anthropic ]')
expect(frame).toContain(' claude-sonnet-4 ✓')
} finally {
h.probe.destroy()
}
})
test('←/→ cycle ONLY while the query is empty; the active tab filters BEFORE the fuzzy query', async () => {
registerPickerTabs(buildModelTabs)
const h = await mountPicker()
try {
h.probe.keys.pressArrow('right') // empty query → Anthropic → OpenAI
await h.probe.settle()
expect(stripLine(h.probe.frame())).toContain('[ OpenAI ]')
// search WITHIN the tab: hermes-4-405b exists under Nous, not here
await h.probe.keys.typeText('hermes')
await h.probe.settle()
let frame = await h.probe.waitForFrame(f => f.includes('(no matches)'))
expect(frame).not.toContain('hermes-4-405b')
// with a non-empty query ←/→ stay native cursor moves — no cycling
h.probe.keys.pressArrow('left')
await h.probe.settle()
expect(stripLine(h.probe.frame())).toContain('[ OpenAI ]')
// Tab still cycles with a query; the query carries over (composes per tab)
h.probe.keys.pressTab() // → All
await h.probe.settle()
frame = await h.probe.waitForFrame(f => f.includes('hermes-4-405b'))
expect(stripLine(frame)).toContain('[ All ]')
expect(frame).not.toContain('gpt-5') // fuzzy query still applied under All
} finally {
h.probe.destroy()
}
})
test('unconfigured providers get NO chips; Ctrl+U under All reveals them (unchanged); provider tabs drop the hint', async () => {
registerPickerTabs(buildModelTabs)
const h = await mountPicker(MIXED) // tabs: Anthropic, OpenAI (Mistral/Copilot unconfigured)
try {
let frame = h.probe.frame()
const strip = stripLine(frame)
expect(strip).not.toContain('Mistral')
expect(strip).not.toContain('GitHub Copilot')
// active provider tab: Ctrl+U does not apply (no unconfigured rows here)
expect(frame).not.toContain('Ctrl+U show unconfigured')
h.probe.keys.pressTab()
h.probe.keys.pressTab() // Anthropic → OpenAI → All
await h.probe.settle()
frame = h.probe.frame()
expect(stripLine(frame)).toContain('[ All ]')
expect(frame).toContain('Ctrl+U show unconfigured')
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('no API key — set GITHUB_TOKEN')
} finally {
h.probe.destroy()
}
})
test('without a registered tab derivation the picker stays stripless (skills view)', async () => {
const h = await mountPicker()
try {
const frame = h.probe.frame()
expect(frame).not.toContain('All')
expect(frame).not.toContain('Tab provider')
} finally {
h.probe.destroy()
}
})
})

View file

@ -6,12 +6,15 @@ import { afterEach, describe, expect, test } from 'vitest'
import type { DetailsMode } from '../logic/details.ts'
import {
buildModelTabs,
dispatchSlash,
mapCompletions,
parseSlash,
pickerTabs,
planCompletion,
readReplaceFrom,
registerPickerRefresh,
registerPickerTabs,
runPickerRefresh,
type SlashContext
} from '../logic/slash.ts'
@ -19,8 +22,11 @@ import type { PickerItem, SessionItem } from '../logic/store.ts'
const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }]
// the picker-refresh seam is module-level state — never leak it across tests
afterEach(() => registerPickerRefresh(undefined))
// the picker-refresh/tabs seams are module-level state — never leak them across tests
afterEach(() => {
registerPickerRefresh(undefined)
registerPickerTabs(undefined)
})
/** A `model.options` payload: two authed providers + two unconfigured skeleton
* rows (the gateway sends them with `include_unconfigured=True,
@ -364,6 +370,35 @@ describe('dispatchSlash — client commands', () => {
expect(p.calls.filter(c => c.method === 'model.options')).toHaveLength(2)
})
test('buildModelTabs: Nous-identified groups first, then catalog order; unconfigured providers get NO tab', () => {
const items: PickerItem[] = [
{ group: 'Anthropic', haystacks: ['anthropic', 'Anthropic'], label: 'claude-sonnet-4.6', value: 'a' },
{ group: 'Anthropic', haystacks: ['anthropic', 'Anthropic'], label: 'claude-opus-4.6', value: 'b' },
{
group: 'OpenAI API',
haystacks: ['openai-api', 'OpenAI API'],
label: 'no API key',
unavailable: true,
value: 'openai-api'
},
{ group: 'GitHub Copilot', haystacks: ['copilot', 'GitHub Copilot'], label: 'gpt-5', value: 'c' },
// Nous identified via the SLUG haystack even when the display name hides it
{ group: 'Portal', haystacks: ['nous-portal', 'Portal'], label: 'hermes-4-405b', value: 'd' }
]
expect(buildModelTabs(items)).toEqual(['Portal', 'Anthropic', 'GitHub Copilot'])
expect(buildModelTabs([])).toEqual([])
})
test('/model registers the provider-tab seam (buildModelTabs); /skills clears it back to stripless', async () => {
const p = makeCtx(async method => (method === 'model.options' ? MODEL_OPTIONS : { output: 'switched' }))
await dispatchSlash('/model', p.ctx)
// the open picker derives Nous-first tabs through the seam
expect(pickerTabs(p.pickers[0]!.items)).toEqual(['Nous Research', 'Anthropic'])
const p2 = makeCtx(async () => ({ skills: { General: ['memory'] } }))
await dispatchSlash('/skills', p2.ctx)
expect(pickerTabs(p.pickers[0]!.items)).toEqual([])
})
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)

View file

@ -20,15 +20,26 @@
* (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.
* v2.2 (provider tabs, user feedback): when the opener registers a tab
* derivation (registerPickerTabs `/model` registers buildModelTabs), a chip
* strip renders under the query line: one chip per configured provider
* (Nous-first) plus a trailing `All` (the classic full grouped view, where
* Ctrl+U still reveals unconfigured providers). The active tab filters rows
* BEFORE the fuzzy query (search-within-tab). The strip is styled text NOT a
* focused tab_select so the search input keeps focus; Tab/Shift+Tab cycle
* (free inside the picker: the composer and its completion menu are unmounted
* while an overlay replaces them), and / also cycle while the query is
* empty (the resume-picker design doc §A specs this same pattern).
*
* Everything heavy is memoized off (query, toggle, tab, items): keystrokes
* re-score at most once and unrelated store updates don't.
*/
import type { BoxRenderable, InputRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
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 { canRefreshPicker, pickerTabs, runPickerRefresh } from '../../logic/slash.ts'
import type { PickerItem } from '../../logic/store.ts'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
@ -48,6 +59,37 @@ function fieldsOf(item: PickerItem): FuzzyField[] {
return fields
}
/**
* Styled-text chip strip (the picker's tab bar) deliberately a standalone,
* reusable piece: the resume-session picker renders the SAME strip (design doc
* docs/plans/opentui-resume-picker.md §A chips as styled text, NOT a focused
* `<tab_select>`, so focus stays on the search input). Active chip: bracketed
* + theme-highlighted; inactive: dimmed with breathing space.
*/
export function TabChips(props: { labels: string[]; active: number }) {
const theme = useTheme()
// Chip descriptors are derived in a memo: `<For>` runs its callback OUTSIDE
// a tracking scope (Solid mapArray), so a ternary on `props.active` inside
// it would never re-render. Fresh descriptor objects per change re-key the
// <For> instead — a handful of chips, so the re-create is free.
const chips = createMemo(() => props.labels.map((label, at) => ({ active: at === props.active, label })))
return (
<text>
<For each={chips()}>
{chip =>
chip.active ? (
<span style={{ bg: theme().color.selectionBg, fg: theme().color.accent }}>
<b>{`[ ${chip.label} ]`}</b>
</span>
) : (
<span style={{ fg: theme().color.muted }}>{` ${chip.label} `}</span>
)
}
</For>
</text>
)
}
export function Picker(props: {
title: string
items: PickerItem[]
@ -72,10 +114,33 @@ export function Picker(props: {
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))
// ── provider tabs (v2.2) ────────────────────────────────────────────────
// Derived through the opener-registered seam over the LIVE rows (a Ctrl+R
// swap re-derives). `undefined` = the trailing `All` tab (classic view).
const tabs = createMemo(() => pickerTabs(items()))
// Open lands on the CURRENT (✓) item's provider tab; All when it has none.
const [activeTab, setActiveTab] = createSignal<string | undefined>(
(() => {
const cur = props.items.find(it => it.current)?.group
return cur !== undefined && pickerTabs(props.items).includes(cur) ? cur : undefined
})()
)
// A live-refresh can drop the active tab's provider — degrade to All.
const tab = createMemo(() => {
const t = activeTab()
return t !== undefined && tabs().includes(t) ? t : undefined
})
// Ctrl+U only applies under All (provider tabs never hold unavailable rows).
const hasUnavailable = createMemo(() => tab() === undefined && 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)))
// The active tab filters BEFORE the fuzzy query (search-within-tab).
const pool = createMemo(() => {
const t = tab()
if (t !== undefined) return items().filter(it => !it.unavailable && it.group === t)
return showAll() ? items() : items().filter(it => !it.unavailable)
})
const filtered = createMemo(() => fuzzyFilter(query(), pool(), fieldsOf))
const grouped = createMemo(() =>
buildPickerRows(
@ -85,14 +150,32 @@ export function Picker(props: {
)
)
// Start on the current (✓) item; reset to the top match whenever the filter changes.
// Start on the current (✓) item; reset to the top match whenever the QUERY,
// the Ctrl+U toggle or a live row swap changes the filter. Tab switches are
// deliberately NOT in this list — cycleTab re-seats the selection itself
// (on the ✓ row when the new tab holds it).
const [sel, setSel] = createSignal(
Math.max(
0,
grouped().flat.findIndex(it => it.current)
)
)
createEffect(on(filtered, () => setSel(0), { defer: true }))
createEffect(on([query, showAll, live], () => setSel(0), { defer: true }))
/** Cycle the chip strip (Tab/Shift+Tab; / on an empty query): provider
* tabs in registered order, then the trailing All, wrapping both ways. */
const cycleTab = (dir: 1 | -1) => {
const order: (string | undefined)[] = [...tabs(), undefined]
if (order.length <= 1) return
const at = order.indexOf(tab())
setActiveTab(order[(at + dir + order.length) % order.length])
setSel(
Math.max(
0,
grouped().flat.findIndex(it => it.current)
)
)
}
const win = createMemo(() => visibleRows(grouped().rows, sel(), MAX_ROWS))
@ -136,6 +219,19 @@ export function Picker(props: {
if (count) setSel(s => (s + 1) % count)
return
}
// Tab strip cycling (v2.2): Tab is FREE inside the picker (the composer's
// completion-accept Tab is unmounted while an overlay replaces it); ←/→
// only cycle on an EMPTY query — with text they stay native cursor moves.
if (key.name === 'tab' && tabs().length) {
key.preventDefault()
cycleTab(key.shift ? -1 : 1)
return
}
if ((key.name === 'left' || key.name === 'right') && tabs().length && !query()) {
key.preventDefault()
cycleTab(key.name === 'left' ? -1 : 1)
return
}
if (key.ctrl && key.name === 'u') {
key.preventDefault()
setShowAll(v => !v)
@ -179,6 +275,12 @@ export function Picker(props: {
<text fg={theme().color.muted}>refreshing</text>
</Show>
</box>
<Show when={tabs().length > 0}>
<TabChips
labels={[...tabs(), 'All']}
active={tab() === undefined ? tabs().length : tabs().indexOf(tab() as string)}
/>
</Show>
<Show when={win().above > 0}>
<text fg={theme().color.muted}>{`${win().above} more`}</text>
</Show>
@ -217,7 +319,7 @@ export function Picker(props: {
<text fg={theme().color.muted}>{`${win().below} more`}</text>
</Show>
<text fg={theme().color.muted}>
{`↑↓ select · Enter pick${
{`↑↓ select · Enter pick${tabs().length ? ' · Tab provider' : ''}${
hasUnavailable() ? ` · Ctrl+U ${showAll() ? 'hide' : 'show'} unconfigured` : ''
}${canRefreshPicker() ? ' · Ctrl+R refresh' : ''} · Esc close`}
</text>