mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 13:41:53 +00:00
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:
parent
3f54152191
commit
7412cd5c78
7 changed files with 157 additions and 16 deletions
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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: '' })
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue