diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts index 3e78c5d0126..b9c390fac6e 100644 --- a/ui-opentui/src/logic/slash.ts +++ b/ui-opentui/src/logic/slash.ts @@ -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() + 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}) into * grouped picker rows (category = group header; also a fuzzy haystack). */ function mapSkills(result: unknown): PickerItem[] { @@ -295,6 +316,26 @@ export function runPickerRefresh(): Promise | 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 ` and the picker pick). * A successful switch refreshes the cached rows in the background (fresh ✓). */ async function switchModel(ctx: SlashContext, name: string): Promise { @@ -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 => diff --git a/ui-opentui/src/test/picker.test.tsx b/ui-opentui/src/test/picker.test.tsx index cb897caf1d9..17200c8fc53 100644 --- a/ui-opentui/src/test/picker.test.tsx +++ b/ui-opentui/src/test/picker.test.tsx @@ -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() + } + }) +}) diff --git a/ui-opentui/src/test/slash.test.ts b/ui-opentui/src/test/slash.test.ts index ad9d1c0ab7f..94bd9b7df0a 100644 --- a/ui-opentui/src/test/slash.test.ts +++ b/ui-opentui/src/test/slash.test.ts @@ -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 switches directly without opening the picker', async () => { const p = makeCtx(async () => ({ output: 'ok' })) await dispatchSlash('/model anthropic/claude-opus-4.6', p.ctx) diff --git a/ui-opentui/src/view/overlays/picker.tsx b/ui-opentui/src/view/overlays/picker.tsx index 7a4de4824ba..3410875c6d8 100644 --- a/ui-opentui/src/view/overlays/picker.tsx +++ b/ui-opentui/src/view/overlays/picker.tsx @@ -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 + * ``, 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: `` 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 + // 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 ( + + + {chip => + chip.active ? ( + + {`[ ${chip.label} ]`} + + ) : ( + {` ${chip.label} `} + ) + } + + + ) +} + export function Picker(props: { title: string items: PickerItem[] @@ -72,10 +114,33 @@ export function Picker(props: { const [live, setLive] = createSignal(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( + (() => { + 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: { refreshing… + 0}> + + 0}> {` ↑ ${win().above} more`} @@ -217,7 +319,7 @@ export function Picker(props: { {` ↓ ${win().below} more`} - {`↑↓ select · Enter pick${ + {`↑↓ select · Enter pick${tabs().length ? ' · Tab provider' : ''}${ hasUnavailable() ? ` · Ctrl+U ${showAll() ? 'hide' : 'show'} unconfigured` : '' }${canRefreshPicker() ? ' · Ctrl+R refresh' : ''} · Esc close`}