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

@ -20,7 +20,8 @@
// unseen for an append. The old code inferred this from DOM
// mutation shape after the fact; here the caller already knows
// which one it did, so there's nothing to infer.
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'preact/hooks';
import { forwardRef } from 'preact/compat';
import { useLiveStream } from '../hooks/useLiveStream.js';
import { Row } from './Row.js';
@ -33,8 +34,21 @@ export interface LiveStreamProps {
onLiveTurnBoundary?: () => void;
}
export function LiveStream({ onLiveTurnBoundary }: LiveStreamProps) {
const { rows, hasMore, loadingMore, loadMore } = useLiveStream({ onLiveTurnBoundary });
/** Imperative escape hatch for TermInput's local-only slash commands
* (`/help`, `/clear`) same shape as app.js's old `termAPI` object,
* kept as a ref handle rather than lifting the whole row array up to
* Root so LiveStream/useLiveStream still own their state privately. */
export interface LiveStreamHandle {
pushNote: (text: string) => void;
clear: () => void;
}
export const LiveStream = forwardRef<LiveStreamHandle, LiveStreamProps>(function LiveStream(
{ onLiveTurnBoundary },
ref,
) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream({ onLiveTurnBoundary });
useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]);
const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [unseen, setUnseen] = useState(0);
@ -110,4 +124,4 @@ export function LiveStream({ onLiveTurnBoundary }: LiveStreamProps) {
)}
</div>
);
}
});

View file

@ -0,0 +1,171 @@
// <TermInput> — the fixed composer at the bottom of the page. Sends a
// plain message to this agent's own turn loop (`POST send`), or
// intercepts a `/slash` command locally (never sent to the agent).
// Ported from app.js's `renderTermInput`/`handleSlashCommand`/
// `completeSlash`, with one deliberate UX change: `/new-session` and
// `/logout` used to pop the old shadow-DOM `themedConfirm` modal
// (@hive/shared/modal.js) before firing — this rewrite's destructive
// actions all avoid that widget family (see SidePanel.tsx's/
// useConfirmClick.ts's file comments for why), and a modal doesn't fit
// a text-input flow anyway. Instead: typing the command once arms it
// (a local note explains what confirming does), typing it again within
// the same "armed" state fires it — a keyboard-native two-step confirm.
import { useRef, useState } from 'preact/hooks';
import { postModel, postEffort } from '../lib/modelEffort.js';
import { postCancelTurn, postCompact, postNewSession, postLogout, postSend } from '../lib/termActions.js';
export interface TermInputProps {
label: string;
online: boolean;
onLocalNote: (text: string) => void;
onClear: () => void;
}
const SLASH_COMMANDS: { name: string; desc: string }[] = [
{ name: '/help', desc: 'list slash commands' },
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
{ name: '/cancel', desc: 'SIGINT the in-flight claude turn' },
{ name: '/compact', desc: 'compact the persistent claude session' },
{ name: '/model', desc: '/model <name> — switch claude model for future turns' },
{ name: '/effort', desc: '/effort <level> — set claude effort (medium/high/xhigh) for future turns' },
{ name: '/new-session', desc: 'fresh claude session next turn (type twice to confirm)' },
{ name: '/logout', desc: 'rotate OAuth creds + park in needs-login (type twice to confirm)' },
];
const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base
function completeSlash(prefix: string): string | null {
const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix));
if (!matches.length) return null;
const idx = matches.findIndex((c) => c.name === prefix);
return matches[(idx + 1) % matches.length].name;
}
export function TermInput({ label, online, onLocalNote, onClear }: TermInputProps) {
const [value, setValue] = useState('');
const taRef = useRef<HTMLTextAreaElement>(null);
// Which destructive command is currently "armed" (typed once, waiting
// for the confirming repeat) — any other command clears it.
const armedRef = useRef<string | null>(null);
function grow() {
const ta = taRef.current;
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px';
}
function reportFailure(label_: string, detail?: string) {
onLocalNote(`${label_} failed${detail ? ': ' + detail : ''}`);
}
function handleSlash(line: string): boolean {
const trimmed = line.trim();
const [cmd, ...rest] = trimmed.split(/\s+/);
const arg = rest.join(' ');
const wasArmed = armedRef.current === cmd;
armedRef.current = null;
switch (cmd) {
case '/help':
onLocalNote('/help');
for (const c of SLASH_COMMANDS) onLocalNote(` ${c.name.padEnd(13)}${c.desc}`);
return true;
case '/clear':
onClear();
onLocalNote('· terminal cleared (local view only — server history kept)');
return true;
case '/cancel':
postCancelTurn().then((r) => !r.ok && reportFailure('/cancel', r.detail));
return true;
case '/compact':
postCompact().then((r) => !r.ok && reportFailure('/compact', r.detail));
return true;
case '/new-session':
if (wasArmed) {
postNewSession().then((r) => !r.ok && reportFailure('/new-session', r.detail));
} else {
armedRef.current = '/new-session';
onLocalNote('⚠ drops all prior --continue context. type /new-session again to confirm.');
}
return true;
case '/logout':
if (wasArmed) {
postLogout().then((r) => !r.ok && reportFailure('/logout', r.detail));
} else {
armedRef.current = '/logout';
onLocalNote('⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.');
}
return true;
case '/model':
if (!arg) onLocalNote('✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)');
else postModel(arg).then((r) => !r.ok && reportFailure('/model', r.detail));
return true;
case '/effort':
if (!arg) onLocalNote('✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)');
else postEffort(arg).then((r) => !r.ok && reportFailure('/effort', r.detail));
return true;
default:
onLocalNote(`✗ unknown slash command: ${cmd} — try /help`);
return true;
}
}
function submit() {
const line = value;
if (!line.trim()) return;
setValue('');
requestAnimationFrame(grow);
if (line.trim().startsWith('/')) {
handleSlash(line);
return;
}
armedRef.current = null;
postSend(line).then((r) => !r.ok && reportFailure('send', r.detail));
}
function onInput(e: Event) {
setValue((e.currentTarget as HTMLTextAreaElement).value);
grow();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Tab' && value.startsWith('/') && !value.includes(' ')) {
const next = completeSlash(value);
if (next) {
e.preventDefault();
setValue(next);
}
return;
}
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
submit();
}
}
return (
<div class={`term-input${online ? '' : ' disabled'}`}>
<form
class="sendform-term"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<span class="prompt">operator@{label} </span>
<textarea
ref={taRef}
value={value}
placeholder={`message ${label}`}
rows={1}
disabled={!online}
autocomplete="off"
onInput={onInput}
onKeyDown={onKeyDown}
/>
<span class="submit-hint"> send · newline · /help</span>
</form>
</div>
);
}