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