318 lines
19 KiB
React
318 lines
19 KiB
React
// app.jsx — Correx TUI shell: layout, live state, keybind engine, theme + tweaks.
|
||
const { useState, useEffect, useRef, useCallback } = React;
|
||
const D = window.CORREX;
|
||
const { applyTheme, THEMES, ACCENTS } = window.CORREX_THEMES;
|
||
|
||
const DIR_ORDER = ["boxed", "soft", "bare"];
|
||
const ACC_ORDER = ["cyan", "amber", "green", "magenta", "neutral"];
|
||
const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||
|
||
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||
"direction": "boxed",
|
||
"accent": "cyan",
|
||
"fontSize": 13,
|
||
"motion": true,
|
||
"scanlines": false
|
||
}/*EDITMODE-END*/;
|
||
|
||
function Panel({ n, title, right, active, className, footer, bodyRef, children }) {
|
||
return (
|
||
<div className={"panel" + (active ? " active" : "") + (className ? " " + className : "")}>
|
||
<div className="panel-h">
|
||
{n && <span className="pn">{n}</span>}
|
||
<span className="pt">{title}</span>
|
||
{right && <span className="ph-r">{right}</span>}
|
||
</div>
|
||
<div className="panel-b" ref={bodyRef}>{children}</div>
|
||
{footer}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function App() {
|
||
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||
const rootRef = useRef(null);
|
||
const evRef = useRef(null);
|
||
|
||
const [overlay, setOverlay] = useState(null); // 'cmd'|'events'|'context'|'graph'|'models'|'tools'|'approval'|null
|
||
const [focusPanel, setFocusPanel] = useState("conv"); // 'conv'|'events'
|
||
const [sessionState, setSessionState] = useState("awaiting_approval");
|
||
const [activeModel, setActiveModel] = useState(D.SESSION.model);
|
||
const [tools, setTools] = useState(D.TOOLS);
|
||
const [autoApprove, setAutoApprove] = useState(false);
|
||
const [approvalResolved, setApprovalResolved] = useState(false);
|
||
|
||
const [msgs, setMsgs] = useState(D.CONVERSATION);
|
||
const [events, setEvents] = useState(D.EVENTS);
|
||
const [stages, setStages] = useState(D.STAGES);
|
||
const [toast, setToast] = useState(null);
|
||
|
||
const [streamTarget, setStreamTarget] = useState(D.CONVERSATION.length - 1);
|
||
const [streamLen, setStreamLen] = useState(0);
|
||
const [frame, setFrame] = useState(0);
|
||
|
||
const [draft, setDraft] = useState("");
|
||
const [promptFocus, setPromptFocus] = useState(false);
|
||
const promptRef = useRef(null);
|
||
const convRef = useRef(null);
|
||
|
||
// theme application
|
||
useEffect(() => { if (rootRef.current) applyTheme(rootRef.current, t.direction, t.accent); }, [t.direction, t.accent]);
|
||
|
||
// spinner / clock frames
|
||
useEffect(() => {
|
||
if (!t.motion) return;
|
||
const id = setInterval(() => setFrame((f) => f + 1), 110);
|
||
return () => clearInterval(id);
|
||
}, [t.motion]);
|
||
|
||
// streaming reveal
|
||
useEffect(() => {
|
||
if (streamTarget == null) return;
|
||
const full = (msgs[streamTarget] || {}).text || "";
|
||
if (streamLen >= full.length) return;
|
||
if (!t.motion) { setStreamLen(full.length); return; }
|
||
const id = setTimeout(() => setStreamLen((n) => Math.min(n + 3, full.length)), 16);
|
||
return () => clearTimeout(id);
|
||
}, [streamTarget, streamLen, msgs, t.motion]);
|
||
|
||
// autoscroll event panel
|
||
useEffect(() => { if (evRef.current) evRef.current.scrollTop = evRef.current.scrollHeight; }, [events]);
|
||
// autoscroll conversation
|
||
useEffect(() => { if (convRef.current) convRef.current.scrollTop = convRef.current.scrollHeight; }, [msgs, streamLen]);
|
||
|
||
const flash = (m) => { setToast(m); setTimeout(() => setToast(null), 2600); };
|
||
const pushEvent = (e) => setEvents((arr) => [...arr, { id: "x" + arr.length + Math.random().toString(36).slice(2, 5), ...e }]);
|
||
const addMsg = (m) => { setMsgs((arr) => { const ni = arr.length; setStreamTarget(ni); setStreamLen(0); return [...arr, m]; }); };
|
||
const appendMsg = (m) => setMsgs((arr) => [...arr, m]);
|
||
|
||
// user steering input → routes back through Correx
|
||
const hm = () => new Date().toTimeString().slice(0, 5);
|
||
const replyFor = (q) => {
|
||
const s = q.toLowerCase();
|
||
if (/test|spec|cover/.test(s)) return "Queued — I'll add SegmentStoreTest with an append→read round-trip and a dedup assertion (identical bytes ⇒ one object). Runs at the validation gate.";
|
||
if (/perf|bench|fast|speed|throughput/.test(s)) return "Noted. I'll add a JMH harness for append throughput; the mmap read path stays zero-copy so reads aren't affected by compression.";
|
||
if (/revert|undo|stop|cancel|rollback/.test(s)) return "Holding edits. Nothing destructive ran — the gated gradle resolve was the only external op. Awaiting your next instruction.";
|
||
if (/compress|zstd|gzip|lz4|codec/.test(s)) return "Compression is pluggable behind a SegmentCodec — zstd for cold, raw for hot. I can swap lz4 for lower CPU at a worse ratio; say the word.";
|
||
if (/index|offset|seek|lookup/.test(s)) return "SegmentIndex maps logical offset ranges → CAS keys with a binary search over segment bounds; O(log n) seeks. I'll memo the hot range.";
|
||
return "Understood — folding that into the implementation plan. I'll surface a revised ImplementationArtifact at the validation gate for review.";
|
||
};
|
||
const send = () => {
|
||
const text = draft.trim(); if (!text) return;
|
||
setDraft("");
|
||
setStreamTarget(null);
|
||
appendMsg({ role: "user", t: hm(), text });
|
||
const at = (ms, fn) => seqRef.current.push(setTimeout(fn, ms));
|
||
at(320, () => pushEvent({ t: hm() + ":02", cat: "Context", type: "steer.captured", text: "router → agent context (L1)" }));
|
||
at(760, () => addMsg({ role: "kernel", kind: "route", text: "Steering synthesized → injected at L1 · agent context updated" }));
|
||
at(1500, () => addMsg({ role: "assistant", t: hm(), text: replyFor(text) }));
|
||
};
|
||
|
||
// resolve approval → drive the live sequence
|
||
const seqRef = useRef([]);
|
||
const clearSeq = () => { seqRef.current.forEach(clearTimeout); seqRef.current = []; };
|
||
useEffect(() => () => clearSeq(), []);
|
||
|
||
const resolveApproval = useCallback((kind, note) => {
|
||
setOverlay(null);
|
||
setApprovalResolved(true);
|
||
setSessionState("active");
|
||
setEvents((arr) => arr.map((e) => e.pending ? { ...e, pending: false } : e));
|
||
const at = (ms, fn) => seqRef.current.push(setTimeout(fn, ms));
|
||
|
||
if (kind === "reject") {
|
||
flash("Rejected — agent re-plans without the network tool");
|
||
pushEvent({ t: "14:12:31", cat: "Approval", type: "approval.rejected", text: "operator rejected T3 gate" });
|
||
at(700, () => pushEvent({ t: "14:12:33", cat: "Inference", type: "infer.start", text: "re-planning · no-network constraint" }));
|
||
at(1600, () => addMsg({ role: "assistant", t: "14:12", text: "Understood — dropping zstd. I'll compress cold segments with the JDK Deflater instead: no new dependency, no network. Slightly worse ratio, zero supply-chain surface." }));
|
||
at(3400, () => pushEvent({ t: "14:12:48", cat: "Domain", type: "artifact.emitted", text: "ImplementationArtifact ✓ (deflater path)" }));
|
||
at(4200, () => transition());
|
||
return;
|
||
}
|
||
// approve / auto / steer
|
||
const auto = kind === "auto";
|
||
if (auto) setAutoApprove(true);
|
||
flash(kind === "steer" ? "Steered + approved — note injected into agent context" : auto ? "Auto-approve enabled for session" : "Approved");
|
||
pushEvent({ t: "14:12:14", cat: "Approval", type: kind === "steer" ? "approval.steered" : "approval.approved", text: note ? "“" + note + "”" : "operator approved" + (auto ? " · auto-session ON" : "") });
|
||
at(500, () => pushEvent({ t: "14:12:15", cat: "Tool", type: "shell.exec", text: "gradle resolving zstd-jni …", running: true }));
|
||
at(2100, () => setEvents((arr) => arr.map((e) => e.running ? { id: e.id, t: "14:12:19", cat: "Tool", type: "shell.exec", text: "gradle resolve OK (4.1s) · +1 dep" } : e)));
|
||
at(2600, () => addMsg({ role: "assistant", t: "14:12", text: (note ? "Locking the version as asked. " : "") + "Resolved zstd-jni 1.5.6-3. Cold segments now zstd-compress on write; hot segments stay raw for mmap. Emitting the implementation artifact." }));
|
||
at(4600, () => pushEvent({ t: "14:12:34", cat: "Domain", type: "artifact.emitted", text: "ImplementationArtifact ✓ valid" }));
|
||
at(5400, () => transition());
|
||
}, []);
|
||
|
||
const transition = () => {
|
||
pushEvent({ t: "14:12:36", cat: "Lifecycle", type: "stage.transition", text: "implementation → validation" });
|
||
setStages((arr) => arr.map((s) => s.id === "implementation" ? { ...s, status: "done", note: "artifact accepted" } : s.id === "validation" ? { ...s, status: "active", enter: "14:12", note: "schema + semantic checks" } : s));
|
||
pushEvent({ t: "14:12:36", cat: "Lifecycle", type: "stage.enter", text: "validation (role=reviewer)" });
|
||
};
|
||
|
||
// auto-open the approval gate once (modal interrupt demonstration)
|
||
const raised = useRef(false);
|
||
useEffect(() => {
|
||
if (raised.current) return; raised.current = true;
|
||
const id = setTimeout(() => { if (!approvalResolved) setOverlay("approval"); }, 1100);
|
||
return () => clearTimeout(id);
|
||
}, []);
|
||
|
||
// ── keybind engine ──
|
||
const cycle = (key, order) => setTweak(key, order[(order.indexOf(t[key]) + 1) % order.length]);
|
||
const setDirection = (dir) => setTweak({ direction: dir, accent: THEMES[dir].defaultAccent });
|
||
const cycleDir = () => setDirection(DIR_ORDER[(DIR_ORDER.indexOf(t.direction) + 1) % DIR_ORDER.length]);
|
||
const open = (id) => {
|
||
if (id === "appearance") { cycleDir(); return; }
|
||
if (id === "approval") { if (approvalResolved) { flash("No pending approvals"); return; } setOverlay("approval"); return; }
|
||
setOverlay(id);
|
||
};
|
||
|
||
useEffect(() => {
|
||
const onKey = (e) => {
|
||
const typing = /^(INPUT|TEXTAREA)$/.test(document.activeElement && document.activeElement.tagName);
|
||
// approval modal chords
|
||
if (overlay === "approval") {
|
||
if (typing) return;
|
||
if (e.key === "a") { resolveApproval("approve"); e.preventDefault(); }
|
||
else if (e.key === "A") { resolveApproval("auto"); e.preventDefault(); }
|
||
else if (e.key === "r") { resolveApproval("reject"); e.preventDefault(); }
|
||
else if (e.key === "Escape") setOverlay(null);
|
||
return;
|
||
}
|
||
if (e.key === "Escape") { if (overlay) { setOverlay(null); e.preventDefault(); } return; }
|
||
if (typing) return;
|
||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { setOverlay("cmd"); e.preventDefault(); return; }
|
||
if (overlay === "cmd") return; // palette owns keys
|
||
if (overlay) return; // other overlays: only esc (above)
|
||
switch (e.key) {
|
||
case "p": case ":": case "/": setOverlay("cmd"); e.preventDefault(); break;
|
||
case "e": open("events"); break;
|
||
case "c": open("context"); break;
|
||
case "g": open("graph"); break;
|
||
case "m": open("models"); break;
|
||
case "t": open("tools"); break;
|
||
case "i": promptRef.current && promptRef.current.focus(); e.preventDefault(); break;
|
||
case "a": open("approval"); break;
|
||
case "F2": cycleDir(); e.preventDefault(); break;
|
||
case "F3": cycle("accent", ACC_ORDER); e.preventDefault(); break;
|
||
case "Tab": setFocusPanel((p) => p === "conv" ? "events" : "conv"); e.preventDefault(); break;
|
||
default: break;
|
||
}
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [overlay, approvalResolved, t.direction, t.accent, resolveApproval]);
|
||
|
||
// ── footer hints (contextual) ──
|
||
const hints = (() => {
|
||
if (overlay === "approval") return [["a", "approve"], ["A", "auto-session"], ["s", "steer"], ["r", "reject"], ["esc", "later"]];
|
||
if (overlay === "cmd") return [["↑↓", "move"], ["↵", "run"], ["esc", "close"]];
|
||
if (overlay) return [["↑↓", "navigate"], ["↵", "select"], ["esc", "back"]];
|
||
if (promptFocus) return [["↵", "send to correx"], ["esc", "blur"]];
|
||
return [["i", "message"], ["p", "commands"], ["a", "approval"], ["e", "events"], ["c", "context"], ["g", "graph"], ["m", "models"], ["t", "tools"], ["F2", "skin"]];
|
||
})();
|
||
|
||
const tokPct = Math.min(100, Math.round((D.CONTEXT_PACK.used / D.CONTEXT_PACK.budget) * 100) + (approvalResolved ? 4 : 0));
|
||
const spin = SPIN[frame % SPIN.length];
|
||
|
||
return (
|
||
<div className="crx" ref={rootRef} style={{ fontSize: t.fontSize, "--scan": t.scanlines ? 1 : 0 }}>
|
||
{t.scanlines && <div style={{ position: "absolute", inset: 0, zIndex: 30, pointerEvents: "none", background: "repeating-linear-gradient(0deg, rgb(0 0 0 / 0.16) 0 1px, transparent 1px 3px)" }} />}
|
||
|
||
{/* top status line */}
|
||
<div className="crx-top">
|
||
<span className="crx-brand">corre<b>x</b></span>
|
||
<span className="dot">·</span>
|
||
<span className="seg">session <b>{D.SESSION.name}</b></span>
|
||
<span className="dot">·</span>
|
||
<span className="seg"><b>{D.SESSION.branch}</b></span>
|
||
<span className="dot">·</span>
|
||
<span className="seg">{activeModel} <span style={{ color: "var(--faint)" }}>({D.MODELS.find((m) => m.id === activeModel).provider})</span></span>
|
||
{autoApprove && <span className="pill live">auto-approve</span>}
|
||
<span className="crx-spacer" />
|
||
<span className="crx-budget">ctx <div className="crx-meter"><i style={{ width: tokPct + "%" }} /></div> {tokPct}%</span>
|
||
<span className={"crx-state"} style={sessionState === "active" ? { color: "var(--ok)", borderColor: "color-mix(in oklab,var(--ok),transparent 60%)", background: "color-mix(in oklab,var(--ok),transparent 88%)" } : null}>
|
||
{sessionState === "awaiting_approval" ? "⏸ awaiting approval" : "▸ active"}
|
||
</span>
|
||
</div>
|
||
|
||
{/* main split */}
|
||
<div className="crx-main">
|
||
<Panel n="[1]" title="router · conversation" right={D.SESSION.project} active={focusPanel === "conv"} bodyRef={convRef}
|
||
footer={
|
||
<div className={"prompt" + (promptFocus ? " foc" : "")} onClick={() => promptRef.current && promptRef.current.focus()}>
|
||
<span className="pr">{promptFocus ? "❯" : "›"}</span>
|
||
<input ref={promptRef} value={draft} placeholder="message correx — steer the run…"
|
||
onFocus={() => setPromptFocus(true)} onBlur={() => setPromptFocus(false)}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") send(); else if (e.key === "Escape") promptRef.current.blur(); }} />
|
||
{!promptFocus && !draft && <span className="ph"><kbd>i</kbd> focus · <kbd>↵</kbd> send</span>}
|
||
</div>
|
||
}>
|
||
{msgs.map((m, i) => {
|
||
const streaming = i === streamTarget;
|
||
const text = streaming ? m.text.slice(0, streamLen) : m.text;
|
||
const who = m.role === "user" ? "you" : m.role === "kernel" ? "correx" : "agent";
|
||
return (
|
||
<div className={"msg " + m.role} key={i}>
|
||
<span className="msg-gut"><span className="who">{who}</span>{m.t}</span>
|
||
<span className="msg-body">{text}{streaming && streamLen < m.text.length && <span className="cursor" />}{streaming && streamLen >= m.text.length && sessionState === "awaiting_approval" && <span className="cursor" />}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</Panel>
|
||
|
||
<Panel n="[2]" title="event stream" active={focusPanel === "events"}
|
||
right={sessionState === "awaiting_approval" ? <span style={{ color: "var(--warn)" }}>{spin} paused</span> : <span style={{ color: "var(--ok)" }}>{spin} live</span>}>
|
||
<div ref={evRef} style={{ height: "100%", overflow: "auto" }}>
|
||
{events.map((e) => (
|
||
<div className={"ev" + (e.pending ? " pending" : "")} key={e.id} onClick={() => setOverlay("events")}>
|
||
<span className="ev-t">{e.t}</span>
|
||
<span className={"ev-cat cat-" + e.cat}>{e.cat}</span>
|
||
<span className="ev-tx">{e.running && <span className="spin">{spin} </span>}<b>{e.type}</b> {e.text}{e.pending && <span className="kbd-inline" style={{ marginLeft: 6 }}>a</span>}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Panel>
|
||
</div>
|
||
|
||
{/* footer keybind bar */}
|
||
<div className="crx-foot">
|
||
{hints.map(([k, l], i) => (
|
||
<span className="kb" key={i} onClick={() => {
|
||
const map = { p: () => setOverlay("cmd"), a: () => open("approval"), e: () => open("events"), c: () => open("context"), g: () => open("graph"), m: () => open("models"), t: () => open("tools"), i: () => promptRef.current && promptRef.current.focus(), F2: () => cycleDir() };
|
||
map[k] && map[k]();
|
||
}}>
|
||
<kbd>{k}</kbd><b>{l}</b>
|
||
</span>
|
||
))}
|
||
<span className="foot-mode">{THEMES[t.direction].label} · <b>{t.accent}</b></span>
|
||
</div>
|
||
|
||
{toast && <div style={{ position: "absolute", bottom: 46, left: "50%", transform: "translateX(-50%)", zIndex: 50, background: "var(--panel-2)", border: "1px solid var(--accent)", color: "var(--fg-strong)", padding: "8px 16px", borderRadius: 8, fontSize: 12.5, boxShadow: "0 14px 40px -16px #000" }}>{toast}</div>}
|
||
|
||
{/* overlays */}
|
||
{overlay === "cmd" && <CommandPalette commands={D.COMMANDS} onRun={(id) => { setOverlay(null); setTimeout(() => open(id), 0); }} onClose={() => setOverlay(null)} />}
|
||
{overlay === "approval" && <ApprovalModal data={D.APPROVAL} onApprove={(auto) => resolveApproval(auto ? "auto" : "approve")} onReject={() => resolveApproval("reject")} onSteer={(n) => resolveApproval("steer", n)} onClose={() => setOverlay(null)} />}
|
||
{overlay === "events" && <EventInspector events={events} onClose={() => setOverlay(null)} />}
|
||
{overlay === "context" && <ContextPackInspector pack={D.CONTEXT_PACK} onClose={() => setOverlay(null)} />}
|
||
{overlay === "graph" && <StageGraph stages={stages} transitions={D.TRANSITIONS} onClose={() => setOverlay(null)} />}
|
||
{overlay === "models" && <ModelRegistry models={D.MODELS} active={activeModel} onSwitch={(id) => { setActiveModel(id); setOverlay(null); flash("Switched → " + id + " · re-synthesizing Context Pack"); pushEvent({ t: "14:12", cat: "Context", type: "pack.resynth", text: "for " + id }); }} onClose={() => setOverlay(null)} />}
|
||
{overlay === "tools" && <ToolPalette tools={tools} onToggle={(id) => setTools((arr) => arr.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x))} onClose={() => setOverlay(null)} />}
|
||
|
||
{/* tweaks */}
|
||
<TweaksPanel>
|
||
<TweakSection label="Aesthetic direction" />
|
||
<TweakRadio label="Chrome" value={t.direction} options={["boxed", "soft", "bare"]} onChange={(v) => setDirection(v)} />
|
||
<TweakColor label="Accent" value={t.accent === "cyan" ? "#56c8d8" : t.accent === "amber" ? "#e8b765" : t.accent === "green" ? "#8fd96a" : t.accent === "magenta" ? "#c98fd9" : "#b9c0c9"}
|
||
options={["#56c8d8", "#e8b765", "#8fd96a", "#c98fd9", "#b9c0c9"]}
|
||
onChange={(hex) => { const map = { "#56c8d8": "cyan", "#e8b765": "amber", "#8fd96a": "green", "#c98fd9": "magenta", "#b9c0c9": "neutral" }; setTweak("accent", map[hex] || "cyan"); }} />
|
||
<TweakSection label="Display" />
|
||
<TweakSlider label="Font size" value={t.fontSize} min={11} max={17} step={1} unit="px" onChange={(v) => setTweak("fontSize", v)} />
|
||
<TweakToggle label="Live motion" value={t.motion} onChange={(v) => setTweak("motion", v)} />
|
||
<TweakToggle label="CRT scanlines" value={t.scanlines} onChange={(v) => setTweak("scanlines", v)} />
|
||
</TweaksPanel>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|