diff --git a/ui-opentui/src/logic/completionMenu.ts b/ui-opentui/src/logic/completionMenu.ts new file mode 100644 index 00000000000..a2f65567417 --- /dev/null +++ b/ui-opentui/src/logic/completionMenu.ts @@ -0,0 +1,61 @@ +/** + * Completion-menu key routing (Epic 8) — the pure decision table for the + * composer's completions dropdown, kept out of the view so the precedence + * rules are unit-testable. + * + * Precedence (the hard part): + * - Tab accepts the highlighted item and Esc dismisses whenever ANY menu is + * open (slash-command OR path/@-mention) — the pre-Epic-8 semantics. + * - Up/Down move the highlight (wrapping) and Enter accepts it ONLY for the + * SLASH menu (the composer's first token starts with `/`). On a path menu + * — or with a Ctrl/Alt-modified key — they `pass`, keeping their existing + * meanings (prompt history, cursor moves, textarea submit). + * - A closed menu (`count === 0`) always passes. + * + * The caller owns the side effects: `move` updates the selection signal, + * `accept` splices the item into the composer (then arg-completion continues + * as before), `dismiss` clears the candidates, `pass` falls through to the + * history/cursor handling. + */ + +/** Max dropdown rows shown (the view slices candidates to this). */ +export const MENU_MAX = 8 + +export interface MenuKeyContext { + /** Number of VISIBLE candidates (already capped at MENU_MAX). */ + count: number + /** The currently highlighted row. */ + selected: number + /** Whether this is the slash-command menu (composer text starts with `/`). */ + slashMenu: boolean +} + +export type MenuKeyAction = + | { kind: 'move'; selected: number } + | { kind: 'accept'; index: number } + | { kind: 'dismiss' } + | { kind: 'pass' } + +const PASS: MenuKeyAction = { kind: 'pass' } + +/** Clamp the selection into the visible range (a shrunken list can strand it). */ +function clampSelected(ctx: MenuKeyContext): number { + return Math.min(Math.max(0, ctx.selected), ctx.count - 1) +} + +/** + * Route one key press against the open menu. `modified` is Ctrl/Alt/Option — + * modified arrows/Enter never belong to the menu (Tab/Esc keep their + * pre-existing modifier-blind accept/dismiss semantics). + */ +export function routeMenuKey(name: string, modified: boolean, ctx: MenuKeyContext): MenuKeyAction { + if (ctx.count <= 0) return PASS + if (name === 'tab') return { index: clampSelected(ctx), kind: 'accept' } + if (name === 'escape') return { kind: 'dismiss' } + if (modified || !ctx.slashMenu) return PASS + const sel = clampSelected(ctx) + if (name === 'up') return { kind: 'move', selected: (sel - 1 + ctx.count) % ctx.count } + if (name === 'down') return { kind: 'move', selected: (sel + 1) % ctx.count } + if (name === 'return') return { index: sel, kind: 'accept' } + return PASS +} diff --git a/ui-opentui/src/test/lib/render.ts b/ui-opentui/src/test/lib/render.ts index c73d933b26f..c74a87888e8 100644 --- a/ui-opentui/src/test/lib/render.ts +++ b/ui-opentui/src/test/lib/render.ts @@ -17,6 +17,7 @@ * real app; here we provide a test keymap built from the test renderer (read via * `useRenderer()` inside the tree) so headless mounts of those views work. */ +import type { TestRendererSetup } from '@opentui/core/testing' import { createDefaultOpenTuiKeymap } from '@opentui/keymap/opentui' import { KeymapProvider } from '@opentui/keymap/solid' import { testRender, useRenderer } from '@opentui/solid' @@ -50,18 +51,26 @@ export interface RenderProbe { readonly resize: (width: number, height: number) => void /** Left-click at screen cell (x, y) via the mock mouse, then settle a pass. */ readonly click: (x: number, y: number) => Promise + /** The mock keyboard (typeText / pressArrow / pressEnter / …) — pair with `settle()`. */ + readonly keys: TestRendererSetup['mockInput'] + /** Run a render pass + flush so simulated input lands in the next `frame()`. */ + readonly settle: () => Promise readonly destroy: () => void } /** Mount a Solid node headlessly and return a probe with a settled first frame. */ export async function renderProbe( node: () => JSX.Element, - options?: { width?: number; height?: number } + options?: { width?: number; height?: number; kittyKeyboard?: boolean } ): Promise { const setup = await testRender(withKeymap(node), { width: options?.width ?? 80, height: options?.height ?? 24, - exitOnCtrlC: false + exitOnCtrlC: false, + // kitty protocol makes a SIMULATED lone ESC parse deterministically (legacy + // input leaves it in the escape-sequence ambiguity window forever — the mock + // never flushes it), so keyboard-driven tests can press Escape. + kittyKeyboard: options?.kittyKeyboard ?? false }) // renderOnce → flush → renderOnce: flush awaits async work (scrollbox measure, // Tree-sitter markdown tokenization) that a single sync pass would miss. The @@ -82,6 +91,11 @@ export async function renderProbe( await setup.renderOnce() await setup.flush() }, + keys: setup.mockInput, + settle: async () => { + await setup.renderOnce() + await setup.flush() + }, destroy: () => setup.renderer.destroy?.() } } diff --git a/ui-opentui/src/test/slashMenu.test.tsx b/ui-opentui/src/test/slashMenu.test.tsx new file mode 100644 index 00000000000..2289ee6a7aa --- /dev/null +++ b/ui-opentui/src/test/slashMenu.test.tsx @@ -0,0 +1,286 @@ +/** + * Slash-menu navigation tests (Epic 8). Two layers: + * + * 1. `routeMenuKey` — the pure key-routing PRECEDENCE TABLE: arrows/Enter + * belong to the dropdown only while it's open AND it's the slash menu + * (first char `/`); Tab/Esc keep their menu-wide accept/dismiss; anything + * else passes through to history/cursor handling. + * 2. Headless frames through the real App + Composer with a simulated + * keyboard: typing `/` opens the catalog dropdown, Up/Down move the + * selection (wrapping), Enter accepts the HIGHLIGHTED command into the + * composer (no submit), Esc dismisses with the text intact, Tab still + * accepts (regression pin), and with no dropdown the arrows keep prompt + * history while Enter submits. + * + * The onType wiring mirrors the entry (`planCompletion` → "gateway" → + * `store.setCompletions`) with a synchronous fake catalog, so frames are + * deterministic. + */ +import { describe, expect, test } from 'vitest' + +import { MENU_MAX, routeMenuKey, type MenuKeyContext } from '../logic/completionMenu.ts' +import { createPromptHistory } from '../logic/history.ts' +import { planCompletion } from '../logic/slash.ts' +import { createSessionStore, type CompletionItem } from '../logic/store.ts' +import { App } from '../view/App.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { renderProbe, type RenderProbe } from './lib/render.ts' + +// ── layer 1: the pure precedence table ───────────────────────────────── + +const ctx = (over: Partial = {}): MenuKeyContext => ({ + count: 4, + selected: 0, + slashMenu: true, + ...over +}) + +describe('routeMenuKey — key-routing precedence table', () => { + test.each([ + // [case, key, modified, context, expected] + ['Down moves the selection', 'down', false, ctx(), { kind: 'move', selected: 1 }], + ['Down wraps bottom → top', 'down', false, ctx({ selected: 3 }), { kind: 'move', selected: 0 }], + ['Up moves the selection', 'up', false, ctx({ selected: 2 }), { kind: 'move', selected: 1 }], + ['Up wraps top → bottom', 'up', false, ctx(), { kind: 'move', selected: 3 }], + ['Enter accepts the highlighted row', 'return', false, ctx({ selected: 2 }), { index: 2, kind: 'accept' }], + ['Tab accepts the highlighted row', 'tab', false, ctx({ selected: 1 }), { index: 1, kind: 'accept' }], + ['Esc dismisses', 'escape', false, ctx({ selected: 2 }), { kind: 'dismiss' }], + // NOT the slash menu (path/@-mention dropdown): arrows + Enter keep their + // existing meanings (history / cursor / textarea submit) … + ['Down on a path menu passes', 'down', false, ctx({ slashMenu: false }), { kind: 'pass' }], + ['Up on a path menu passes', 'up', false, ctx({ slashMenu: false }), { kind: 'pass' }], + ['Enter on a path menu passes (submits as today)', 'return', false, ctx({ slashMenu: false }), { kind: 'pass' }], + // … but Tab/Esc keep working on ANY menu (pre-Epic-8 semantics) + ['Tab on a path menu still accepts', 'tab', false, ctx({ slashMenu: false }), { index: 0, kind: 'accept' }], + ['Esc on a path menu still dismisses', 'escape', false, ctx({ slashMenu: false }), { kind: 'dismiss' }], + // closed menu: everything passes + ['Down with no menu passes', 'down', false, ctx({ count: 0 }), { kind: 'pass' }], + ['Enter with no menu passes', 'return', false, ctx({ count: 0 }), { kind: 'pass' }], + ['Esc with no menu passes', 'escape', false, ctx({ count: 0 }), { kind: 'pass' }], + // modified arrows/Enter never belong to the menu + ['Ctrl+Down passes', 'down', true, ctx(), { kind: 'pass' }], + ['Alt+Enter passes', 'return', true, ctx(), { kind: 'pass' }], + // unrelated keys pass (printables refine the query via the textarea) + ['a printable passes', 'a', false, ctx(), { kind: 'pass' }], + ['Left passes (cursor move)', 'left', false, ctx(), { kind: 'pass' }] + ])('%s', (_name, key, modified, context, expected) => { + expect(routeMenuKey(key as string, modified as boolean, context as MenuKeyContext)).toEqual(expected) + }) + + test('a stranded selection clamps into the visible range before moving/accepting', () => { + expect(routeMenuKey('down', false, ctx({ count: 2, selected: 5 }))).toEqual({ kind: 'move', selected: 0 }) + expect(routeMenuKey('return', false, ctx({ count: 2, selected: 5 }))).toEqual({ index: 1, kind: 'accept' }) + }) +}) + +// ── layer 2: headless frames with a simulated keyboard ───────────────── + +/** Fake gateway catalog (what `complete.slash` would return for a `/` prefix). */ +const CATALOG: CompletionItem[] = [ + { display: '/clear', meta: 'clear the transcript', text: '/clear' }, + { display: '/copy', meta: 'copy the last response', text: '/copy' }, + { display: '/help', meta: 'list commands', text: '/help' }, + { display: '/model', meta: 'switch model', text: '/model' } +] + +interface Harness { + probe: RenderProbe + submitted: string[] + typed: string[] +} + +/** Mount the real App with entry-parity onType (planCompletion → fake catalog). */ +async function mountComposer(historyEntries: string[] = []): Promise { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + const submitted: string[] = [] + const typed: string[] = [] + const history = createPromptHistory({ initial: historyEntries }) + const onType = (text: string) => { + typed.push(text) + const plan = planCompletion(text) + if (!plan || plan.method !== 'complete.slash') { + store.clearCompletions() + return + } + const q = String(plan.params.text).toLowerCase() + const items = CATALOG.filter(c => c.text.startsWith(q) && c.text !== q) + if (items.length) store.setCompletions(items, plan.from) + else store.clearCompletions() + } + const probe = await renderProbe( + () => ( + store.state.theme}> + submitted.push(t)} onType={onType} history={history} /> + + ), + // kitty keyboard: a SIMULATED lone ESC never parses under legacy input (it + // sits in the escape-sequence ambiguity window), and the Esc test needs it. + { height: 24, kittyKeyboard: true, width: 70 } + ) + return { probe, submitted, typed } +} + +describe('slash menu — typing `/` opens the catalog dropdown', () => { + test('`/` as the first char shows the candidates + the nav hint', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/') + await h.probe.settle() + const frame = await h.probe.waitForFrame(f => f.includes('/clear')) + expect(frame).toContain('/copy') + expect(frame).toContain('/help') + expect(frame).toContain('/model') + expect(frame).toContain('↑/↓ select') + } finally { + h.probe.destroy() + } + }) + + test('`/` mid-prose (not the first char) does NOT open the slash menu', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('say /') + await h.probe.settle() + const frame = h.probe.frame() + expect(frame).not.toContain('/clear') + expect(frame).not.toContain('Esc dismiss') + expect(frame).toContain('say /') // the prose stays in the composer + } finally { + h.probe.destroy() + } + }) +}) + +describe('slash menu — arrow navigation + Enter accept', () => { + test('ArrowDown moves the selection; Enter accepts the highlighted command (no submit)', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('/model')) + h.probe.keys.pressArrow('down') // /clear → /copy + await h.probe.settle() + h.probe.keys.pressEnter() + await h.probe.settle() + const frame = h.probe.frame() + expect(frame).toContain('/copy') // spliced into the composer … + expect(frame).not.toContain('/clear') // … and the menu is gone + expect(h.submitted).toEqual([]) // Enter ACCEPTED, did not submit + expect(h.typed.at(-1)).toBe('/copy ') // trailing space → arg-completion re-query ran + } finally { + h.probe.destroy() + } + }) + + test('ArrowUp from the top wraps to the LAST candidate', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('/model')) + h.probe.keys.pressArrow('up') // wraps 0 → 3 (/model) + await h.probe.settle() + h.probe.keys.pressEnter() + await h.probe.settle() + expect(h.probe.frame()).toContain('/model') + expect(h.submitted).toEqual([]) + expect(h.typed.at(-1)).toBe('/model ') + } finally { + h.probe.destroy() + } + }) + + test('ArrowDown past the bottom wraps to the FIRST candidate', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('/model')) + for (let i = 0; i < 4; i++) h.probe.keys.pressArrow('down') // 0→1→2→3→0 + await h.probe.settle() + h.probe.keys.pressEnter() + await h.probe.settle() + expect(h.typed.at(-1)).toBe('/clear ') + expect(h.submitted).toEqual([]) + } finally { + h.probe.destroy() + } + }) +}) + +describe('slash menu — Esc / Tab / no-dropdown routing', () => { + test('Esc closes the dropdown and leaves the composer text intact', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/he') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('list commands')) + h.probe.keys.pressEscape() + // a lone ESC byte sits in the parser's ambiguity window for a tick — wait + // for the dismissal to land rather than asserting the very next frame + const frame = await h.probe.waitForFrame(f => !f.includes('list commands')) + expect(frame).not.toContain('list commands') // menu row gone + expect(frame).not.toContain('Esc dismiss') // hint gone + expect(frame).toContain('/he') // text untouched + expect(h.submitted).toEqual([]) + } finally { + h.probe.destroy() + } + }) + + test('Tab still accepts (regression pin) and Enter then submits the command', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/he') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('list commands')) + h.probe.keys.pressTab() + await h.probe.settle() + expect(h.typed.at(-1)).toBe('/help ') // accepted with the trailing space + h.probe.keys.pressEnter() // no dropdown now → submit as today + await h.probe.settle() + expect(h.submitted).toEqual(['/help']) + } finally { + h.probe.destroy() + } + }) + + test('with NO dropdown, Up/Down recall prompt history and Enter submits', async () => { + const h = await mountComposer(['first prompt', 'second prompt']) + try { + h.probe.keys.pressArrow('up') + await h.probe.settle() + expect(h.probe.frame()).toContain('second prompt') + h.probe.keys.pressArrow('up') + await h.probe.settle() + expect(h.probe.frame()).toContain('first prompt') + h.probe.keys.pressArrow('down') + await h.probe.settle() + expect(h.probe.frame()).toContain('second prompt') + h.probe.keys.pressEnter() + await h.probe.settle() + expect(h.submitted).toEqual(['second prompt']) + } finally { + h.probe.destroy() + } + }) + + test('arrows while the slash menu is open do NOT touch prompt history', async () => { + const h = await mountComposer(['older prompt']) + try { + await h.probe.keys.typeText('/') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('/model')) + h.probe.keys.pressArrow('up') // menu nav (wraps to /model), NOT history + await h.probe.settle() + expect(h.probe.frame()).not.toContain('older prompt') + } finally { + h.probe.destroy() + } + }) + + test('the dropdown caps at MENU_MAX rows', () => { + expect(MENU_MAX).toBe(8) // the view slices candidates to this + }) +}) diff --git a/ui-opentui/src/view/composer.tsx b/ui-opentui/src/view/composer.tsx index b9328ffa7e7..d7fa2f0aca7 100644 --- a/ui-opentui/src/view/composer.tsx +++ b/ui-opentui/src/view/composer.tsx @@ -8,8 +8,12 @@ * * Completions: `onContentChange` reports the text → `onType` (entry boundary) * queries `complete.slash` and fills `completions()`. The textarea owns key input - * (so live-refine-by-typing works), so we use Tab to accept the top match and Esc - * to dismiss (arrow-nav would fight the textarea's cursor; a polish item). + * (so live-refine-by-typing works); menu keys are routed by the pure + * `routeMenuKey` table (Epic 8): Tab accepts / Esc dismisses any open menu, and + * for the SLASH menu (first char `/`) Up/Down move a themed highlight (wrapping) + * and Enter accepts it — `key.preventDefault()` keeps the consumed key out of + * the textarea (cursor moves / its `submit` binding). Path/@-mention menus keep + * Tab-only accept so arrows/Enter retain history/cursor/submit meanings. * `onSubmit`/`onType` are plain callbacks wired by the entry — no Effect here. * * Always-active input (item 2): the textarea focuses on mount, on click @@ -21,8 +25,9 @@ */ import { type PasteEvent, type TextareaRenderable } from '@opentui/core' import { useKeyboard } from '@opentui/solid' -import { For, onMount, Show } from 'solid-js' +import { createEffect, createSignal, For, on, onMount, Show } from 'solid-js' +import { MENU_MAX, routeMenuKey } from '../logic/completionMenu.ts' import type { CompletionItem } from '../logic/store.ts' import type { PromptHistory } from '../logic/history.ts' import { type PasteStore, shouldPlaceholder } from '../logic/pastes.ts' @@ -93,6 +98,21 @@ export function Composer(props: { let ta: TextareaRenderable | undefined let submitting = false const completions = () => props.completions?.() ?? [] + /** The visible dropdown rows (the menu is capped, selection wraps within it). */ + const menuItems = () => completions().slice(0, MENU_MAX) + // Highlighted dropdown row (Epic 8). New candidates (every refine keystroke + // swaps the array) reset it to the top match. + const [selected, setSelected] = createSignal(0) + createEffect( + on( + () => props.completions?.(), + () => setSelected(0) + ) + ) + // Whether the composer text starts with `/` (slash menu vs path menu) — a + // signal so the dropdown hint stays reactive; the key handler re-checks + // `ta.plainText` directly. + const [slashText, setSlashText] = createSignal(false) /** Replace the textarea content and park the cursor at the end (history recall). */ const setBuffer = (text: string) => { @@ -101,6 +121,19 @@ export function Composer(props: { ta.cursorOffset = text.length } + /** Splice the n-th candidate into the buffer (Tab/Enter accept). Only the token + * being completed is replaced — `completionFrom` is the gateway's + * `replace_from` / token start — then the trailing space lets arg-completion + * continue (setText fires onContentChange → onType re-queries). */ + const acceptCompletion = (index: number) => { + const item = menuItems()[index] ?? menuItems()[0] + if (!item || !ta) return + const from = props.completionFrom?.() ?? 0 + const before = ta.plainText.slice(0, Math.min(Math.max(0, from), ta.plainText.length)) + setBuffer(before + item.text + ' ') + props.onDismiss?.() + } + const submit = () => { if (submitting || !ta) return // Expand any `[Pasted text #N]` placeholders back to their full content before @@ -117,21 +150,30 @@ export function Composer(props: { } useKeyboard(key => { - // 1) completion accept (Tab) / dismiss (Esc) while the dropdown is open - if (completions().length > 0) { - if (key.name === 'tab') { - const top = completions()[0] - if (top && ta) { - // splice only the token being completed (slash-arg / @-mention), not the - // whole line — `completionFrom` is the gateway's replace_from / token start. - const from = props.completionFrom?.() ?? 0 - const before = ta.plainText.slice(0, Math.min(Math.max(0, from), ta.plainText.length)) - setBuffer(before + top.text + ' ') - props.onDismiss?.() - } + // 1) completion-menu keys while the dropdown is open (Epic 8): Tab accept / + // Esc dismiss for ANY menu (the pre-existing semantics — Esc stays exactly + // "dismiss if open, else fall through"), plus Up/Down/Enter for the SLASH + // menu only (routeMenuKey's precedence table). preventDefault keeps a + // consumed arrow/Enter from also reaching the textarea (cursor move / its + // `submit` keybinding); Tab/Esc stay un-prevented as before. + const menu = menuItems() + if (menu.length > 0) { + const action = routeMenuKey(key.name, key.ctrl || key.meta || key.option, { + count: menu.length, + selected: selected(), + slashMenu: (ta?.plainText ?? '').startsWith('/') + }) + if (action.kind === 'move') { + setSelected(action.selected) + key.preventDefault() return } - if (key.name === 'escape') { + if (action.kind === 'accept') { + acceptCompletion(action.index) + if (key.name === 'return') key.preventDefault() + return + } + if (action.kind === 'dismiss') { props.onDismiss?.() return } @@ -180,17 +222,25 @@ export function Composer(props: { > {/* the completion dropdown is transient input chrome (menu rows + the key-hint) — not transcript content — so it's excluded from mouse - selection (item 4). */} - + selection (item 4). The highlighted row tracks `selected()` (Epic 8) + with the THEMED completionCurrentBg — Up/Down move it on the slash + menu; on path menus it stays on the top match (Tab's target). */} + {(c, i) => ( - - {c.display || c.text} - {c.meta ? ` ${c.meta}` : ''} - + + + {c.display || c.text} + {c.meta ? ` ${c.meta}` : ''} + + )} - Tab complete · Esc dismiss + {slashText() ? '↑/↓ select · Enter/Tab accept · Esc dismiss' : 'Tab complete · Esc dismiss'} @@ -232,7 +282,11 @@ export function Composer(props: { } // small pastes fall through to the textarea's native insert }} - onContentChange={() => props.onType?.(ta?.plainText ?? '')} + onContentChange={() => { + const text = ta?.plainText ?? '' + setSlashText(text.startsWith('/')) + props.onType?.(text) + }} /> diff --git a/ui-opentui/vitest.config.ts b/ui-opentui/vitest.config.ts index 814073b3ec8..08d6ff64357 100644 --- a/ui-opentui/vitest.config.ts +++ b/ui-opentui/vitest.config.ts @@ -54,6 +54,17 @@ export default defineConfig({ ] }, test: { - include: ['src/test/**/*.test.{ts,tsx}'] + include: ['src/test/**/*.test.{ts,tsx}'], + server: { + deps: { + // Inline solid-js/store so ITS bare `import 'solid-js'` goes through the + // alias above (client build). Externalized, Node's `node` export condition + // would hand it the SSR `server.js` — a SECOND, non-tracking reactive + // runtime, so post-mount store updates would never repaint test frames + // (@opentui/solid itself deep-imports `solid-js/dist/solid.js`, which is + // exactly where the alias points — one shared runtime). + inline: [/solid-js[/\\]store/] + } + } } })