feat(opentui-v2): Phase 5a — slash completions dropdown (last first-class overlay)

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).
This commit is contained in:
alt-glitch 2026-06-08 16:15:38 +00:00
parent 3f54152191
commit 7412cd5c78
7 changed files with 157 additions and 16 deletions

View file

@ -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<string, unknown>) => {
@ -220,6 +233,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
<App
store={store}
onSubmit={submit}
onType={onType}
onRespond={respond}
onResume={onResume}
sessionId={() => gateway.sessionId()}

View file

@ -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

View file

@ -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,

View file

@ -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(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ 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
})
})

View file

@ -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: '' })

View file

@ -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<string, unknown>) => void
readonly onResume?: (sessionId: string) => void
readonly sessionId?: () => string | undefined
@ -60,7 +61,16 @@ export function App(props: AppProps) {
fallback={
<>
<Transcript store={props.store} />
<Switch fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
<Switch
fallback={
<Composer
onSubmit={props.onSubmit ?? NOOP}
onType={props.onType}
completions={() => props.store.state.completions ?? []}
onDismiss={() => props.store.clearCompletions()}
/>
}
>
<Match when={blocked()}>
<PromptOverlay
store={props.store}

View file

@ -1,24 +1,34 @@
/**
* Composer the input row (spec v4 §2 `view/composer.tsx`). Phase 2: a native
* <textarea> captured by ref; Enter submits, the input clears imperatively.
* Composer the input row (spec v4 §2). A native <textarea> captured by ref;
* Enter submits, the input clears imperatively, and a live slash-completion
* dropdown renders ABOVE it as you type `/…` (spec §1 completions).
*
* Gotchas (§8 #3): `flexShrink:0` on the wrapper so it never collapses onto its
* rule under a full transcript; clear via the renderable's `.clear()` (NOT a
* `key`-remount or controlled `value=""`); a `submitting` re-entrancy guard so a
* double-Enter can't read the now-empty buffer and submit a phantom prompt.
* Gotchas (§8 #3): `flexShrink:0` so it never collapses onto its rule; clear via
* `.clear()` (NOT key-remount); a `submitting` re-entrancy guard.
*
* `onSubmit` is a plain callback wired by the entry (Effect boundary) it fires
* the `prompt.submit` RPC. The composer itself stays pure Solid (no Effect).
* 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).
* `onSubmit`/`onType` are plain callbacks wired by the entry no Effect here.
*/
import { type TextareaRenderable } from '@opentui/core'
import { onMount } from 'solid-js'
import { useKeyboard } from '@opentui/solid'
import { For, onMount, Show } from 'solid-js'
import type { CompletionItem } from '../logic/store.ts'
import { useTheme } from './theme.tsx'
export function Composer(props: { onSubmit: (text: string) => void }) {
export function Composer(props: {
onSubmit: (text: string) => void
onType?: ((text: string) => void) | undefined
completions?: (() => CompletionItem[]) | undefined
onDismiss?: (() => void) | undefined
}) {
const theme = useTheme()
let ta: TextareaRenderable | undefined
let submitting = false
const completions = () => props.completions?.() ?? []
const submit = () => {
if (submitting || !ta) return
@ -27,13 +37,50 @@ export function Composer(props: { onSubmit: (text: string) => void }) {
submitting = true
props.onSubmit(text)
ta.clear()
props.onDismiss?.()
submitting = false
}
// Tab accepts the top completion; Esc dismisses the dropdown. Only act while the
// dropdown is open so normal Tab/Esc behaviour is unaffected otherwise.
useKeyboard(key => {
if (completions().length === 0) return
if (key.name === 'tab') {
const top = completions()[0]
if (top && ta) {
ta.clear()
ta.insertText(top.text + ' ')
props.onDismiss?.()
}
} else if (key.name === 'escape') {
props.onDismiss?.()
}
})
onMount(() => ta?.focus())
return (
<box style={{ flexShrink: 0, marginTop: 1 }}>
<box style={{ flexDirection: 'column', flexShrink: 0, marginTop: 1 }}>
<Show when={completions().length > 0}>
<box
style={{
backgroundColor: theme().color.completionBg,
flexDirection: 'column',
paddingLeft: 1,
paddingRight: 1
}}
>
<For each={completions().slice(0, 8)}>
{(c, i) => (
<text fg={i() === 0 ? theme().color.accent : theme().color.text}>
{c.display || c.text}
{c.meta ? ` ${c.meta}` : ''}
</text>
)}
</For>
<text fg={theme().color.muted}>Tab complete · Esc dismiss</text>
</box>
</Show>
<textarea
ref={el => (ta = el)}
style={{ height: 3, width: '100%' }}
@ -44,6 +91,7 @@ export function Composer(props: { onSubmit: (text: string) => void }) {
focusedBackgroundColor={theme().color.statusBg}
keyBindings={[{ action: 'submit', name: 'return' }]}
onSubmit={submit}
onContentChange={() => props.onType?.(ta?.plainText ?? '')}
/>
</box>
)