/* Director Console (hero) — transcript + command engine + autocomplete. */
import { useEffect, useRef, useState, type MutableRefObject } from "react";
import { Icon } from "./Icon";
import { boldHtml, highlightArgs } from "../state/util";
import { VERBS, parse } from "../state/sceneTool";
import { parseCliResult, runSceneCommand } from "../state/backend";
import type { ComposerMode, CmdTurn, Turn } from "../state/types";
/** Imperative handle so the palette can run a command in the console. */
export interface ConsoleApi {
/** Run a command; `forceCommand` runs it as a raw scene-tool command regardless of mode. */
run?: (text: string, forceCommand?: boolean) => void;
}
// Quick-insert examples — each one is the REAL CLI grammar (animation-scene.ts) and runs as-is.
const CHIPS = [
'cast add --id pip --query "robot kid"',
"shot retime --id shot-2 --duration 30",
"camera --shot shot-1 --preset close-up",
"gesture --character pip --shot shot-1 --clip wave"
];
/** The portion of a command after its (possibly two-word) verb — the card's args line. */
function argTail(raw: string, verb: string): string {
const t = raw.trim();
return verb && t.toLowerCase().startsWith(verb.toLowerCase()) ? t.slice(verb.length).trim() : "";
}
function CmdCard({ turn }: { turn: CmdTurn }) {
return (
aura
›
{turn.verb}
{turn.state === "run" ? (
validating
) : turn.state === "bad" ? (
rejected
) : (
committed
)}
{turn.state !== "run" && turn.diffs && (
{turn.diffs.map((d, i) => (
{d.op}
))}
)}
{turn.state === "ok" && (
{"ran in " + turn.dur}
{"doc @ " + turn.hash}
)}
);
}
function TurnView({ turn }: { turn: Turn }) {
if (turn.type === "you")
return (
);
if (turn.type === "dir")
return (
);
if (turn.type === "cmd")
return (
);
if (turn.type === "render")
return (
{turn.shot}
{"· " + turn.meta}
);
return null;
}
export interface ConsoleProps {
transcript: Turn[];
setTranscript: React.Dispatch>;
selShot: string | null;
onRender: (scope: "shot" | "sequence") => void;
/** Called after a committed Scene-Tool mutation so the app re-syncs with the real document. */
onSceneCommit: () => void;
api: MutableRefObject;
}
export function Console({ transcript, setTranscript, selShot, onRender, onSceneCommit, api }: ConsoleProps) {
const [mode, setMode] = useState("Prompt");
const [val, setVal] = useState("");
const [focus, setFocus] = useState(false);
const [acIdx, setAcIdx] = useState(0);
const scrollRef = useRef(null);
const inputRef = useRef(null);
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [transcript]);
const suggestions = (() => {
const q = val.trim().toLowerCase();
if (!q) return VERBS.slice(0, 6);
return VERBS.filter((v) => v.verb.startsWith(q.split(" ")[0]) || v.verb.includes(q));
})();
const push = (t: Turn) => setTranscript((p) => [...p, t]);
const update = (id: string, patch: Partial) =>
setTranscript((p) => p.map((t) => (t.id === id && t.type === "cmd" ? { ...t, ...patch } : t)));
// Command mode runs the raw scene-tool command against the REAL CLI (POST /api/scene);
// Prompt mode shows the user's intent — their own coding agent drives the actual commands.
// `render`/`render --shot` short-circuits to the real render pipeline.
const run = (text: string, forceCommand = false) => {
const raw = text.trim();
if (!raw) return;
push({ type: "you", id: "u" + Date.now(), text: raw });
setVal("");
if (inputRef.current) inputRef.current.style.height = "auto";
const p = parse(raw);
// Render verb → the real render pipeline (also reachable from the Render button).
if (p.verb === "render") {
const cmdId = "c" + Date.now();
const scope = p.flags.shot ? "shot" : "sequence";
push({ type: "cmd", id: cmdId, verb: "render", args: p.flags.shot ? "--shot " + p.flags.shot : "--scope sequence", state: "run" });
onRender(scope);
update(cmdId, { state: "ok", diffs: [{ op: "~", k: "mod", t: "render queued · " + scope }], dur: "—", hash: "" });
return;
}
// Prompt mode: do NOT mutate. The directing agent (the user's coding agent) reads this
// intent and runs the concrete scene-tool commands itself. We only echo the intent.
// (The palette forces command execution, so it bypasses this.)
if (mode === "Prompt" && !forceCommand) {
window.setTimeout(
() =>
push({
type: "dir",
id: "d" + Date.now(),
think:
"Noted. Your coding agent is the director — it will translate this into validated **Scene-Tool** commands. " +
"Switch to **Command** mode to run a raw command here against the working document."
}),
300
);
return;
}
// Command mode: run the REAL CLI and render the committed / rejected card.
const cmdId = "c" + Date.now();
push({ type: "cmd", id: cmdId, verb: p.verb || raw.split(/\s+/)[0], args: argTail(raw, p.verb), state: "run" });
void runSceneCommand(raw).then((res) => {
const { diffs } = parseCliResult(raw, res);
if (!res.ok || res.rejected) {
update(cmdId, { state: "bad", diffs });
return;
}
update(cmdId, { state: "ok", diffs, dur: res.ms ? (res.ms / 1000).toFixed(1) + "s" : "—", hash: res.hash || "—" });
// Re-sync the panels with the now-mutated working document.
onSceneCommit();
});
};
useEffect(() => {
api.current.run = run;
});
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
run(val);
setAcIdx(0);
return;
}
if (focus && suggestions.length) {
if (e.key === "ArrowDown") {
e.preventDefault();
setAcIdx((i) => Math.min(suggestions.length - 1, i + 1));
}
if (e.key === "ArrowUp") {
e.preventDefault();
setAcIdx((i) => Math.max(0, i - 1));
}
if (e.key === "Tab") {
e.preventDefault();
const s = suggestions[acIdx];
setVal(s.verb + " ");
inputRef.current?.focus();
}
}
};
const showAc = focus && mode === "Command" && suggestions.length > 0;
return (
Edit this scene
ask the AI in plain English, or type an exact command
{transcript.map((t) => (
))}
{CHIPS.map((c) => (
))}
{showAc && (
Scene-Tool commands
{suggestions.map((s, i) => (
setAcIdx(i)}
onMouseDown={(e) => {
e.preventDefault();
setVal(s.verb + " ");
inputRef.current?.focus();
}}
>
{s.verb} {s.tail}
{s.desc}
{i === acIdx && tab}
))}
)}
{(["Prompt", "Command"] as ComposerMode[]).map((m) => (
))}
{mode === "Prompt" ? "plain English · your AI agent runs it" : "exact command · runs now"}
{mode === "Command" && (
Top commands — click one to use it
{VERBS.map((v) => (
))}
)}
);
}