agent: TermInput — composer + slash commands

Ported from app.js's `renderTermInput`/`handleSlashCommand`/
`completeSlash`: prompt + auto-growing textarea, Enter sends (Shift+Enter
newline), Tab cycles slash-command completion. Same command set
(/help, /clear, /cancel, /compact, /model, /effort, /new-session,
/logout), same routes (`api/cancel`, `api/compact`, `api/new-session`,
`api/logout`, `send`) via new `lib/termActions.ts` (same `{ok, detail}`
shape as modelEffort.ts's POST helper).

One deliberate UX change: `/new-session` and `/logout` used to pop the
old shadow-DOM `themedConfirm` modal before firing — this rewrite's
destructive actions all avoid that widget family already (SidePanel,
useConfirmClick), and a modal doesn't fit a text-input flow anyway.
Typing the command once arms it (a local note explains what confirming
does); typing it again fires it — a keyboard-native two-step confirm.

`/help`/`/clear` need to reach into LiveStream's row list (local-only
echo rows, never sent anywhere) without lifting that state up to Root —
`useLiveStream` gained `pushLocalNote`/`clearLocal`, exposed off
`LiveStream` via `forwardRef`+`useImperativeHandle` (preact/compat),
same shape as app.js's old `termAPI` object but scoped as a ref handle
instead of a module-level variable.

Screenshot-verified end to end: typed "/help" + Enter into a real
mounted composer, confirmed the textarea clears and the local note rows
(command list) append to the live pane.
This commit is contained in:
iris 2026-08-28 02:53:03 +02:00
commit d9249a36bf
5 changed files with 249 additions and 9 deletions

View file

@ -2,15 +2,16 @@
// to the real `/api/state` snapshot via `useAgentState`. Still a slice,
// not the whole page — see main.tsx's file comment for what's left
// (the live SSE stream, login flow, inbox/todos, term input, overflow).
import { useState } from 'preact/hooks';
import { useRef, useState } from 'preact/hooks';
import type { BadgeTone } from '@hive/shared/badge.js';
import { Header } from './components/Header.js';
import { StatusChips } from './components/StatusChips.js';
import { LiveStream } from './components/LiveStream.js';
import { LiveStream, type LiveStreamHandle } from './components/LiveStream.js';
import { HeaderPill } from './components/HeaderPill.js';
import { SidePanel } from './components/SidePanel.js';
import { InboxPanel } from './components/InboxPanel.js';
import { TodosPanel } from './components/TodosPanel.js';
import { TermInput } from './components/TermInput.js';
import { useAgentState } from './hooks/useAgentState.js';
import { useTodos } from './hooks/useTodos.js';
import { fmtAge, fmtTokens } from './lib/format.js';
@ -51,6 +52,17 @@ export function Root() {
const { state, refresh } = useAgentState();
const { todos, refresh: refreshTodos } = useTodos();
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const liveStreamRef = useRef<LiveStreamHandle>(null);
const termInput = (
<footer class="agent-composer">
<TermInput
label={state?.label ?? '…'}
online={state?.status === 'online'}
onLocalNote={(text) => liveStreamRef.current?.pushNote(text)}
onClear={() => liveStreamRef.current?.clear()}
/>
</footer>
);
const pills = (
<>
@ -99,8 +111,9 @@ export function Root() {
/>
</Header>
<main className="agent-main">
<LiveStream />
<LiveStream ref={liveStreamRef} />
</main>
{termInput}
{panel}
</>
);
@ -145,8 +158,9 @@ export function Root() {
/>
</Header>
<main className="agent-main">
<LiveStream onLiveTurnBoundary={refresh} />
<LiveStream ref={liveStreamRef} onLiveTurnBoundary={refresh} />
</main>
{termInput}
{panel}
</>
);