diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index ba3934e9..ede56cd3 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -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(null); + const liveStreamRef = useRef(null); + const termInput = ( +
+ liveStreamRef.current?.pushNote(text)} + onClear={() => liveStreamRef.current?.clear()} + /> +
+ ); const pills = ( <> @@ -99,8 +111,9 @@ export function Root() { />
- +
+ {termInput} {panel} ); @@ -145,8 +158,9 @@ export function Root() { />
- +
+ {termInput} {panel} ); diff --git a/frontend/packages/agent/src/components/LiveStream.tsx b/frontend/packages/agent/src/components/LiveStream.tsx index 64e9a0d1..47b0832e 100644 --- a/frontend/packages/agent/src/components/LiveStream.tsx +++ b/frontend/packages/agent/src/components/LiveStream.tsx @@ -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(function LiveStream( + { onLiveTurnBoundary }, + ref, +) { + const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream({ onLiveTurnBoundary }); + useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]); const logRef = useRef(null); const [stickToBottom, setStickToBottom] = useState(true); const [unseen, setUnseen] = useState(0); @@ -110,4 +124,4 @@ export function LiveStream({ onLiveTurnBoundary }: LiveStreamProps) { )} ); -} +}); diff --git a/frontend/packages/agent/src/components/TermInput.tsx b/frontend/packages/agent/src/components/TermInput.tsx new file mode 100644 index 00000000..823817db --- /dev/null +++ b/frontend/packages/agent/src/components/TermInput.tsx @@ -0,0 +1,171 @@ +// — 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 — switch claude model for future turns' }, + { name: '/effort', desc: '/effort — 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(null); + // Which destructive command is currently "armed" (typed once, waiting + // for the confirming repeat) — any other command clears it. + const armedRef = useRef(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 ( +
+
{ + e.preventDefault(); + submit(); + }} + > + operator@{label} ▸ +