hyperhive/frontend/packages/agent/src/components/TermInput.tsx
atlas 39b95c2ede treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
2026-09-02 15:25:07 +02:00

217 lines
6.6 KiB
TypeScript

// <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>
);
}