diff --git a/ui-opentui/src/boundary/ffiSafe.ts b/ui-opentui/src/boundary/ffiSafe.ts index 6d34ef2ab8f..449370d3369 100644 --- a/ui-opentui/src/boundary/ffiSafe.ts +++ b/ui-opentui/src/boundary/ffiSafe.ts @@ -28,7 +28,7 @@ * TODO(upstream): file/track an OpenTUI issue to widen these FFI params to i32 * (or clamp in core) — then this shim can be deleted. */ -import { OptimizedBuffer } from '@opentui/core' +import { OptimizedBuffer, TextBufferView } from '@opentui/core' let installed = false @@ -84,4 +84,17 @@ export function installFfiCoordSafety(): void { if (x < 0 || y < 0) return origDrawChar.call(this, char, x, y, ...rest) } + + // Same u32 marshaling on a different entry point: `textBufferViewSetViewport` + // takes x/y/width/height as u32, but `TextRenderable.onResize` feeds it the + // RAW transient layout size — observed NON-u32 (negative/NaN) mid-relayout + // while a shrinking list (the fuzzy picker filtering rows away) reflows. Bun + // wraps/coerces, node:ffi throws (`Argument 3 must be a uint32`). Coerce into + // the valid quadrant; a zero-sized viewport is the native side's own no-op. + const u32 = (v: number) => (Number.isFinite(v) ? Math.max(0, Math.trunc(v)) : 0) + const viewProto = TextBufferView.prototype + const origSetViewport = viewProto.setViewport + viewProto.setViewport = function (this: TextBufferView, x, y, width, height) { + origSetViewport.call(this, u32(x), u32(y), u32(width), u32(height)) + } } diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index c05fe2323f1..1ad2e4f18d0 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -36,7 +36,14 @@ import { envFlag } from '../logic/env.ts' import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts' import { createPasteStore } from '../logic/pastes.ts' import { mapResumeHistory, mapSessionList } from '../logic/resume.ts' -import { dispatchSlash, mapCompletions, planCompletion, readReplaceFrom, type SlashContext } from '../logic/slash.ts' +import { + dispatchSlash, + mapCompletions, + mapModelOptions, + planCompletion, + readReplaceFrom, + type SlashContext +} from '../logic/slash.ts' import { createSessionStore, type SessionStore } from '../logic/store.ts' import { App } from '../view/App.tsx' import { ThemeProvider } from '../view/theme.tsx' @@ -170,6 +177,17 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp store.pushUser(prompt) yield* gateway.request('prompt.submit', { session_id: sid, text: prompt }) } + + // Prefetch the /model catalog (Epic 7 instant open): `model.options` is the + // slow RPC (it does network calls — pricing fetch + Nous tier check), so pay + // that cost ONCE here in this already-forked bootstrap fiber; `/model` then + // paints from memory on the same frame. Best-effort: if this hasn't landed + // (or the RPC is missing/old), /model falls back to fetching on first open. + const modelOpts = yield* gateway + .request('model.options', { session_id: sid }) + .pipe(Effect.catchCause(() => Effect.succeed(undefined))) + const modelItems = mapModelOptions(modelOpts) + if (modelItems.length) store.setModelItems(modelItems) }).pipe(Effect.catchCause(cause => Effect.sync(() => getLog().warn('bootstrap', 'failed', { cause: String(cause) })))) /** The entry Effect. Mirrors opencode `app.tsx:177` `run = Effect.fn("Tui.run")`. */ @@ -357,6 +375,8 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { return true }, listSessions: () => Effect.runPromise(gateway.request('session.list', {})).then(mapSessionList), + modelItems: () => store.state.modelItems, + setModelItems: items => store.setModelItems(items), logTail: () => getLog() .tail(200) diff --git a/ui-opentui/src/logic/fuzzy.ts b/ui-opentui/src/logic/fuzzy.ts new file mode 100644 index 00000000000..e3fed6bd3c3 --- /dev/null +++ b/ui-opentui/src/logic/fuzzy.ts @@ -0,0 +1,163 @@ +/** + * fuzzy.ts — pure fuzzy filtering + grouped presentation for picker overlays + * (Epic 7 model picker v2). No deps: a ~subsequence scorer in the spirit of + * opencode's fuzzysort usage (multi-key scoring, title weighted 2×, grouped + * headers) at the scale we need (≤ a few hundred catalog rows). + * + * Scoring model (per term, per field): a case-insensitive subsequence match + * where consecutive runs, string-prefix and word-boundary starts score high and + * gaps/late starts are penalized — so for `son`: `sonnet` (prefix) > + * `claude-sonnet` (boundary) > `meson` (scattered). A query is split on + * whitespace; EVERY term must match at least one field (best field wins, its + * weight applied), so `anthropic son` works across provider+model fields. + */ + +/** One searchable field of an item (e.g. model id ×2, provider slug, lab name). */ +export interface FuzzyField { + text: string + /** Score multiplier (default 1). The primary label is conventionally 2. */ + weight?: number +} + +/** Word-boundary characters inside catalog-ish ids/names. */ +const BOUNDARY = new Set([' ', '-', '_', '.', '/', ':', '@', '(', ')']) + +/** Cap on alternative start positions tried for the first term char. */ +const MAX_STARTS = 8 + +/** Greedy subsequence score from a fixed start index; null when it can't match. */ +function scoreFrom(term: string, hay: string, start: number): number | null { + let score = 0 + let prev = -1 + let from = start + for (let qi = 0; qi < term.length; qi++) { + const idx = hay.indexOf(term.charAt(qi), from) + if (idx === -1) return null + let charScore = 1 + if (prev !== -1 && idx === prev + 1) charScore += 3 // consecutive run + if (idx === 0) + charScore += 6 // string prefix + else if (BOUNDARY.has(hay.charAt(idx - 1))) charScore += 4 // word boundary + if (prev !== -1 && idx > prev + 1) charScore -= Math.min(idx - prev - 1, 3) // gap + if (prev === -1) charScore -= Math.min(idx, 4) // late start + score += charScore + prev = idx + from = idx + 1 + } + return score +} + +/** + * Score one term against one text. Null = the term is not a subsequence. + * Greedy from the first occurrence is order-sensitive (`son` in `saturn-sonnet` + * must anchor at the second `s`), so try each occurrence of the first term char + * (capped) and keep the best. + */ +export function scoreTerm(term: string, text: string): number | null { + const needle = term.toLowerCase() + if (!needle) return 0 + const hay = text.toLowerCase() + let best: number | null = null + let start = hay.indexOf(needle.charAt(0)) + for (let tries = 0; start !== -1 && tries < MAX_STARTS; tries++) { + const s = scoreFrom(needle, hay, start) + if (s !== null && (best === null || s > best)) best = s + start = hay.indexOf(needle.charAt(0), start + 1) + } + return best +} + +/** + * Score a whitespace-split query against an item's fields. Every term must + * match at least one field; each term contributes its best weighted field + * score. Empty/blank query scores 0 (matches everything — catalog order). + */ +export function scoreFields(query: string, fields: readonly FuzzyField[]): number | null { + const terms = query.trim().split(/\s+/).filter(Boolean) + if (!terms.length) return 0 + let total = 0 + for (const term of terms) { + let best: number | null = null + for (const field of fields) { + const s = scoreTerm(term, field.text) + if (s === null) continue + const weighted = s * (field.weight ?? 1) + if (best === null || weighted > best) best = weighted + } + if (best === null) return null + total += best + } + return total +} + +/** + * Filter + rank items by query. Empty query → the items in catalog order; + * otherwise matches sorted by score (descending), ties keeping catalog order. + */ +export function fuzzyFilter(query: string, items: readonly T[], fieldsOf: (item: T) => FuzzyField[]): T[] { + if (!query.trim()) return [...items] + const scored: Array<{ item: T; score: number; at: number }> = [] + for (let i = 0; i < items.length; i++) { + const item = items[i] as T + const score = scoreFields(query, fieldsOf(item)) + if (score !== null) scored.push({ at: i, item, score }) + } + scored.sort((a, b) => b.score - a.score || a.at - b.at) + return scored.map(s => s.item) +} + +/** A render row of a grouped picker: a non-selectable group header or an item. + * `index` is the item's position in the flat ARROW-TRAVERSAL order. */ +export type PickerRow = { kind: 'header'; label: string } | { kind: 'item'; item: T; index: number } + +/** + * Group items for display (group order = first appearance, so a score-sorted + * input puts the best group first). Returns the header+item render rows and + * the flat selectable list in traversal order — arrows walk `flat` and thus + * cross group boundaries seamlessly; headers are never selectable. Items + * without a group render headerless (e.g. the skills picker). + */ +export function buildPickerRows( + items: readonly T[], + groupOf: (item: T) => string | undefined +): { rows: PickerRow[]; flat: T[] } { + const order: string[] = [] + const buckets = new Map() + for (const item of items) { + const group = groupOf(item) ?? '' + let bucket = buckets.get(group) + if (!bucket) { + bucket = [] + buckets.set(group, bucket) + order.push(group) + } + bucket.push(item) + } + const rows: PickerRow[] = [] + const flat: T[] = [] + for (const group of order) { + if (group) rows.push({ kind: 'header', label: group }) + for (const item of buckets.get(group) ?? []) { + rows.push({ index: flat.length, item, kind: 'item' }) + flat.push(item) + } + } + return { flat, rows } +} + +/** + * Slice rows to a visible window of at most `cap` rows that keeps the selected + * item in view (centered when possible). `above`/`below` are the hidden row + * counts for the ↑/↓ "more" indicators. + */ +export function visibleRows( + rows: readonly PickerRow[], + selected: number, + cap: number +): { rows: PickerRow[]; above: number; below: number } { + if (rows.length <= cap) return { above: 0, below: 0, rows: [...rows] } + const selRow = rows.findIndex(r => r.kind === 'item' && r.index === selected) + const anchor = selRow === -1 ? 0 : selRow + const start = Math.max(0, Math.min(anchor - Math.floor(cap / 2), rows.length - cap)) + return { above: start, below: rows.length - (start + cap), rows: rows.slice(start, start + cap) } +} diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts index 0465a2b3a6f..a4c4b83aaf8 100644 --- a/ui-opentui/src/logic/slash.ts +++ b/ui-opentui/src/logic/slash.ts @@ -53,6 +53,10 @@ export interface SlashContext { readonly openPicker: (picker: PickerState) => void /** Open the agents dashboard (/agents, /tasks). */ readonly openDashboard: () => void + /** Cached `/model` picker rows (Epic 7 instant open); undefined until prefetched. */ + readonly modelItems: () => PickerItem[] | undefined + /** Update the cached `/model` picker rows. */ + readonly setModelItems: (items: PickerItem[]) => void } function readStr(value: unknown, key: string): string | undefined { @@ -153,26 +157,55 @@ 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[] { +/** + * Flatten `model.options` (authenticated providers' models) into grouped picker + * rows (Epic 7): group = the provider's display ("lab") name, haystacks = slug + + * lab name (so `oai`/`anthropic` fuzzy-match models), value = the FULL switch + * arg ` --provider ` so picking a model under a different provider + * actually switches provider+model (the gateway's `_apply_model_switch` parses + * `--provider` via parse_model_flags). The current model is flagged, not baked + * into the label, so the fuzzy scorer never matches the ✓. + */ +export 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 currentProvider = readStr(opts, 'provider') 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 lab = readStr(p, 'name') ?? slug + // The gateway's own normalized "this row is the active provider" flag — + // more reliable than comparing `provider` to `slug` (the agent's provider + // string can be the API dialect, e.g. an openai-compatible base_url). + const rowCurrent = (p as { is_current?: unknown }).is_current === true 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 }) + if (typeof m !== 'string') continue + const item: PickerItem = { label: m, value: slug ? `${m} --provider ${slug}` : m } + // current = same model id under the active provider (row flag first, + // then the slug comparison, then "no provider known at all"). + if (m === current && (rowCurrent || currentProvider === slug || !currentProvider)) item.current = true + if (lab) item.group = lab + const haystacks = [slug, lab].filter(Boolean) + if (haystacks.length) item.haystacks = haystacks + items.push(item) } } + // Provider matching failed entirely (string-normalization drift) but the + // model id is known → flag the first id match so the ✓ never just vanishes. + if (current && !items.some(i => i.current)) { + const fallback = items.find(i => i.label === current) + if (fallback) fallback.current = true + } return items } -/** Flatten `skills.manage {action:'list'}` ({skills: Record}) into picker rows. */ +/** Flatten `skills.manage {action:'list'}` ({skills: Record}) into + * grouped picker rows (category = group header; also a fuzzy haystack). */ function mapSkills(result: unknown): PickerItem[] { if (!result || typeof result !== 'object') return [] const skills = (result as { skills?: unknown }).skills @@ -180,33 +213,56 @@ function mapSkills(result: unknown): PickerItem[] { 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 }) + for (const n of names) if (typeof n === 'string') items.push({ group: category, label: n, value: n }) } return items } -/** Switch the model via the server (shared by `/model ` and the picker pick). */ +/** Re-fetch `model.options` and update the cached picker rows (fire-and-forget). */ +function refreshModelItems(ctx: SlashContext): Promise { + return ctx + .request('model.options', { session_id: ctx.sessionId() }) + .then(opts => { + const items = mapModelOptions(opts) + if (items.length) ctx.setModelItems(items) + }) + .catch(() => {}) +} + +/** 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 { try { const r = await ctx.request('slash.exec', { command: `model ${name}`, session_id: ctx.sessionId() }) ctx.pushSystem(readStr(r, 'output') || `→ ${name}`) + void refreshModelItems(ctx) } catch (error) { ctx.pushSystem(`/model ${name}: ${error instanceof Error ? error.message : 'switch failed'}`) } } -/** `/model` — bare opens the model picker; `/model ` switches directly. */ +/** `/model` — bare opens the model picker; `/model ` switches directly. + * Opens from the CACHED catalog when present — zero RPCs, same-frame paint + * (Epic 7; the catalog is prefetched at bootstrap and refreshed on switch). */ const modelCmd: ClientHandler = async (arg, ctx) => { if (arg.trim()) { await switchModel(ctx, arg.trim()) return } - const items = mapModelOptions(await ctx.request('model.options', {})) + const open = (items: PickerItem[]) => + ctx.openPicker({ items, onPick: name => void switchModel(ctx, name), title: 'Switch model' }) + const cached = ctx.modelItems() + if (cached?.length) { + open(cached) + return + } + const items = mapModelOptions(await ctx.request('model.options', { session_id: ctx.sessionId() })) if (!items.length) { ctx.pushSystem('No models available (no authenticated providers).') return } - ctx.openPicker({ items, onPick: name => void switchModel(ctx, name), title: 'Switch model' }) + ctx.setModelItems(items) + open(items) } /** `/skills` — open the skills hub; picking a skill shows its info in the pager. */ diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index eee43e2d68f..aa4396b5e98 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -98,11 +98,19 @@ export interface SessionItem { messageCount: number } -/** A row in a generic `` 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. + * Picker — the generic fuzzy picker overlay (spec §2b; Epic 7 model picker v2). + * Powers /model and /skills: one query line filters live across label AND + * group AND extra haystacks (provider slug / lab name — `son4` finds + * claude-sonnet-4, `oai` finds openai models); results render GROUPED with + * non-selectable headers, and ↑↓ traverse the flat item order seamlessly + * ACROSS group boundaries. Enter picks, Esc/Ctrl+C closes (keymap layer), + * typing/backspace edits the query (maskedPrompt's own-buffer pattern — no + * focused `` feedback loops). Replaces the composer while open. + * + * Everything heavy is memoized off (query, items): score → group → window, so + * keystrokes re-score at most once and unrelated store updates don't. */ import type { BoxRenderable } from '@opentui/core' -import { createMemo } from 'solid-js' +import { useKeyboard } from '@opentui/solid' +import { createEffect, createMemo, createSignal, For, on, onMount, Show } from 'solid-js' +import { buildPickerRows, fuzzyFilter, visibleRows, type FuzzyField } from '../../logic/fuzzy.ts' import type { PickerItem } from '../../logic/store.ts' import { useCloseLayer } from '../keymap.tsx' import { useTheme } from '../theme.tsx' +/** Max visible rows (headers + items) before the window scrolls. */ +const MAX_ROWS = 12 + +/** The fuzzy haystacks of a row: label ×2 (opencode's title weighting), then + * group (lab name), description and any extra haystacks (provider slug). */ +function fieldsOf(item: PickerItem): FuzzyField[] { + const fields: FuzzyField[] = [{ text: item.label, weight: 2 }] + if (item.group) fields.push({ text: item.group }) + if (item.description) fields.push({ text: item.description }) + for (const h of item.haystacks ?? []) fields.push({ text: h }) + return fields +} + export function Picker(props: { title: string items: PickerItem[] @@ -19,39 +41,108 @@ export function Picker(props: { }) { const theme = useTheme() let rootRef: BoxRenderable | undefined - // Native select handles ↑↓/j/k/Enter; the keymap owns Esc/Ctrl+C close. + // Esc/Ctrl+C close via the native keymap; the root box is focused on mount so + // the focus-within layer is active (the list/query are not focusable). + onMount(() => rootRef?.focus()) useCloseLayer( () => rootRef, () => props.onClose() ) - const options = createMemo(() => - props.items.map(it => ({ description: it.description ?? '', name: it.label, value: it.value })) + const [query, setQuery] = createSignal('') + // score → group → window, all memoized: typing re-scores once; nothing else does. + const filtered = createMemo(() => fuzzyFilter(query(), props.items, fieldsOf)) + const grouped = createMemo(() => buildPickerRows(filtered(), it => it.group)) + + // Start on the current (✓) item; reset to the top match whenever the filter changes. + const [sel, setSel] = createSignal( + Math.max( + 0, + grouped().flat.findIndex(it => it.current) + ) ) + createEffect(on(filtered, () => setSel(0), { defer: true })) + + const win = createMemo(() => visibleRows(grouped().rows, sel(), MAX_ROWS)) + + const pick = (item: PickerItem | undefined) => { + if (item) props.onPick(item.value) + } + + useKeyboard(key => { + // Esc/Ctrl+C also close via the keymap layer above; handling them here too + // keeps close working even when focus never landed (maskedPrompt pattern). + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) return props.onClose() + const count = grouped().flat.length + if (key.name === 'return') return pick(grouped().flat[sel()]) + if (key.name === 'up' || (key.ctrl && key.name === 'p')) { + if (count) setSel(s => (s - 1 + count) % count) + return + } + if (key.name === 'down' || (key.ctrl && key.name === 'n')) { + if (count) setSel(s => (s + 1) % count) + return + } + if (key.name === 'backspace') return setQuery(q => q.slice(0, -1)) + // printable → refine the query + const ch = key.sequence + if (ch.length === 1 && !key.ctrl && !key.meta && ch >= ' ') setQuery(q => q + ch) + }) return ( (rootRef = el)} + focusable style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }} border > - - {props.title} - -