mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
opentui(v6): slash menu — arrow navigation + enter accept
This commit is contained in:
parent
6a6693b182
commit
394f45a3d5
5 changed files with 453 additions and 27 deletions
61
ui-opentui/src/logic/completionMenu.ts
Normal file
61
ui-opentui/src/logic/completionMenu.ts
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
* real app; here we provide a test keymap built from the test renderer (read via
|
* 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.
|
* `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 { createDefaultOpenTuiKeymap } from '@opentui/keymap/opentui'
|
||||||
import { KeymapProvider } from '@opentui/keymap/solid'
|
import { KeymapProvider } from '@opentui/keymap/solid'
|
||||||
import { testRender, useRenderer } from '@opentui/solid'
|
import { testRender, useRenderer } from '@opentui/solid'
|
||||||
|
|
@ -50,18 +51,26 @@ export interface RenderProbe {
|
||||||
readonly resize: (width: number, height: number) => void
|
readonly resize: (width: number, height: number) => void
|
||||||
/** Left-click at screen cell (x, y) via the mock mouse, then settle a pass. */
|
/** Left-click at screen cell (x, y) via the mock mouse, then settle a pass. */
|
||||||
readonly click: (x: number, y: number) => Promise<void>
|
readonly click: (x: number, y: number) => Promise<void>
|
||||||
|
/** 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<void>
|
||||||
readonly destroy: () => void
|
readonly destroy: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mount a Solid node headlessly and return a probe with a settled first frame. */
|
/** Mount a Solid node headlessly and return a probe with a settled first frame. */
|
||||||
export async function renderProbe(
|
export async function renderProbe(
|
||||||
node: () => JSX.Element,
|
node: () => JSX.Element,
|
||||||
options?: { width?: number; height?: number }
|
options?: { width?: number; height?: number; kittyKeyboard?: boolean }
|
||||||
): Promise<RenderProbe> {
|
): Promise<RenderProbe> {
|
||||||
const setup = await testRender(withKeymap(node), {
|
const setup = await testRender(withKeymap(node), {
|
||||||
width: options?.width ?? 80,
|
width: options?.width ?? 80,
|
||||||
height: options?.height ?? 24,
|
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,
|
// renderOnce → flush → renderOnce: flush awaits async work (scrollbox measure,
|
||||||
// Tree-sitter markdown tokenization) that a single sync pass would miss. The
|
// 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.renderOnce()
|
||||||
await setup.flush()
|
await setup.flush()
|
||||||
},
|
},
|
||||||
|
keys: setup.mockInput,
|
||||||
|
settle: async () => {
|
||||||
|
await setup.renderOnce()
|
||||||
|
await setup.flush()
|
||||||
|
},
|
||||||
destroy: () => setup.renderer.destroy?.()
|
destroy: () => setup.renderer.destroy?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
286
ui-opentui/src/test/slashMenu.test.tsx
Normal file
286
ui-opentui/src/test/slashMenu.test.tsx
Normal file
|
|
@ -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> = {}): 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<Harness> {
|
||||||
|
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(
|
||||||
|
() => (
|
||||||
|
<ThemeProvider theme={() => store.state.theme}>
|
||||||
|
<App store={store} onSubmit={t => submitted.push(t)} onType={onType} history={history} />
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
// 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -8,8 +8,12 @@
|
||||||
*
|
*
|
||||||
* Completions: `onContentChange` reports the text → `onType` (entry boundary)
|
* Completions: `onContentChange` reports the text → `onType` (entry boundary)
|
||||||
* queries `complete.slash` and fills `completions()`. The textarea owns key input
|
* 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
|
* (so live-refine-by-typing works); menu keys are routed by the pure
|
||||||
* to dismiss (arrow-nav would fight the textarea's cursor; a polish item).
|
* `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.
|
* `onSubmit`/`onType` are plain callbacks wired by the entry — no Effect here.
|
||||||
*
|
*
|
||||||
* Always-active input (item 2): the textarea focuses on mount, on click
|
* 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 { type PasteEvent, type TextareaRenderable } from '@opentui/core'
|
||||||
import { useKeyboard } from '@opentui/solid'
|
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 { CompletionItem } from '../logic/store.ts'
|
||||||
import type { PromptHistory } from '../logic/history.ts'
|
import type { PromptHistory } from '../logic/history.ts'
|
||||||
import { type PasteStore, shouldPlaceholder } from '../logic/pastes.ts'
|
import { type PasteStore, shouldPlaceholder } from '../logic/pastes.ts'
|
||||||
|
|
@ -93,6 +98,21 @@ export function Composer(props: {
|
||||||
let ta: TextareaRenderable | undefined
|
let ta: TextareaRenderable | undefined
|
||||||
let submitting = false
|
let submitting = false
|
||||||
const completions = () => props.completions?.() ?? []
|
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). */
|
/** Replace the textarea content and park the cursor at the end (history recall). */
|
||||||
const setBuffer = (text: string) => {
|
const setBuffer = (text: string) => {
|
||||||
|
|
@ -101,6 +121,19 @@ export function Composer(props: {
|
||||||
ta.cursorOffset = text.length
|
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 = () => {
|
const submit = () => {
|
||||||
if (submitting || !ta) return
|
if (submitting || !ta) return
|
||||||
// Expand any `[Pasted text #N]` placeholders back to their full content before
|
// Expand any `[Pasted text #N]` placeholders back to their full content before
|
||||||
|
|
@ -117,21 +150,30 @@ export function Composer(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
useKeyboard(key => {
|
useKeyboard(key => {
|
||||||
// 1) completion accept (Tab) / dismiss (Esc) while the dropdown is open
|
// 1) completion-menu keys while the dropdown is open (Epic 8): Tab accept /
|
||||||
if (completions().length > 0) {
|
// Esc dismiss for ANY menu (the pre-existing semantics — Esc stays exactly
|
||||||
if (key.name === 'tab') {
|
// "dismiss if open, else fall through"), plus Up/Down/Enter for the SLASH
|
||||||
const top = completions()[0]
|
// menu only (routeMenuKey's precedence table). preventDefault keeps a
|
||||||
if (top && ta) {
|
// consumed arrow/Enter from also reaching the textarea (cursor move / its
|
||||||
// splice only the token being completed (slash-arg / @-mention), not the
|
// `submit` keybinding); Tab/Esc stay un-prevented as before.
|
||||||
// whole line — `completionFrom` is the gateway's replace_from / token start.
|
const menu = menuItems()
|
||||||
const from = props.completionFrom?.() ?? 0
|
if (menu.length > 0) {
|
||||||
const before = ta.plainText.slice(0, Math.min(Math.max(0, from), ta.plainText.length))
|
const action = routeMenuKey(key.name, key.ctrl || key.meta || key.option, {
|
||||||
setBuffer(before + top.text + ' ')
|
count: menu.length,
|
||||||
props.onDismiss?.()
|
selected: selected(),
|
||||||
}
|
slashMenu: (ta?.plainText ?? '').startsWith('/')
|
||||||
|
})
|
||||||
|
if (action.kind === 'move') {
|
||||||
|
setSelected(action.selected)
|
||||||
|
key.preventDefault()
|
||||||
return
|
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?.()
|
props.onDismiss?.()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -180,17 +222,25 @@ export function Composer(props: {
|
||||||
>
|
>
|
||||||
{/* the completion dropdown is transient input chrome (menu rows + the
|
{/* the completion dropdown is transient input chrome (menu rows + the
|
||||||
key-hint) — not transcript content — so it's excluded from mouse
|
key-hint) — not transcript content — so it's excluded from mouse
|
||||||
selection (item 4). */}
|
selection (item 4). The highlighted row tracks `selected()` (Epic 8)
|
||||||
<For each={completions().slice(0, 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). */}
|
||||||
|
<For each={menuItems()}>
|
||||||
{(c, i) => (
|
{(c, i) => (
|
||||||
<text selectable={false} fg={i() === 0 ? theme().color.accent : theme().color.text}>
|
<box
|
||||||
{c.display || c.text}
|
style={{
|
||||||
{c.meta ? ` ${c.meta}` : ''}
|
backgroundColor: i() === selected() ? theme().color.completionCurrentBg : theme().color.completionBg
|
||||||
</text>
|
}}
|
||||||
|
>
|
||||||
|
<text selectable={false} fg={i() === selected() ? theme().color.accent : theme().color.text}>
|
||||||
|
{c.display || c.text}
|
||||||
|
{c.meta ? ` ${c.meta}` : ''}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
<text selectable={false} fg={theme().color.muted}>
|
<text selectable={false} fg={theme().color.muted}>
|
||||||
Tab complete · Esc dismiss
|
{slashText() ? '↑/↓ select · Enter/Tab accept · Esc dismiss' : 'Tab complete · Esc dismiss'}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -232,7 +282,11 @@ export function Composer(props: {
|
||||||
}
|
}
|
||||||
// small pastes fall through to the textarea's native insert
|
// 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)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,17 @@ export default defineConfig({
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
test: {
|
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/]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue