Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/agent/src/components/TermInput.tsx
atlas ed271e655f agents: remove the hive-level /effort control too
mara on #5034: "effort shares /models fate". The per-agent web UI no
longer sets effort: the `/effort` slash command, the effort picker,
`POST /api/effort`, `Bus::set_effort`, the `hyperhive-effort` writer,
`EFFORT_LEVELS`/`is_valid_effort` and the ACP effort-list payload are
gone, and `lib/modelEffort.ts` with them. The effort chip is
display-only and shows its source (`effort_source` in `/api/state`);
on ACP it stays hidden while the session offers no effort level.

The read side is unchanged: with no swarm value an agent runs an
existing `hyperhive-effort` file, else its agent.nix effort, else
medium. Existing files are not touched.

Refs #5034
2026-10-11 01:28:44 +02:00

187 lines
5.7 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 {
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: "/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] = trimmed.split(/\s+/);
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;
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>
);
}