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:
parent
190d810762
commit
d9249a36bf
5 changed files with 249 additions and 9 deletions
|
|
@ -2,15 +2,16 @@
|
||||||
// to the real `/api/state` snapshot via `useAgentState`. Still a slice,
|
// 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
|
// 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).
|
// (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 type { BadgeTone } from '@hive/shared/badge.js';
|
||||||
import { Header } from './components/Header.js';
|
import { Header } from './components/Header.js';
|
||||||
import { StatusChips } from './components/StatusChips.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 { HeaderPill } from './components/HeaderPill.js';
|
||||||
import { SidePanel } from './components/SidePanel.js';
|
import { SidePanel } from './components/SidePanel.js';
|
||||||
import { InboxPanel } from './components/InboxPanel.js';
|
import { InboxPanel } from './components/InboxPanel.js';
|
||||||
import { TodosPanel } from './components/TodosPanel.js';
|
import { TodosPanel } from './components/TodosPanel.js';
|
||||||
|
import { TermInput } from './components/TermInput.js';
|
||||||
import { useAgentState } from './hooks/useAgentState.js';
|
import { useAgentState } from './hooks/useAgentState.js';
|
||||||
import { useTodos } from './hooks/useTodos.js';
|
import { useTodos } from './hooks/useTodos.js';
|
||||||
import { fmtAge, fmtTokens } from './lib/format.js';
|
import { fmtAge, fmtTokens } from './lib/format.js';
|
||||||
|
|
@ -51,6 +52,17 @@ export function Root() {
|
||||||
const { state, refresh } = useAgentState();
|
const { state, refresh } = useAgentState();
|
||||||
const { todos, refresh: refreshTodos } = useTodos();
|
const { todos, refresh: refreshTodos } = useTodos();
|
||||||
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
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 = (
|
const pills = (
|
||||||
<>
|
<>
|
||||||
|
|
@ -99,8 +111,9 @@ export function Root() {
|
||||||
/>
|
/>
|
||||||
</Header>
|
</Header>
|
||||||
<main className="agent-main">
|
<main className="agent-main">
|
||||||
<LiveStream />
|
<LiveStream ref={liveStreamRef} />
|
||||||
</main>
|
</main>
|
||||||
|
{termInput}
|
||||||
{panel}
|
{panel}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -145,8 +158,9 @@ export function Root() {
|
||||||
/>
|
/>
|
||||||
</Header>
|
</Header>
|
||||||
<main className="agent-main">
|
<main className="agent-main">
|
||||||
<LiveStream onLiveTurnBoundary={refresh} />
|
<LiveStream ref={liveStreamRef} onLiveTurnBoundary={refresh} />
|
||||||
</main>
|
</main>
|
||||||
|
{termInput}
|
||||||
{panel}
|
{panel}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@
|
||||||
// unseen for an append. The old code inferred this from DOM
|
// unseen for an append. The old code inferred this from DOM
|
||||||
// mutation shape after the fact; here the caller already knows
|
// mutation shape after the fact; here the caller already knows
|
||||||
// which one it did, so there's nothing to infer.
|
// 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 { useLiveStream } from '../hooks/useLiveStream.js';
|
||||||
import { Row } from './Row.js';
|
import { Row } from './Row.js';
|
||||||
|
|
||||||
|
|
@ -33,8 +34,21 @@ export interface LiveStreamProps {
|
||||||
onLiveTurnBoundary?: () => void;
|
onLiveTurnBoundary?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveStream({ onLiveTurnBoundary }: LiveStreamProps) {
|
/** Imperative escape hatch for TermInput's local-only slash commands
|
||||||
const { rows, hasMore, loadingMore, loadMore } = useLiveStream({ onLiveTurnBoundary });
|
* (`/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 logRef = useRef<HTMLDivElement>(null);
|
||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
const [stickToBottom, setStickToBottom] = useState(true);
|
||||||
const [unseen, setUnseen] = useState(0);
|
const [unseen, setUnseen] = useState(0);
|
||||||
|
|
@ -110,4 +124,4 @@ export function LiveStream({ onLiveTurnBoundary }: LiveStreamProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
171
frontend/packages/agent/src/components/TermInput.tsx
Normal file
171
frontend/packages/agent/src/components/TermInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,13 @@ export interface UseLiveStreamResult {
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
loadingMore: boolean;
|
loadingMore: boolean;
|
||||||
loadMore: () => void;
|
loadMore: () => void;
|
||||||
|
/** Append a local-only note row (TermInput's `/help` output, error
|
||||||
|
* feedback on a failed slash-command POST) — never sent anywhere,
|
||||||
|
* purely a client-side echo. */
|
||||||
|
pushLocalNote: (text: string) => void;
|
||||||
|
/** Wipe the local view only (server-side history is untouched) —
|
||||||
|
* TermInput's `/clear`. */
|
||||||
|
clearLocal: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] {
|
function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] {
|
||||||
|
|
@ -179,5 +186,14 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rows, hasMore, loadingMore, loadMore };
|
const localKeyRef = useRef(0);
|
||||||
|
function pushLocalNote(text: string) {
|
||||||
|
localKeyRef.current += 1;
|
||||||
|
setRows((prev) => appendRow(prev, { key: `local-${localKeyRef.current}`, cssClass: 'note', fromHistory: false, text }));
|
||||||
|
}
|
||||||
|
function clearLocal() {
|
||||||
|
setRows([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
frontend/packages/agent/src/lib/termActions.ts
Normal file
25
frontend/packages/agent/src/lib/termActions.ts
Normal 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 }));
|
||||||
Loading…
Reference in a new issue