// — 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} ▸