Files
hermes-agent/ui-opentui/src/view/composer.tsx
T

295 lines
11 KiB
TypeScript

/**
* 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` so it never collapses onto its rule; clear via
* `.clear()` (NOT key-remount); a `submitting` re-entrancy guard.
*
* 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); 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
* (onMouseDown), and reclaims focus on the next PRINTABLE keystroke if focus ever
* drifted off (e.g. the transcript scrollbox grabbed it on a mouse-scroll). Nav
* keys are left alone so keyboard transcript-scroll still works (opencode keeps
* the prompt focused via a reactive effect; here a keystroke net is enough since
* the composer remounts+refocuses whenever an overlay closes).
*/
import { type PasteEvent, type TextareaRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
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'
import { useDimensions } from './dimensions.tsx'
import { useTheme } from './theme.tsx'
const GUTTER = 2
/** Keys that must NOT steal focus back to the composer (scroll/edit/nav). */
const NAV_KEYS = new Set([
'return',
'linefeed',
'tab',
'escape',
'backspace',
'delete',
'insert',
'up',
'down',
'left',
'right',
'home',
'end',
'pageup',
'pagedown',
'clear',
'menu'
])
/** A printable, unmodified key press (recoverable into the textarea). */
function isPrintableKey(k: {
name: string
ctrl: boolean
meta: boolean
option: boolean
super?: boolean
sequence: string
eventType?: string
}): boolean {
return (
k.eventType !== 'release' &&
!k.ctrl &&
!k.meta &&
!k.option &&
!k.super &&
!NAV_KEYS.has(k.name) &&
typeof k.sequence === 'string' &&
k.sequence.length >= 1 &&
(k.sequence.codePointAt(0) ?? 0) >= 0x20
)
}
export function Composer(props: {
onSubmit: (text: string) => void
onType?: ((text: string) => void) | undefined
completions?: (() => CompletionItem[]) | undefined
completionFrom?: (() => number) | undefined
onDismiss?: (() => void) | undefined
history?: PromptHistory | undefined
onImagePaste?: (() => void) | undefined
pasteStore?: PasteStore | undefined
}) {
const theme = useTheme()
const dims = useDimensions()
// Auto-expand the input up to ~a third of the screen, then it scrolls internally
// (opencode's prompt: minHeight 1, maxHeight max(6, ⌊rows/3⌋)).
const maxHeight = () => Math.max(6, Math.floor(dims().height / 3))
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) => {
if (!ta) return
ta.setText(text)
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
// sending (item: pasted-text). No-op when nothing was placeheld.
const text = (props.pasteStore?.expand(ta.plainText) ?? ta.plainText).trim()
if (!text) return
submitting = true
props.onSubmit(text)
props.history?.push(text)
ta.clear()
props.pasteStore?.clear()
props.onDismiss?.()
submitting = false
}
useKeyboard(key => {
// 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 (action.kind === 'accept') {
acceptCompletion(action.index)
if (key.name === 'return') key.preventDefault()
return
}
if (action.kind === 'dismiss') {
props.onDismiss?.()
return
}
}
// 2) prompt history (item 6): Up at the first line → older prompt; Down at the
// last line → newer/draft. At the boundary the textarea's own up/down is a
// no-op, so there's no conflict; mid-buffer it falls through to cursor moves.
if (ta && props.history) {
if (key.name === 'up' && ta.logicalCursor.row === 0) {
const entry = props.history.prev(ta.plainText)
if (entry !== null) setBuffer(entry)
return
}
if (key.name === 'down' && ta.logicalCursor.row === ta.lineCount - 1) {
const entry = props.history.next()
if (entry !== null) setBuffer(entry)
return
}
// any edit resets the recall cursor so the next Up starts from the bottom
if (key.name === 'backspace' || key.name === 'delete' || isPrintableKey(key)) {
props.history.reset()
}
}
// 3) always-active input (item 2): a printable key while the textarea lost
// focus reclaims it. The renderer runs this GLOBAL handler BEFORE routing the
// key to the focused renderable, so after focus() the SAME keystroke is still
// delivered to the (now-focused) textarea — do NOT insert it here too, or the
// first letter doubles. Nav/scroll keys are untouched.
if (ta && !ta.focused && isPrintableKey(key)) {
ta.focus()
}
})
onMount(() => ta?.focus())
return (
<box style={{ flexDirection: 'column', flexShrink: 0 }}>
<Show when={completions().length > 0}>
<box
style={{
backgroundColor: theme().color.completionBg,
flexDirection: 'column',
paddingLeft: 1,
paddingRight: 1
}}
>
{/* the completion dropdown is transient input chrome (menu rows + the
key-hint) — not transcript content — so it's excluded from mouse
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). */}
<For each={menuItems()}>
{(c, i) => (
<box
style={{
backgroundColor: i() === selected() ? theme().color.completionCurrentBg : theme().color.completionBg
}}
>
<text selectable={false} fg={i() === selected() ? theme().color.accent : theme().color.text}>
{c.display || c.text}
{c.meta ? ` ${c.meta}` : ''}
</text>
</box>
)}
</For>
<text selectable={false} fg={theme().color.muted}>
{slashText() ? '↑/↓ select · Enter/Tab accept · Esc dismiss' : 'Tab complete · Esc dismiss'}
</text>
</box>
</Show>
{/* prompt glyph + textarea — the glyph (item 3) marks the input line so the
composer is distinguished by structure (glyph + the status-bar rule above),
not a background tint. */}
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
<text selectable={false}>
<span style={{ fg: theme().color.prompt }}>{theme().brand.prompt}</span>
</text>
</box>
<textarea
ref={el => (ta = el)}
minHeight={1}
maxHeight={maxHeight()}
style={{ flexGrow: 1, minWidth: 0 }}
placeholder={theme().brand.welcome}
placeholderColor={theme().color.muted}
textColor={theme().color.text}
cursorColor={theme().color.accent}
keyBindings={[{ action: 'submit', name: 'return' }]}
onMouseDown={() => ta?.focus()}
onSubmit={submit}
onPaste={(e: PasteEvent) => {
const text = new TextDecoder().decode(e.bytes)
// An empty bracketed paste = an image-only clipboard (item 1) — read + attach it.
if (text.trim() === '') {
e.preventDefault()
props.onImagePaste?.()
return
}
// A large paste becomes a compact `[Pasted text #N +M lines]` chip instead
// of flooding the input; the real text is expanded back on submit.
if (props.pasteStore && shouldPlaceholder(text)) {
e.preventDefault()
ta?.insertText(props.pasteStore.add(text))
return
}
// small pastes fall through to the textarea's native insert
}}
onContentChange={() => {
const text = ta?.plainText ?? ''
setSlashText(text.startsWith('/'))
props.onType?.(text)
}}
/>
</box>
</box>
)
}