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

@ -0,0 +1,25 @@
// Same-origin fetch POST helpers for the term-input slash commands —
// same shape as modelEffort.ts's `post()` ({ ok, detail } rather than
// throwing), reused by TermInput.tsx.
async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> {
try {
const resp = await fetch(path, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
body,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
if (ok) return { ok: true };
const detail = await resp.text().catch(() => '');
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
} catch (err) {
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
}
}
export const postCancelTurn = () => post('api/cancel');
export const postCompact = () => post('api/compact');
export const postNewSession = () => post('api/new-session');
export const postLogout = () => post('api/logout');
export const postSend = (body: string) => post('send', new URLSearchParams({ body }));