mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
opentui(v6): model picker provider tabs (nous-first chip strip)
This commit is contained in:
parent
018c8fb17f
commit
b957dc6f72
4 changed files with 330 additions and 13 deletions
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue