From 7412cd5c781c8797220a151f9878eab093c07ab3 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 16:15:38 +0000 Subject: [PATCH] =?UTF-8?q?feat(opentui-v2):=20Phase=205a=20=E2=80=94=20sl?= =?UTF-8?q?ash=20completions=20dropdown=20(last=20first-class=20overlay)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live slash-completion dropdown renders above the composer as you type `/…` (spec §1 autocomplete) — the 6th and final first-class overlay surface. - view/composer.tsx: onContentChange → onType (reads ta.plainText); a dropdown of candidates (display + meta) renders above the textarea when completions are set. The textarea owns key input (live refine-by-typing), so Tab accepts the top match (ta.clear()+insertText) and Esc dismisses; arrow-nav would fight the cursor (noted polish). - store: completions state + setCompletions/clearCompletions; CompletionItem. - logic/slash.ts: mapCompletions(complete.slash result) → candidates. - entry: onType queries complete.slash for `/word` (no space) and sets/clears the store completions; cleared on submit / non-slash / space. Verified: bun run check green (49 tests / 7 files) — mapCompletions + a composer-dropdown frame test. LIVE tmux: typing `/comp` showed /compress, /composio, /compact (with descriptions); Tab accepted the top + cleared the dropdown. ALL 6 first-class overlays are now ✅+tested+smoked (blocking prompts, pager, session switcher, model picker, skills hub, completions). Smoke P5a + matrix updated. Remaining: chrome (5b), agent features (5d), agents dashboard (5e). --- ui-tui-opentui-v2/src/entry/main.tsx | 16 ++++- ui-tui-opentui-v2/src/logic/slash.ts | 16 ++++- ui-tui-opentui-v2/src/logic/store.ts | 22 ++++++- ui-tui-opentui-v2/src/test/render.test.tsx | 22 +++++++ ui-tui-opentui-v2/src/test/slash.test.ts | 15 ++++- ui-tui-opentui-v2/src/view/App.tsx | 12 +++- ui-tui-opentui-v2/src/view/composer.tsx | 70 ++++++++++++++++++---- 7 files changed, 157 insertions(+), 16 deletions(-) diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index fdd5855dc4c..f7733b910d4 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -28,7 +28,7 @@ import { getLog } from '../boundary/log.ts' import { acquireRenderer } from '../boundary/renderer.ts' import { makeAppLayer } from '../boundary/runtime.ts' import { mapResumeHistory, mapSessionList } from '../logic/resume.ts' -import { dispatchSlash, type SlashContext } from '../logic/slash.ts' +import { dispatchSlash, mapCompletions, 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' @@ -194,6 +194,19 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { else submitPrompt(text) } + // Live slash completions: query `complete.slash` while typing a `/command` + // name (no space yet); clear otherwise. Cheap local completer — fired per + // keystroke (a debounce is a polish item). + const onType = (text: string) => { + if (!text.startsWith('/') || text.includes(' ') || text.includes('\n')) { + store.clearCompletions() + return + } + Effect.runPromise(gateway.request('complete.slash', { text })) + .then(result => store.setCompletions(mapCompletions(result))) + .catch(() => store.clearCompletions()) + } + // Blocking-prompt replies (clarify/approval/sudo/secret `*.respond`). Same // detached-runFork pattern; failures logged, never thrown into the view. const respond = (method: string, params: Record) => { @@ -220,6 +233,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { gateway.sessionId()} diff --git a/ui-tui-opentui-v2/src/logic/slash.ts b/ui-tui-opentui-v2/src/logic/slash.ts index 2b3b5b00226..f7dc52e0d6a 100644 --- a/ui-tui-opentui-v2/src/logic/slash.ts +++ b/ui-tui-opentui-v2/src/logic/slash.ts @@ -11,7 +11,7 @@ * (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn · * prefill → notice). Long output routes to the pager (Phase 5a). */ -import type { PickerItem, PickerState, SessionItem } from './store.ts' +import type { CompletionItem, PickerItem, PickerState, SessionItem } from './store.ts' export interface ParsedSlash { name: string @@ -59,6 +59,20 @@ function readStr(value: unknown, key: string): string | undefined { const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1) +/** Map a `complete.slash` result ({items:[{text,display,meta}]}) into completion candidates. */ +export function mapCompletions(result: unknown): CompletionItem[] { + if (!result || typeof result !== 'object') return [] + const items = (result as { items?: unknown }).items + if (!Array.isArray(items)) return [] + const out: CompletionItem[] = [] + for (const it of items) { + const text = readStr(it, 'text') + if (!text) continue + out.push({ display: readStr(it, 'display') ?? text, meta: readStr(it, 'meta') ?? '', text }) + } + return out +} + /** Long output → the pager; short → a system line (Ink: >180 chars or >2 lines). */ function present(ctx: SlashContext, title: string, text: string): void { const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2 diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index ac7b39b27c8..92a13a2f7f5 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -86,6 +86,13 @@ export interface PickerState { onPick: (value: string) => void } +/** A slash-completion candidate (from `complete.slash`). */ +export interface CompletionItem { + text: string + display: string + meta: string +} + export interface StoreState { ready: boolean messages: Message[] @@ -98,6 +105,8 @@ export interface StoreState { switcher: SessionItem[] | undefined /** The open generic picker (model/skills/…); undefined when none. */ picker: PickerState | undefined + /** Live slash-completion candidates shown above the composer; undefined/empty when none. */ + completions: CompletionItem[] | undefined } const LRU_LIMIT = 1000 @@ -116,7 +125,8 @@ export function createSessionStore() { prompt: undefined, pager: undefined, switcher: undefined, - picker: undefined + picker: undefined, + completions: undefined }) // Monotonic part id (stable `key` per part so a new tool part below a streaming @@ -240,6 +250,14 @@ export function createSessionStore() { setState('picker', undefined) } + /** Set / clear the live slash-completion candidates (composer dropdown). */ + function setCompletions(items: CompletionItem[]) { + setState('completions', items.length ? items : undefined) + } + function clearCompletions() { + setState('completions', undefined) + } + /** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */ function apply(event: GatewayEvent): void { if (buffering) { @@ -408,6 +426,8 @@ export function createSessionStore() { closeSwitcher, openPicker, closePicker, + setCompletions, + clearCompletions, hydrate, beginBuffer, commitSnapshot, diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index 749196c64ca..39fcf03bcd2 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -148,4 +148,26 @@ describe('App render (Phase 1, themed)', () => { expect(frame).toContain('Second chat') expect(frame).not.toContain('Type your message') // composer hidden while switcher open }) + + test('the composer shows a live slash-completions dropdown', async () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.setCompletions([ + { display: '/compact', meta: 'compress context', text: '/compact' }, + { display: '/clear', meta: '', text: '/clear' } + ]) + + const frame = await captureFrame( + () => ( + store.state.theme}> + + + ), + { until: '/compact', width: 72, height: 18 } + ) + + expect(frame).toContain('/compact') // candidate + expect(frame).toContain('compress context') // its meta + expect(frame).toContain('Tab complete') // dropdown hint + }) }) diff --git a/ui-tui-opentui-v2/src/test/slash.test.ts b/ui-tui-opentui-v2/src/test/slash.test.ts index b342cd7941d..737b6908808 100644 --- a/ui-tui-opentui-v2/src/test/slash.test.ts +++ b/ui-tui-opentui-v2/src/test/slash.test.ts @@ -4,11 +4,24 @@ */ import { describe, expect, test } from 'bun:test' -import { dispatchSlash, parseSlash, type SlashContext } from '../logic/slash.ts' +import { dispatchSlash, mapCompletions, parseSlash, type SlashContext } from '../logic/slash.ts' import type { PickerItem, SessionItem } from '../logic/store.ts' const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }] +describe('mapCompletions', () => { + test('maps complete.slash items → candidates (display/meta default)', () => { + expect( + mapCompletions({ items: [{ display: '/compact', meta: 'compress', text: '/compact' }, { text: '/details' }] }) + ).toEqual([ + { display: '/compact', meta: 'compress', text: '/compact' }, + { display: '/details', meta: '', text: '/details' } + ]) + expect(mapCompletions({ items: [] })).toEqual([]) + expect(mapCompletions(null)).toEqual([]) + }) +}) + describe('parseSlash', () => { test('splits name + arg; rejects non-slash / empty', () => { expect(parseSlash('/help')).toEqual({ name: 'help', arg: '' }) diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index 46b069f9958..af00ac2cbb6 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -27,6 +27,7 @@ import { Transcript } from './transcript.tsx' export interface AppProps { readonly store: SessionStore readonly onSubmit?: (text: string) => void + readonly onType?: (text: string) => void readonly onRespond?: (method: string, params: Record) => void readonly onResume?: (sessionId: string) => void readonly sessionId?: () => string | undefined @@ -60,7 +61,16 @@ export function App(props: AppProps) { fallback={ <> - }> + props.store.state.completions ?? []} + onDismiss={() => props.store.clearCompletions()} + /> + } + > captured by ref; Enter submits, the input clears imperatively. + * Composer — the input row (spec v4 §2). A native