diff --git a/docs/visual/ref/app.jsx b/docs/visual/ref/app.jsx
new file mode 100644
index 00000000..cf485d34
--- /dev/null
+++ b/docs/visual/ref/app.jsx
@@ -0,0 +1,317 @@
+// 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 (
+
+
+ {n && {n} }
+ {title}
+ {right && {right} }
+
+
{children}
+ {footer}
+
+ );
+}
+
+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 (
+
+ {t.scanlines &&
}
+
+ {/* top status line */}
+
+
correx
+
·
+
session {D.SESSION.name}
+
·
+
{D.SESSION.branch}
+
·
+
{activeModel} ({D.MODELS.find((m) => m.id === activeModel).provider})
+ {autoApprove &&
auto-approve }
+
+
ctx
{tokPct}%
+
+ {sessionState === "awaiting_approval" ? "⏸ awaiting approval" : "▸ active"}
+
+
+
+ {/* main split */}
+
+ }>
+ {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 (
+
+ {who} {m.t}
+ {text}{streaming && streamLen < m.text.length && }{streaming && streamLen >= m.text.length && sessionState === "awaiting_approval" && }
+
+ );
+ })}
+
+
+
{spin} paused : {spin} live }>
+
+ {events.map((e) => (
+
setOverlay("events")}>
+ {e.t}
+ {e.cat}
+ {e.running && {spin} }{e.type} {e.text}{e.pending && a }
+
+ ))}
+
+
+
+
+ {/* footer keybind bar */}
+
+ {hints.map(([k, l], i) => (
+ {
+ 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]();
+ }}>
+ {k} {l}
+
+ ))}
+ {THEMES[t.direction].label} · {t.accent}
+
+
+ {toast && {toast}
}
+
+ {/* overlays */}
+ {overlay === "cmd" && { setOverlay(null); setTimeout(() => open(id), 0); }} onClose={() => setOverlay(null)} />}
+ {overlay === "approval" && resolveApproval(auto ? "auto" : "approve")} onReject={() => resolveApproval("reject")} onSteer={(n) => resolveApproval("steer", n)} onClose={() => setOverlay(null)} />}
+ {overlay === "events" && setOverlay(null)} />}
+ {overlay === "context" && setOverlay(null)} />}
+ {overlay === "graph" && setOverlay(null)} />}
+ {overlay === "models" && { 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" && setTools((arr) => arr.map((x) => x.id === id ? { ...x, enabled: !x.enabled } : x))} onClose={() => setOverlay(null)} />}
+
+ {/* tweaks */}
+
+
+ setDirection(v)} />
+ { const map = { "#56c8d8": "cyan", "#e8b765": "amber", "#8fd96a": "green", "#c98fd9": "magenta", "#b9c0c9": "neutral" }; setTweak("accent", map[hex] || "cyan"); }} />
+
+ setTweak("fontSize", v)} />
+ setTweak("motion", v)} />
+ setTweak("scanlines", v)} />
+
+
+ );
+}
+
+ReactDOM.createRoot(document.getElementById("root")).render( );
diff --git a/docs/visual/ref/correx.css b/docs/visual/ref/correx.css
new file mode 100644
index 00000000..743d556b
--- /dev/null
+++ b/docs/visual/ref/correx.css
@@ -0,0 +1,239 @@
+/* correx.css — Correx TUI. Chrome modes driven by [data-chrome] on the root. */
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+.crx {
+ position: absolute; inset: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font-family: var(--font);
+ font-size: 13px;
+ line-height: var(--line);
+ font-variant-ligatures: none;
+ -webkit-font-smoothing: antialiased;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ overflow: hidden;
+ user-select: none;
+}
+.crx ::selection { background: var(--accent); color: var(--bg-deep); }
+
+/* faint global texture for warmth (subtle, not slop) */
+.crx::after {
+ content: ""; position: absolute; inset: 0; pointer-events: none;
+ background: radial-gradient(120% 90% at 50% -10%, rgb(var(--glow) / 0.05), transparent 60%);
+ z-index: 0;
+}
+
+/* ───────────────────────── top status line ───────────────────────── */
+.crx-top {
+ position: relative; z-index: 2;
+ display: flex; align-items: center; gap: 16px;
+ padding: 8px 14px;
+ background: var(--bg-deep);
+ border-bottom: 1px solid var(--border);
+ font-size: 12px;
+}
+.crx[data-chrome="soft"] .crx-top { background: var(--panel); border-bottom: none; }
+.crx[data-chrome="bare"] .crx-top { background: transparent; border-bottom: 1px solid var(--border); }
+.crx-brand { color: var(--accent); font-weight: 700; letter-spacing: 0.06em; }
+.crx-brand b { color: var(--fg-strong); }
+.crx-top .dot { color: var(--faint); }
+.crx-top .seg { color: var(--dim); white-space: nowrap; }
+.crx-top .seg b { color: var(--fg); font-weight: 500; }
+.crx-spacer { flex: 1; }
+.crx-state {
+ padding: 1px 8px; border-radius: 999px; font-size: 11px; letter-spacing: 0.04em;
+ color: var(--warn); border: 1px solid color-mix(in oklab, var(--warn), transparent 60%);
+ background: color-mix(in oklab, var(--warn), transparent 88%);
+}
+.crx-budget { display: flex; align-items: center; gap: 7px; color: var(--dim); }
+.crx-meter { width: 84px; height: 6px; border-radius: 3px; background: var(--faint); overflow: hidden; }
+.crx-meter > i { display: block; height: 100%; background: var(--accent); }
+
+/* ───────────────────────── main grid ───────────────────────── */
+.crx-main {
+ position: relative; z-index: 1;
+ display: grid; grid-template-columns: 1.7fr 1fr;
+ gap: var(--gap); padding: var(--gap);
+ min-height: 0; overflow: hidden;
+}
+
+/* ───────────────────────── panel chrome ───────────────────────── */
+.panel {
+ position: relative; display: flex; flex-direction: column; min-height: 0;
+ background: var(--panel); border-radius: var(--radius);
+}
+.panel-h {
+ display: flex; align-items: center; gap: 8px;
+ padding: 5px 11px; flex: 0 0 auto;
+ color: var(--dim); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
+}
+.panel-h .pn { color: var(--faint); }
+.panel-h .pt { color: var(--fg); letter-spacing: 0.08em; }
+.panel-h .ph-r { margin-left: auto; text-transform: none; letter-spacing: 0; color: var(--faint); }
+.panel-b { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 4px 11px 10px; }
+.panel-b::-webkit-scrollbar { width: 7px; }
+.panel-b::-webkit-scrollbar-thumb { background: var(--faint); border-radius: 4px; }
+.panel-b::-webkit-scrollbar-track { background: transparent; }
+
+/* boxed: full border, active = accent border, lazygit label tab */
+.crx[data-chrome="boxed"] .panel { border: 1px solid var(--border); }
+.crx[data-chrome="boxed"] .panel.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent), transparent 70%); }
+.crx[data-chrome="boxed"] .panel-h { border-bottom: 1px solid var(--border); background: var(--bg-deep); }
+.crx[data-chrome="boxed"] .panel.active .panel-h { color: var(--accent); }
+
+/* soft: rounded, padded, lighter panel, no hard header rule */
+.crx[data-chrome="soft"] .panel { border: 1px solid var(--border); padding: var(--panel-pad); }
+.crx[data-chrome="soft"] .panel.active { border-color: color-mix(in oklab, var(--accent), transparent 35%); box-shadow: 0 0 0 1px color-mix(in oklab,var(--accent),transparent 55%), 0 6px 28px -18px rgb(var(--glow)/0.6); }
+.crx[data-chrome="soft"] .panel-h { padding: 8px 12px 4px; }
+.crx[data-chrome="soft"] .panel.active .panel-h .pt { color: var(--accent); }
+.crx[data-chrome="soft"] .panel-b { padding: 2px 12px 12px; }
+
+/* bare: no box; just a header with a thin bottom rule */
+.crx[data-chrome="bare"] .panel { background: transparent; }
+.crx[data-chrome="bare"] .panel-h { border-bottom: 1px solid var(--border); padding-left: 2px; padding-right: 2px; }
+.crx[data-chrome="bare"] .panel.active .panel-h { color: var(--accent); }
+.crx[data-chrome="bare"] .panel.active .panel-h::before { content: "▌"; color: var(--accent); margin-right: -3px; }
+.crx[data-chrome="bare"] .panel-b { padding-left: 2px; padding-right: 6px; }
+
+/* ───────────────────────── conversation ───────────────────────── */
+.msg { padding: 7px 0; display: grid; grid-template-columns: 64px 1fr; gap: 10px; align-items: start; }
+.msg + .msg { border-top: 1px solid color-mix(in oklab, var(--border), transparent 30%); }
+.crx[data-chrome="bare"] .msg + .msg { border-top: none; }
+.msg-gut { font-size: 11px; color: var(--faint); text-align: right; padding-top: 1px; }
+.msg-gut .who { display: block; font-weight: 600; letter-spacing: 0.02em; }
+.msg.user .who { color: var(--fg); }
+.msg.assistant .who { color: var(--accent); }
+.msg.kernel .who { color: var(--accent-2); }
+.msg-body { color: var(--fg); text-wrap: pretty; }
+.msg.kernel .msg-body { color: var(--dim); font-size: 12px; }
+.msg.kernel .msg-body::before { content: "» "; color: var(--accent-2); }
+.msg .cursor { display: inline-block; width: 7px; height: 1.05em; vertical-align: text-bottom; background: var(--accent); margin-left: 2px; animation: blink 1.05s steps(1) infinite; }
+@keyframes blink { 50% { opacity: 0; } }
+
+/* ───────────────────────── event stream ───────────────────────── */
+.ev { display: grid; grid-template-columns: auto auto 1fr; gap: 8px; align-items: baseline; padding: 2.5px 0; font-size: 12px; cursor: pointer; border-radius: 4px; }
+.ev:hover { background: var(--sel); }
+.ev.sel { background: var(--sel); box-shadow: inset 2px 0 0 var(--accent); }
+.ev-t { color: var(--faint); font-size: 11px; }
+.ev-cat { font-size: 10px; letter-spacing: 0.03em; padding: 0 5px; border-radius: 3px; white-space: nowrap; }
+.ev-tx { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ev-tx b { color: var(--dim); font-weight: 400; }
+.ev.pending .ev-tx { color: var(--warn); }
+.cat-Lifecycle { color: var(--accent-2); background: color-mix(in oklab,var(--accent-2),transparent 86%); }
+.cat-Context { color: #c98fd9; background: color-mix(in oklab,#c98fd9,transparent 86%); }
+.cat-Inference { color: var(--accent); background: color-mix(in oklab,var(--accent),transparent 86%); }
+.cat-Tool { color: var(--ok); background: color-mix(in oklab,var(--ok),transparent 86%); }
+.cat-Domain { color: #e8b765; background: color-mix(in oklab,#e8b765,transparent 86%); }
+.cat-Approval { color: var(--warn); background: color-mix(in oklab,var(--warn),transparent 84%); }
+.spin { display: inline-block; color: var(--accent); }
+
+/* ───────────────────────── footer keybind bar ───────────────────────── */
+.crx-foot {
+ position: relative; z-index: 2;
+ display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
+ padding: 6px 12px; background: var(--bg-deep);
+ border-top: 1px solid var(--border); font-size: 12px;
+}
+.crx[data-chrome="soft"] .crx-foot { background: var(--panel); border-top: none; }
+.kb { display: inline-flex; align-items: baseline; gap: 5px; padding: 1px 8px; border-radius: 5px; color: var(--dim); }
+.kb:hover { background: var(--sel); color: var(--fg); }
+.kb kbd {
+ font: inherit; font-size: 11px; color: var(--bg-deep); background: var(--accent);
+ padding: 0 5px; border-radius: 3px; font-weight: 600; min-width: 14px; text-align: center;
+}
+.crx[data-chrome="bare"] .kb kbd { background: transparent; color: var(--accent); padding: 0; }
+.crx[data-chrome="bare"] .kb kbd::before { content: "["; } .crx[data-chrome="bare"] .kb kbd::after { content: "]"; }
+.kb b { color: var(--fg); font-weight: 400; }
+.foot-mode { margin-left: auto; color: var(--faint); letter-spacing: 0.08em; }
+.foot-mode b { color: var(--accent); }
+
+/* ───────────────────────── overlays ───────────────────────── */
+.scrim { position: absolute; inset: 0; z-index: 40; background: rgb(0 0 0 / 0.55); backdrop-filter: blur(1.5px); display: flex; align-items: flex-start; justify-content: center; padding: 7vh 16px 16px; }
+.scrim.center { align-items: center; padding-top: 16px; }
+.ovl {
+ position: relative; width: min(760px, 94%); max-height: 82vh; display: flex; flex-direction: column;
+ background: var(--panel); color: var(--fg);
+ border: 1px solid var(--border); border-radius: var(--radius);
+ box-shadow: 0 24px 80px -28px #000, 0 0 0 1px color-mix(in oklab,var(--accent),transparent 80%);
+ animation: pop 0.14s cubic-bezier(.2,.9,.3,1);
+}
+.crx[data-chrome="soft"] .ovl { border-radius: 14px; }
+@keyframes pop { from { transform: translateY(-8px) scale(0.99); } }
+.ovl-h { display: flex; align-items: center; gap: 10px; padding: 11px 15px; border-bottom: 1px solid var(--border); }
+.ovl-h .tag { color: var(--accent); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; font-size: 11px; }
+.ovl-h .ti { color: var(--fg-strong); }
+.ovl-h .esc { margin-left: auto; color: var(--faint); font-size: 11px; }
+.ovl-b { padding: 13px 15px; overflow: auto; }
+.ovl-b::-webkit-scrollbar { width: 8px; } .ovl-b::-webkit-scrollbar-thumb { background: var(--faint); border-radius: 4px; }
+
+/* command palette */
+.cmd-input { display: flex; align-items: center; gap: 9px; padding: 12px 15px; border-bottom: 1px solid var(--border); }
+.cmd-input .pr { color: var(--accent); font-weight: 700; }
+.cmd-input input { flex: 1; background: transparent; border: none; outline: none; color: var(--fg-strong); font: inherit; font-size: 14px; }
+.cmd-input input::placeholder { color: var(--faint); }
+.cmd-list { padding: 6px; overflow: auto; max-height: 50vh; }
+.cmd-it { display: flex; align-items: center; gap: 11px; padding: 8px 11px; border-radius: 7px; cursor: pointer; }
+.cmd-it.sel { background: var(--sel); }
+.cmd-it.sel .ci-l { color: var(--fg-strong); }
+.crx[data-chrome="boxed"] .cmd-it { border-radius: 0; } .crx[data-chrome="boxed"] .cmd-it.sel { box-shadow: inset 2px 0 0 var(--accent); }
+.ci-k { font-size: 11px; color: var(--bg-deep); background: var(--accent); padding: 1px 6px; border-radius: 3px; min-width: 22px; text-align: center; font-weight: 600; }
+.ci-l { flex: 1; color: var(--fg); }
+.ci-h { color: var(--dim); font-size: 11px; }
+
+/* approval modal */
+.appr { width: min(620px, 94%); }
+.appr-tier { display: flex; align-items: center; gap: 12px; padding: 13px 16px; background: color-mix(in oklab,var(--warn),transparent 90%); border-bottom: 1px solid color-mix(in oklab,var(--warn),transparent 70%); }
+.appr-badge { font-size: 22px; font-weight: 800; color: var(--warn); letter-spacing: 0.04em; }
+.appr-tier .lab { color: var(--fg-strong); } .appr-tier .lab b { color: var(--warn); }
+.appr-tier .sub { color: var(--dim); font-size: 12px; }
+.appr-cmd { margin: 13px 0; padding: 11px 13px; background: var(--bg-deep); border-radius: 6px; border: 1px solid var(--border); color: var(--fg-strong); font-size: 13px; white-space: pre-wrap; word-break: break-all; }
+.appr-cmd .pr { color: var(--accent); }
+.kv { display: grid; grid-template-columns: 96px 1fr; gap: 4px 12px; font-size: 12.5px; margin: 9px 0; }
+.kv dt { color: var(--dim); } .kv dd { color: var(--fg); }
+.risk { color: var(--warn); font-size: 12.5px; padding: 1.5px 0; }
+.risk::before { content: "▲ "; font-size: 10px; }
+.steer { margin-top: 12px; display: flex; gap: 8px; align-items: center; }
+.steer input { flex: 1; background: var(--bg-deep); border: 1px solid var(--border); border-radius: 6px; padding: 8px 11px; color: var(--fg-strong); font: inherit; font-size: 12.5px; outline: none; }
+.steer input:focus { border-color: var(--accent); }
+.appr-acts { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); flex-wrap: wrap; }
+.btn { font: inherit; font-size: 12.5px; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel-2); color: var(--fg); cursor: pointer; display: inline-flex; gap: 7px; align-items: center; }
+.btn:hover { border-color: var(--accent); color: var(--fg-strong); }
+.btn kbd { font: inherit; font-size: 10px; color: var(--dim); }
+.btn.ok { background: color-mix(in oklab,var(--ok),transparent 84%); border-color: color-mix(in oklab,var(--ok),transparent 55%); color: var(--ok); }
+.btn.ok:hover { background: color-mix(in oklab,var(--ok),transparent 72%); }
+.btn.bad { color: var(--bad); border-color: color-mix(in oklab,var(--bad),transparent 60%); }
+.btn.ghost { background: transparent; }
+.btn .sp { margin-left: auto; }
+
+/* generic rows used in inspectors */
+.row { display: grid; gap: 10px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid color-mix(in oklab,var(--border),transparent 40%); }
+.row:last-child { border-bottom: none; }
+.mono-bar { height: 7px; border-radius: 3px; background: var(--faint); overflow: hidden; }
+.mono-bar > i { display: block; height: 100%; background: var(--accent); }
+.tag-tier { font-size: 10px; padding: 0 5px; border-radius: 3px; font-weight: 600; }
+.tier-T0,.tier-T1 { color: var(--ok); background: color-mix(in oklab,var(--ok),transparent 85%); }
+.tier-T2 { color: var(--accent-2); background: color-mix(in oklab,var(--accent-2),transparent 85%); }
+.tier-T3 { color: var(--warn); background: color-mix(in oklab,var(--warn),transparent 84%); }
+.tier-T4 { color: var(--bad); background: color-mix(in oklab,var(--bad),transparent 84%); }
+.pill { font-size: 11px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--dim); }
+.pill.on { color: var(--ok); border-color: color-mix(in oklab,var(--ok),transparent 55%); }
+.pill.off { color: var(--faint); }
+.pill.live { color: var(--accent); border-color: color-mix(in oklab,var(--accent),transparent 50%); }
+
+/* ───────────────────────── router prompt bar ───────────────────────── */
+.prompt { display: flex; align-items: center; gap: 9px; flex: 0 0 auto; padding: 8px 11px; border-top: 1px solid var(--border); }
+.crx[data-chrome="bare"] .prompt { padding-left: 2px; padding-right: 6px; }
+.crx[data-chrome="soft"] .prompt { margin: 2px; border-top: 1px solid var(--border); }
+.prompt .pr { color: var(--accent); font-weight: 700; }
+.prompt.foc { box-shadow: inset 0 1px 0 var(--accent); }
+.crx[data-chrome="soft"] .prompt.foc { box-shadow: inset 0 0 0 1px color-mix(in oklab,var(--accent),transparent 55%); border-radius: 8px; border-top-color: transparent; }
+.prompt input { flex: 1; background: transparent; border: none; outline: none; color: var(--fg-strong); font: inherit; font-size: 13px; }
+.prompt input::placeholder { color: var(--faint); }
+.prompt .ph { color: var(--faint); font-size: 11px; white-space: nowrap; }
+.prompt .ph kbd { font: inherit; font-size: 10px; color: var(--accent); }
+
+.hint-empty { color: var(--faint); font-size: 12px; padding: 14px 4px; text-align: center; }
+.kbd-inline { font-size: 11px; color: var(--bg-deep); background: var(--accent); padding: 0 5px; border-radius: 3px; font-weight: 600; }
+.crx[data-chrome="bare"] .kbd-inline { background: transparent; color: var(--accent); padding: 0; }
diff --git a/docs/visual/ref/correx.html b/docs/visual/ref/correx.html
new file mode 100644
index 00000000..d5df0d65
--- /dev/null
+++ b/docs/visual/ref/correx.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+correx — agent harness TUI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/visual/ref/data.jsx b/docs/visual/ref/data.jsx
new file mode 100644
index 00000000..42c88cce
--- /dev/null
+++ b/docs/visual/ref/data.jsx
@@ -0,0 +1,114 @@
+// data.jsx — Correx scenario fixture
+// Session: implementing CAS-based storage for segments (Kafka-style) of domain data.
+// The kernel ("Correx") is the orchestration harness; the model is a stateless worker.
+
+const SESSION = {
+ id: "01J9-CAS-SEG-7F2A",
+ name: "cas-segment-storage",
+ project: "correx/segment-store",
+ branch: "feat/cas-segments",
+ state: "awaiting_approval",
+ started: "14:02:11",
+ model: "qwen_coder_14b",
+ provider: "local",
+};
+
+// Stage / transition graph. current = implementation.
+const STAGES = [
+ { id: "intake", role: "router", status: "done", enter: "14:02", note: "parsed request → plan" },
+ { id: "planning", role: "architect", status: "done", enter: "14:04", note: "segment layout + CAS index" },
+ { id: "implementation", role: "coder", status: "active", enter: "14:11", note: "writing SegmentStore.kt" },
+ { id: "validation", role: "reviewer", status: "pending", enter: "—", note: "schema + semantic checks" },
+ { id: "integration", role: "coder", status: "pending", enter: "—", note: "wire into DomainLog" },
+ { id: "done", role: "—", status: "pending", enter: "—", note: "" },
+];
+
+const TRANSITIONS = [
+ { when: 'artifact.status == "success"', goto: "validation" },
+ { when: "retries > 3", goto: "failed" },
+ { when: 'semantic.contradiction', goto: "retry" },
+];
+
+// Router conversation. The last assistant turn is mid-stream.
+const CONVERSATION = [
+ { role: "user", t: "14:02", text: "Implement CAS-based storage for segments, Kafka-style. Domain data lands as an append-only log; segments should be content-addressed so dedup is free." },
+ { role: "kernel", t: "14:02", kind: "route", text: "Routed → planning (role: architect). Synthesized Context Pack [4 sources, 6.1k tok]." },
+ { role: "assistant", t: "14:05", text: "Plan: each segment is an immutable, size-bounded run of records keyed by SHA-256 of its bytes. A SegmentIndex maps logical offset ranges → CAS keys. Compaction rewrites overlapping segments; identical segments collapse to one object. Writing SegmentStore.kt, CasKey.kt, SegmentIndex.kt." },
+ { role: "kernel", t: "14:11", kind: "route", text: "Transition planning → implementation (role: coder). Tools: filesystem, git, shell." },
+ { role: "assistant", t: "14:12", streaming: true, text: "SegmentStore.append() now hashes the framed buffer with a streaming SHA-256 and writes .seg under .correx/cas/. To pull zstd compression for cold segments I need com.github.luben:zstd-jni — resolving it touches the network, so I've raised a T3 approval before running gradle" },
+];
+
+// Live event stream (newest at bottom). category drives color + filter.
+const EVENTS = [
+ { id: "e41", t: "14:11:02", cat: "Lifecycle", type: "stage.enter", text: "implementation (role=coder)" },
+ { id: "e42", t: "14:11:02", cat: "Context", type: "pack.synthesized", text: "4 sources · 6,120 tok · dedup −38%" },
+ { id: "e43", t: "14:11:03", cat: "Inference", type: "infer.start", text: "qwen_coder_14b · t=0.2 · ctx 6.1k/32k" },
+ { id: "e44", t: "14:11:19", cat: "Tool", type: "fs.write", text: "SegmentStore.kt (+212)" },
+ { id: "e45", t: "14:11:20", cat: "Tool", type: "fs.write", text: "CasKey.kt (+47)" },
+ { id: "e46", t: "14:11:24", cat: "Domain", type: "artifact.partial", text: "ImplementationArtifact draft" },
+ { id: "e47", t: "14:11:31", cat: "Tool", type: "shell.exec", text: "./gradlew compileKotlin → OK (3.2s)" },
+ { id: "e48", t: "14:11:48", cat: "Inference", type: "infer.token", text: "streaming … 1,204 tok" },
+ { id: "e49", t: "14:12:02", cat: "Approval", type: "approval.raised", text: "T3 · gradle network resolve", pending: true },
+];
+
+// Pending approval (modal interrupt).
+const APPROVAL = {
+ tier: "T3",
+ tierLabel: "external / network",
+ tool: "shell",
+ intent: "Resolve a new dependency over the network",
+ command: "./gradlew :segment-store:dependencies --refresh-dependencies",
+ detail: "Adds com.github.luben:zstd-jni:1.5.6-3 for cold-segment compression. Touches Maven Central (network egress).",
+ reversible: false,
+ policy: "stages.implementation.approvals.tool_tier_3 = required",
+ risks: [
+ "Network egress to repo1.maven.org",
+ "Mutates gradle.lockfile",
+ "New transitive: org.lz4? (none detected)",
+ ],
+};
+
+// Context Pack — what the model actually receives. Layered L0..L4.
+const CONTEXT_PACK = {
+ budget: 32768,
+ used: 6120,
+ layers: [
+ { id: "L0", name: "live execution", tok: 1840, mode: "verbatim", items: ["current diff: SegmentStore.kt", "last tool result: compileKotlin OK"] },
+ { id: "L1", name: "stage-local", tok: 1310, mode: "verbatim", items: ["stage=implementation", "role=coder card", "allowed tools (3)"] },
+ { id: "L2", name: "compressed session", tok: 1490, mode: "semantic_summary", items: ["plan summary", "CasKey design decision", "rejected: per-record hashing"] },
+ { id: "L3", name: "project memory", tok: 980, mode: "ranked", items: ["DomainLog.kt interface", "build conventions", "ADR-014 immutability"] },
+ { id: "L4", name: "archival", tok: 500, mode: "latest_only", items: ["prior CAS spike (summary)"] },
+ ],
+ dropped: ["12 stale tool logs (summarized)", "router chit-chat (excluded)", "duplicate file reads (deduped)"],
+};
+
+// Model registry.
+const MODELS = [
+ { id: "qwen_coder_14b", provider: "local", active: true, caps: ["coding", "tool_calling", "reasoning"], ctx: 32768, temp: 0.2, gpu: "48 layers · q8_0 kv", status: "warm", parallel: "1/2" },
+ { id: "qwen_coder_32b", provider: "local", active: false, caps: ["coding", "tool_calling", "reasoning"], ctx: 32768, temp: 0.2, gpu: "unloaded", status: "cold", parallel: "0/1" },
+ { id: "gpt-oss-120b", provider: "remote", active: false, caps: ["coding", "reasoning"], ctx: 131072, temp: 0.3, gpu: "—", status: "ready", parallel: "0/8" },
+ { id: "claude-sonnet", provider: "remote", active: false, caps: ["coding", "tool_calling", "reasoning"], ctx: 200000, temp: 0.2, gpu: "—", status: "ready", parallel: "0/8" },
+];
+
+// Tool palette.
+const TOOLS = [
+ { id: "filesystem", tier: "T2", enabled: true, mode: "auto", desc: "read/write workspace files" },
+ { id: "git", tier: "T2", enabled: true, mode: "auto", desc: "stage, commit, branch" },
+ { id: "shell", tier: "T2", enabled: true, mode: "prompt", desc: "run bounded commands" },
+ { id: "gradle", tier: "T3", enabled: true, mode: "prompt", desc: "build · may resolve deps (network)" },
+ { id: "curl", tier: "T3", enabled: false, mode: "deny", desc: "raw network egress" },
+ { id: "rm", tier: "T4", enabled: false, mode: "deny", desc: "destructive delete" },
+];
+
+// Command palette entries — id maps to an overlay/action in app.jsx.
+const COMMANDS = [
+ { id: "events", label: "Inspect event stream", hint: "filter · drill payload", key: "e" },
+ { id: "context", label: "Inspect Context Pack", hint: "what the model sees", key: "c" },
+ { id: "graph", label: "Stage / transition graph", hint: "workflow DAG", key: "g" },
+ { id: "models", label: "Model registry", hint: "switch provider/model", key: "m" },
+ { id: "tools", label: "Tool palette", hint: "enable · tiers · approvals", key: "t" },
+ { id: "approval", label: "Review pending approval", hint: "T3 gate", key: "a" },
+ { id: "appearance", label: "Cycle aesthetic direction", hint: "Boxed · Soft · Bare", key: "F2" },
+];
+
+window.CORREX = { SESSION, STAGES, TRANSITIONS, CONVERSATION, EVENTS, APPROVAL, CONTEXT_PACK, MODELS, TOOLS, COMMANDS };
diff --git a/docs/visual/ref/overlays.jsx b/docs/visual/ref/overlays.jsx
new file mode 100644
index 00000000..fc6b5168
--- /dev/null
+++ b/docs/visual/ref/overlays.jsx
@@ -0,0 +1,243 @@
+// overlays.jsx — Correx overlays: command palette, approval gate, and inspectors.
+const { useState, useEffect, useRef, useMemo } = React;
+
+/* ── generic overlay shell ── */
+function Overlay({ tag, title, esc = "esc", center, width, onClose, children }) {
+ return (
+ { if (e.target === e.currentTarget) onClose(); }}>
+
e.stopPropagation()}>
+
+ {tag}
+ {title}
+ {esc} to close
+
+
{children}
+
+
+ );
+}
+
+/* ── command palette ── */
+function CommandPalette({ commands, onRun, onClose }) {
+ const [q, setQ] = useState("");
+ const [i, setI] = useState(0);
+ const inp = useRef(null);
+ useEffect(() => { inp.current && inp.current.focus(); }, []);
+ const score = (c) => {
+ const s = (c.label + " " + c.hint).toLowerCase(), t = q.toLowerCase().trim();
+ if (!t) return 1;
+ if (s.includes(t)) return 2;
+ let qi = 0; for (const ch of s) if (ch === t[qi]) qi++;
+ return qi === t.length ? 1 : 0;
+ };
+ const list = useMemo(() => commands.map((c) => [c, score(c)]).filter(([, s]) => s > 0)
+ .sort((a, b) => b[1] - a[1]).map(([c]) => c), [q]);
+ useEffect(() => { setI(0); }, [q]);
+ const key = (e) => {
+ if (e.key === "ArrowDown") { setI((x) => Math.min(x + 1, list.length - 1)); e.preventDefault(); }
+ else if (e.key === "ArrowUp") { setI((x) => Math.max(x - 1, 0)); e.preventDefault(); }
+ else if (e.key === "Enter") { list[i] && onRun(list[i].id); e.preventDefault(); }
+ else if (e.key === "Escape") { onClose(); }
+ e.stopPropagation();
+ };
+ return (
+ { if (e.target === e.currentTarget) onClose(); }}>
+
e.stopPropagation()}>
+
+ ⌘
+ setQ(e.target.value)} onKeyDown={key} />
+
+
+ {list.length === 0 &&
no matches
}
+ {list.map((c, n) => (
+
setI(n)} onClick={() => onRun(c.id)}>
+ {c.key}
+ {c.label}
+ {c.hint}
+
+ ))}
+
+
+
+ );
+}
+
+/* ── approval gate (modal interrupt) ── */
+function ApprovalModal({ data, onApprove, onReject, onSteer, onClose }) {
+ const [steer, setSteer] = useState("");
+ const sref = useRef(null);
+ return (
+ e.stopPropagation()}>
+
e.stopPropagation()}>
+
+
{data.tier}
+
+
Approval required · {data.tierLabel}
+
{data.intent}
+
+
execution paused
+
+
+
$ {data.command}
+
{data.detail}
+
+ tool {data.tool} {data.tier}
+ reversible {data.reversible ? "yes" : "no"}
+ policy {data.policy}
+
+
semantic risk scan
+ {data.risks.map((r, n) =>
{r}
)}
+
+ ↳
+ setSteer(e.target.value)}
+ onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter" && steer.trim()) onSteer(steer.trim()); if (e.key === "Escape") sref.current.blur(); }} />
+
+
+
+ onApprove(false)}>approve a
+ onApprove(true)}>auto-approve session A
+ steer.trim() ? onSteer(steer.trim()) : sref.current.focus()}>steer s
+ reject r
+
+
+
+ );
+}
+
+/* ── event stream inspector ── */
+function EventInspector({ events, onClose }) {
+ const cats = ["All", "Lifecycle", "Context", "Inference", "Tool", "Domain", "Approval"];
+ const [cat, setCat] = useState("All");
+ const [sel, setSel] = useState(events[events.length - 1]);
+ const list = events.filter((e) => cat === "All" || e.cat === cat);
+ const payload = {
+ id: sel.id, type: sel.type, category: sel.cat, ts: "2026-05-29T" + sel.t,
+ session_id: window.CORREX.SESSION.id, correlation_id: "cor-7f2a", causation_id: sel.id === "e49" ? "e48" : null,
+ payload: { detail: sel.text },
+ };
+ return (
+
+
+ {cats.map((c) => setCat(c)}>{c} )}
+
+
+
+ {list.map((e) => (
+
setSel(e)}>
+ {e.t}
+ {e.cat}
+ {e.type} {e.text}
+
+ ))}
+
+
+
{JSON.stringify(payload, null, 2)}
+
+
+
+ );
+}
+
+/* ── context pack inspector ── */
+function ContextPackInspector({ pack, onClose }) {
+ const pct = Math.round((pack.used / pack.budget) * 100);
+ return (
+
+
+
{pack.used.toLocaleString()} / {pack.budget.toLocaleString()} tok
+
+
{pct}%
+
+ {pack.layers.map((l) => (
+
+ {l.id}
+ {l.name}{l.mode}
+ {l.items.join(" · ")}
+ {l.tok}t
+
+ ))}
+ excluded by synthesis
+ {pack.dropped.map((d, n) => − {d}
)}
+
+ );
+}
+
+/* ── stage / transition graph ── */
+function StageGraph({ stages, transitions, onClose }) {
+ const glyph = { done: "✔", active: "▸", pending: "·" };
+ return (
+
+
+
+ {stages.map((s, n) => (
+
+
+ {glyph[s.status]}
+ {s.id}
+ role:{s.role}
+ {s.status === "active" && running · {s.enter} }
+ {s.note}
+
+ {n < stages.length - 1 &&
│
}
+
+ ))}
+
+
+
transition rules
+
no hardcoded graph — rules eval per artifact
+ {transitions.map((t, n) => (
+
+ when {t.when}
+ → goto {t.goto}
+
+ ))}
+
+
+
+ );
+}
+
+/* ── model registry / provider switch ── */
+function ModelRegistry({ models, active, onSwitch, onClose }) {
+ return (
+
+ models are stateless engines — switching re-synthesizes the Context Pack for the new window.
+ {models.map((m) => (
+ m.id !== active && onSwitch(m.id)}>
+
+
+ {m.id}
+ {m.provider}
+ {m.id === active && active }
+ {m.status}
+
+
{m.caps.join(" · ")} — ctx {m.ctx.toLocaleString()} · t={m.temp} · {m.gpu}
+
+
+ sessions {m.parallel}{m.id !== active &&
switch →
}
+
+
+ ))}
+
+ );
+}
+
+/* ── tool palette ── */
+function ToolPalette({ tools, onToggle, onClose }) {
+ return (
+
+ config-driven · capability-scoped. tier sets the approval gate; disabled tools are invisible to the agent.
+ {tools.map((t) => (
+ onToggle(t.id)}>
+ {t.tier}
+ {t.id} — {t.desc}
+ {t.mode}
+ {t.enabled ? "enabled" : "disabled"}
+
+ ))}
+
+ );
+}
+
+Object.assign(window, { Overlay, CommandPalette, ApprovalModal, EventInspector, ContextPackInspector, StageGraph, ModelRegistry, ToolPalette });
diff --git a/docs/visual/ref/themes.jsx b/docs/visual/ref/themes.jsx
new file mode 100644
index 00000000..ee14df3c
--- /dev/null
+++ b/docs/visual/ref/themes.jsx
@@ -0,0 +1,114 @@
+// themes.jsx — three aesthetic directions for the Correx TUI.
+// Each theme is a set of CSS variables + a `chrome` mode that changes how
+// panels render. Palette accent is independently swappable.
+
+const ACCENTS = {
+ cyan: { accent: "#56c8d8", accent2: "#7aa2f7", glow: "86 200 216" },
+ amber: { accent: "#e8b765", accent2: "#d98a5b", glow: "232 183 101" },
+ green: { accent: "#8fd96a", accent2: "#5dd1a0", glow: "143 217 106" },
+ magenta: { accent: "#c98fd9", accent2: "#d98ab0", glow: "201 143 217" },
+ neutral: { accent: "#b9c0c9", accent2: "#8a929c", glow: "185 192 201" },
+};
+
+const THEMES = {
+ // 1. Boxed — lazygit / k9s. Sharp single-line borders, numbered panels,
+ // active panel highlighted, cool slate ground.
+ boxed: {
+ label: "Boxed",
+ blurb: "lazygit · k9s",
+ chrome: "boxed",
+ defaultAccent: "cyan",
+ vars: {
+ "--bg": "#0b0f14",
+ "--bg-deep": "#070a0e",
+ "--panel": "#0e141b",
+ "--panel-2": "#111922",
+ "--fg": "#c6d0da",
+ "--fg-strong": "#eef3f8",
+ "--dim": "#5b6873",
+ "--faint": "#36424d",
+ "--border": "#1d2935",
+ "--border-active": "var(--accent)",
+ "--sel": "#16222e",
+ "--radius": "0px",
+ "--panel-pad": "0px",
+ "--gap": "6px",
+ "--font": "'JetBrains Mono','SF Mono',ui-monospace,monospace",
+ "--line": "1.45",
+ "--ok": "#7fd88f",
+ "--warn": "#e8c06a",
+ "--bad": "#e87f7f",
+ },
+ },
+
+ // 2. Soft — Charm / Bubble Tea. Rounded panels, generous padding, gentle
+ // separators. Neutral charcoal ground so the look isn't tinted — warmth
+ // lives only in the (swappable) accent.
+ soft: {
+ label: "Soft",
+ blurb: "charm · bubbletea",
+ chrome: "soft",
+ defaultAccent: "amber",
+ vars: {
+ "--bg": "#14161a",
+ "--bg-deep": "#0f1115",
+ "--panel": "#181b21",
+ "--panel-2": "#1f232b",
+ "--fg": "#ced3da",
+ "--fg-strong": "#eef1f5",
+ "--dim": "#838b96",
+ "--faint": "#434a55",
+ "--border": "#262b33",
+ "--border-active": "var(--accent)",
+ "--sel": "#21262e",
+ "--radius": "10px",
+ "--panel-pad": "4px",
+ "--gap": "10px",
+ "--font": "'JetBrains Mono','SF Mono',ui-monospace,monospace",
+ "--line": "1.6",
+ "--ok": "#86d39a",
+ "--warn": "#e8b765",
+ "--bad": "#e3938a",
+ },
+ },
+
+ // 3. Bare — classic monochrome phosphor. Almost no chrome: thin rules,
+ // whitespace, green-on-black. Minimal, fast, text-forward.
+ bare: {
+ label: "Bare",
+ blurb: "classic phosphor",
+ chrome: "bare",
+ defaultAccent: "green",
+ vars: {
+ "--bg": "#080b08",
+ "--bg-deep": "#050705",
+ "--panel": "#080b08",
+ "--panel-2": "#0b100b",
+ "--fg": "#a7c99a",
+ "--fg-strong": "#e6f6df",
+ "--dim": "#5a7050",
+ "--faint": "#2f3d2b",
+ "--border": "#1a241799",
+ "--border-active": "var(--accent)",
+ "--sel": "#12200f",
+ "--radius": "0px",
+ "--panel-pad": "0px",
+ "--gap": "2px",
+ "--font": "'JetBrains Mono','SF Mono',ui-monospace,monospace",
+ "--line": "1.55",
+ "--ok": "#8fd96a",
+ "--warn": "#cbd96a",
+ "--bad": "#d98a8a",
+ },
+ },
+};
+
+function applyTheme(el, themeKey, accentKey) {
+ const th = THEMES[themeKey] || THEMES.boxed;
+ const acc = ACCENTS[accentKey] || ACCENTS[th.defaultAccent];
+ const vars = { ...th.vars, "--accent": acc.accent, "--accent-2": acc.accent2, "--glow": acc.glow };
+ for (const [k, v] of Object.entries(vars)) el.style.setProperty(k, v);
+ el.setAttribute("data-chrome", th.chrome);
+}
+
+window.CORREX_THEMES = { THEMES, ACCENTS, applyTheme };
diff --git a/docs/visual/ref/tweaks-panel.jsx b/docs/visual/ref/tweaks-panel.jsx
new file mode 100644
index 00000000..2377d304
--- /dev/null
+++ b/docs/visual/ref/tweaks-panel.jsx
@@ -0,0 +1,540 @@
+
+/* BEGIN USAGE */
+// tweaks-panel.jsx
+// Reusable Tweaks shell + form-control helpers.
+// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
+// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
+//
+// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
+// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
+// individual prototypes don't re-roll it. Ships a consistent set of controls so you
+// don't hand-draw , segmented radios, steppers, etc.
+//
+// Usage (in an HTML file that loads React + Babel):
+//
+// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+// "primaryColor": "#D97757",
+// "palette": ["#D97757", "#29261b", "#f6f4ef"],
+// "fontSize": 16,
+// "density": "regular",
+// "dark": false
+// }/*EDITMODE-END*/;
+//
+// function App() {
+// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+// return (
+//
+// Hello
+//
+//
+// setTweak('fontSize', v)} />
+// setTweak('density', v)} />
+//
+// setTweak('primaryColor', v)} />
+// setTweak('palette', v)} />
+// setTweak('dark', v)} />
+//
+//
+// );
+// }
+//
+// TweakRadio is the segmented control for 2–3 short options (auto-falls-back to
+// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
+// options are many or long. For color tweaks always curate 3-4 options rather than
+// a free picker; an option can also be a whole 2–5 color palette (the stored value
+// is the array). The Tweak* controls are a floor, not a ceiling — build custom
+// controls inside the panel if a tweak calls for UI they don't cover.
+/* END USAGE */
+// ─────────────────────────────────────────────────────────────────────────────
+
+const __TWEAKS_STYLE = `
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
+ transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
+ background:rgba(250,249,247,.78);color:#29261b;
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
+ overflow-y:auto;overflow-x:hidden;min-height:0;
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
+ .twk-body::-webkit-scrollbar{width:8px}
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
+ border:2px solid transparent;background-clip:content-box}
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
+ border:2px solid transparent;background-clip:content-box}
+ .twk-row{display:flex;flex-direction:column;gap:5px}
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
+ color:rgba(41,38,27,.72)}
+ .twk-lbl>span:first-child{font-weight:500}
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
+
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
+ color:rgba(41,38,27,.45);padding:10px 0 0}
+ .twk-sect:first-child{padding-top:0}
+
+ .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
+ select.twk-field{padding-right:22px;
+ background-image:url("data:image/svg+xml;utf8, ");
+ background-repeat:no-repeat;background-position:right 8px center}
+
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
+ width:14px;height:14px;border-radius:50%;background:#fff;
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
+ background:rgba(0,0,0,.06);user-select:none}
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
+ background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
+ border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
+ overflow-wrap:anywhere}
+
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
+ .twk-toggle[data-on="1"]{background:#34c759}
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
+
+ .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
+ user-select:none;padding-right:8px}
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
+ outline:none;color:inherit;-moz-appearance:textfield}
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
+ -webkit-appearance:none;margin:0}
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
+
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
+
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
+ background:transparent;flex-shrink:0}
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
+
+ .twk-chips{display:flex;gap:6px}
+ .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
+ padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
+ box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
+ transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
+ .twk-chip:hover{transform:translateY(-1px);
+ box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
+ .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
+ 0 2px 6px rgba(0,0,0,.15)}
+ .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
+ display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
+ .twk-chip>span>i:first-child{box-shadow:none}
+ .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
+ filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
+`;
+
+// ── useTweaks ───────────────────────────────────────────────────────────────
+// Single source of truth for tweak values. setTweak persists via the host
+// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
+function useTweaks(defaults) {
+ const [values, setValues] = React.useState(defaults);
+ // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
+ // useState-style call doesn't write a "[object Object]" key into the persisted
+ // JSON block.
+ const setTweak = React.useCallback((keyOrEdits, val) => {
+ const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
+ ? keyOrEdits : { [keyOrEdits]: val };
+ setValues((prev) => ({ ...prev, ...edits }));
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
+ // Same-window signal so in-page listeners (deck-stage rail thumbnails)
+ // can react — the parent message only reaches the host, not peers.
+ window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
+ }, []);
+ return [values, setTweak];
+}
+
+// ── TweaksPanel ─────────────────────────────────────────────────────────────
+// Floating shell. Registers the protocol listener BEFORE announcing
+// availability — if the announce ran first, the host's activate could land
+// before our handler exists and the toolbar toggle would silently no-op.
+// The close button posts __edit_mode_dismissed so the host's toolbar toggle
+// flips off in lockstep; the host echoes __deactivate_edit_mode back which
+// is what actually hides the panel.
+function TweaksPanel({ title = 'Tweaks', children }) {
+ const [open, setOpen] = React.useState(false);
+ const dragRef = React.useRef(null);
+ const offsetRef = React.useRef({ x: 16, y: 16 });
+ const PAD = 16;
+
+ const clampToViewport = React.useCallback(() => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const w = panel.offsetWidth, h = panel.offsetHeight;
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
+ offsetRef.current = {
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
+ };
+ panel.style.right = offsetRef.current.x + 'px';
+ panel.style.bottom = offsetRef.current.y + 'px';
+ }, []);
+
+ React.useEffect(() => {
+ if (!open) return;
+ clampToViewport();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', clampToViewport);
+ return () => window.removeEventListener('resize', clampToViewport);
+ }
+ const ro = new ResizeObserver(clampToViewport);
+ ro.observe(document.documentElement);
+ return () => ro.disconnect();
+ }, [open, clampToViewport]);
+
+ React.useEffect(() => {
+ const onMsg = (e) => {
+ const t = e?.data?.type;
+ if (t === '__activate_edit_mode') setOpen(true);
+ else if (t === '__deactivate_edit_mode') setOpen(false);
+ };
+ window.addEventListener('message', onMsg);
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
+ return () => window.removeEventListener('message', onMsg);
+ }, []);
+
+ const dismiss = () => {
+ setOpen(false);
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
+ };
+
+ const onDragStart = (e) => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const r = panel.getBoundingClientRect();
+ const sx = e.clientX, sy = e.clientY;
+ const startRight = window.innerWidth - r.right;
+ const startBottom = window.innerHeight - r.bottom;
+ const move = (ev) => {
+ offsetRef.current = {
+ x: startRight - (ev.clientX - sx),
+ y: startBottom - (ev.clientY - sy),
+ };
+ clampToViewport();
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ };
+
+ if (!open) return null;
+ return (
+ <>
+
+
+
+ {title}
+ e.stopPropagation()}
+ onClick={dismiss}>✕
+
+
+ {children}
+
+
+ >
+ );
+}
+
+// ── Layout helpers ──────────────────────────────────────────────────────────
+
+function TweakSection({ label, children }) {
+ return (
+ <>
+ {label}
+ {children}
+ >
+ );
+}
+
+function TweakRow({ label, value, children, inline = false }) {
+ return (
+
+
+ {label}
+ {value != null && {value} }
+
+ {children}
+
+ );
+}
+
+// ── Controls ────────────────────────────────────────────────────────────────
+
+function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
+ return (
+
+ onChange(Number(e.target.value))} />
+
+ );
+}
+
+function TweakToggle({ label, value, onChange }) {
+ return (
+
+
{label}
+
onChange(!value)}>
+
+ );
+}
+
+function TweakRadio({ label, value, options, onChange }) {
+ const trackRef = React.useRef(null);
+ const [dragging, setDragging] = React.useState(false);
+ // The active value is read by pointer-move handlers attached for the lifetime
+ // of a drag — ref it so a stale closure doesn't fire onChange for every move.
+ const valueRef = React.useRef(value);
+ valueRef.current = value;
+
+ // Segments wrap mid-word once per-segment width runs out. The track is
+ // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
+ // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
+ // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
+ // back to a dropdown rather than wrap.
+ const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
+ const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
+ const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
+ if (!fitsAsSegments) {
+ // emits strings — map back to the original option value so the
+ // fallback stays type-preserving (numbers, booleans) like the segment path.
+ const resolve = (s) => {
+ const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
+ return m === undefined ? s : typeof m === 'object' ? m.value : m;
+ };
+ return onChange(resolve(s))} />;
+ }
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
+ const n = opts.length;
+
+ const segAt = (clientX) => {
+ const r = trackRef.current.getBoundingClientRect();
+ const inner = r.width - 4;
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
+ };
+
+ const onPointerDown = (e) => {
+ setDragging(true);
+ const v0 = segAt(e.clientX);
+ if (v0 !== valueRef.current) onChange(v0);
+ const move = (ev) => {
+ if (!trackRef.current) return;
+ const v = segAt(ev.clientX);
+ if (v !== valueRef.current) onChange(v);
+ };
+ const up = () => {
+ setDragging(false);
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+
+ return (
+
+
+
+ {opts.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+ );
+}
+
+function TweakSelect({ label, value, options, onChange }) {
+ return (
+
+ onChange(e.target.value)}>
+ {options.map((o) => {
+ const v = typeof o === 'object' ? o.value : o;
+ const l = typeof o === 'object' ? o.label : o;
+ return {l} ;
+ })}
+
+
+ );
+}
+
+function TweakText({ label, value, placeholder, onChange }) {
+ return (
+
+ onChange(e.target.value)} />
+
+ );
+}
+
+function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
+ const clamp = (n) => {
+ if (min != null && n < min) return min;
+ if (max != null && n > max) return max;
+ return n;
+ };
+ const startRef = React.useRef({ x: 0, val: 0 });
+ const onScrubStart = (e) => {
+ e.preventDefault();
+ startRef.current = { x: e.clientX, val: value };
+ const decimals = (String(step).split('.')[1] || '').length;
+ const move = (ev) => {
+ const dx = ev.clientX - startRef.current.x;
+ const raw = startRef.current.val + dx * step;
+ const snapped = Math.round(raw / step) * step;
+ onChange(clamp(Number(snapped.toFixed(decimals))));
+ };
+ const up = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+ return (
+
+ {label}
+ onChange(clamp(Number(e.target.value)))} />
+ {unit && {unit} }
+
+ );
+}
+
+// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
+// read on both #111 and #fafafa without per-option configuration. Hex input
+// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
+function __twkIsLight(hex) {
+ const h = String(hex).replace('#', '');
+ const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
+ const n = parseInt(x.slice(0, 6), 16);
+ if (Number.isNaN(n)) return true;
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
+ return r * 299 + g * 587 + b * 114 > 148000;
+}
+
+const __TwkCheck = ({ light }) => (
+
+
+
+);
+
+// TweakColor — curated color/palette picker. Each option is either a single
+// hex string or an array of 1-5 hex strings; the card adapts — a lone color
+// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
+// rest stacked in a sharp column on the right. onChange emits the
+// option in the shape it was passed (string stays string, array stays array).
+// Without options it falls back to the native color input for back-compat.
+function TweakColor({ label, value, options, onChange }) {
+ if (!options || !options.length) {
+ return (
+
+
{label}
+
onChange(e.target.value)} />
+
+ );
+ }
+ // Native emits lowercase hex per the HTML spec, so
+ // compare case-insensitively. String() guards JSON.stringify(undefined),
+ // which returns the primitive undefined (no .toLowerCase).
+ const key = (o) => String(JSON.stringify(o)).toLowerCase();
+ const cur = key(value);
+ return (
+
+
+ {options.map((o, i) => {
+ const colors = Array.isArray(o) ? o : [o];
+ const [hero, ...rest] = colors;
+ const sup = rest.slice(0, 4);
+ const on = key(o) === cur;
+ return (
+ onChange(o)}>
+ {sup.length > 0 && (
+
+ {sup.map((c, j) => )}
+
+ )}
+ {on && <__TwkCheck light={__twkIsLight(hero)} />}
+
+ );
+ })}
+
+
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+ {label}
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});
diff --git a/docs/visual/tui-layout-plan.md b/docs/visual/tui-layout-plan.md
new file mode 100644
index 00000000..fb9d229d
--- /dev/null
+++ b/docs/visual/tui-layout-plan.md
@@ -0,0 +1,631 @@
+# TUI Layout Redesign — Soft Rounded + Blue Accent
+
+This document has two halves. The **top half** describes what the user sees and experiences — the visual language, the three screens, microfeedback. The **bottom half** is the build plan — phases, files, checklists, code patterns.
+
+---
+
+# DESIGN SPEC
+
+---
+
+## 1. Visual Language
+
+Everything shares one consistent look: a **soft rounded** dark theme with a **blue accent**.
+
+### Surfaces
+
+| Element | Looks like | Feels like |
+|---------|------------|------------|
+| Background | Deep charcoal `#14161a` | The ground — recedes |
+| Panels | Slightly lighter `#181b21`, rounded borders `╭──╮` | Cards floating on the ground |
+| Top bar | Darkest `#0f1115` | Anchors the screen |
+| Footer bar | Same as top bar | Anchors the bottom |
+| Active panel | Blue-tinted border glow | The thing you're working in |
+
+### Light — five levels of contrast
+
+```
+#eef1f5 — bright white → headings, important values
+#ced3da — light gray → body text, messages
+#838b96 — muted gray → labels, metadata, timestamps
+#434a55 — faint → borders, separators, placeholders
+#262b33 — darkest gray → subtle panel borders
+```
+
+### Accent — blue (#4a9eff)
+
+Used sparingly so it means something:
+- **Selected item** indicator (`▶`)
+- **Active panel** border highlight
+- **Interactive hints** (keybind labels)
+- **Brand mark** (the `x` in `correx`)
+- **Inference events** in the stream
+- **Prompt cursor** (`▌`)
+
+Secondary accent is a purple-blue (`#7aa2f7`) used for lifecycle events.
+
+---
+
+## 2. The Three Screens
+
+Every screen shares the same three-row grid. The middle row is a 1.7:1 horizontal split whose content changes by state. The input bar is always inside the left panel's footer.
+
+```
+┌──────────────────────────────────────────────────────┐
+│ status bar │ ← always visible
+├─────────────────────────────────┬─────────────────────┤
+│ left panel (1.7) │ right panel (1.0) │ ← changes by state
+│ │ │
+│ ──────────────────── │ │
+│ ❯ input bar (panel footer) │ │
+├─────────────────────────────────┴─────────────────────┤
+│ footer bar — keybind hints · mode │ ← always visible
+└──────────────────────────────────────────────────────┘
+```
+
+### IDLE — choosing what to do
+
+Left column shows the session list (+ workflow picker if no sessions). Right column is a welcome panel.
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ ● connected │ localhost:8080 │ qwen_coder_14b (local) │
+├──────────────────────────┬───────────────────────────────────┤
+│ sessions │ Correx Agent Harness │
+│ │ │
+│ ▶ [01J9CA] ACTIVE │ Connected · 3 sessions │
+│ cas-segment-store │ ───────────────────── │
+│ [02KF8B] COMPLETED │ Select a session from the left │
+│ approval-flow │ or type a name to start a new │
+│ [03LD7C] FAILED │ one. │
+│ refactor-router │ │
+│ │ recent: │
+│ ────────────────────── │ cas-segment-store 14:02 ▸ │
+│ ▌ Session name… │ approval-flow 13:45 ✔ │
+│ tab filter ctrl+n new │ refactor-router 12:30 ✘ │
+└──────────────────────────┴───────────────────────────────────┘
+│ ⌨i message ⌨p commands ⌨a approval ⌨e events ⌨F2 skin │ Soft · blue
+└──────────────────────────────────────────────────────────────────────┘
+```
+
+### IN_SESSION — interacting with an agent
+
+Left column is the conversation (messages from user, agent, kernel). Right column is the live event stream. Status bar expands to show session name, branch, model, context meter, state badge.
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ ● connected · cas-segment-store · feat/cas-segments │
+│ qwen_coder_14b (local) ctx ████████░░ 64% ▸ active │
+├──────────────────────────┬───────────────────────────────────┤
+│ router · conversation │ event stream ◌ live │
+│ │ │
+│ you 14:02 │ 14:11:02 [Lifecycle] stage.enter │
+│ Implement CAS storage… │ 14:11:02 [Context] pack.synth │
+│ │ 14:11:03 [Inference] infer.start │
+│ correx 14:02 │ 14:11:19 [Tool] fs.write │
+│ Routed → planning… │ 14:11:20 [Tool] fs.write │
+│ │ 14:11:24 [Domain] artifact │
+│ agent 14:05 │ 14:11:31 [Tool] shell.exec │
+│ Plan: each segment is… │ 14:11:48 [Inference] infer.token │
+│ │ 14:12:02 [Approval] approval │
+│ correx 14:11 │ raised T3 network │
+│ Transition planning→impl │ │
+│ │ │
+│ agent 14:12 │ │
+│ SegmentStore.append()… │ │
+│ │ │
+│ ─────────────────────── │ │
+│ ▌ Ask anything… │ │
+│ feat/cas-segments · chat │ │
+└──────────────────────────┴───────────────────────────────────┘
+│ ⌨i message ⌨p commands ⌨a approval ⌨e events ⌨Tab panels ⌨F2 skin │ Soft · blue
+└────────────────────────────────────────────────────────────────────────────────────┘
+```
+
+### APPROVAL — gate interrupted
+
+The horizontal split collapses. The full main area shows the approval surface with tier badge, command preview, risk scan, steering input, and decision buttons.
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ ● connected · cas-segment-store · feat/cas-segments │
+│ qwen_coder_14b (local) ctx ████████░░ 64% ⏸ awaiting│
+├──────────────────────────────────────────────────────────────┤
+│ ┌─ approval ────────────────────────────────────────────┐ │
+│ │ T3 Approval required · external / network │ │
+│ │ Resolve a new dependency over the network │ │
+│ │ paused │ │
+│ │ │ │
+│ │ $ ./gradlew :segment-store:dependencies │ │
+│ │ │ │
+│ │ Adds com.github.luben:zstd-jni:1.5.6-3 for cold- │ │
+│ │ segment compression. Touches Maven Central. │ │
+│ │ │ │
+│ │ tool shell T3 │ │
+│ │ reversible no │ │
+│ │ policy stages.implementation.approvals.tool=req │ │
+│ │ │ │
+│ │ SEMANTIC RISK SCAN │ │
+│ │ ▲ Network egress to repo1.maven.org │ │
+│ │ ▲ Mutates gradle.lockfile │ │
+│ │ ▲ New transitive: org.lz4? (none detected) │ │
+│ │ │ │
+│ │ ↳ ok, but lock the version_________________ │ │
+│ │ │ │
+│ │ [approve a] [auto-approve A] [steer s] [reject r] │ │
+│ └───────────────────────────────────────────────────────┘ │
+├──────────────────────────────────────────────────────────────┤
+│ ⌨a approve ⌨A auto-session ⌨s steer ⌨r reject ⌨esc later │ Soft · blue
+└──────────────────────────────────────────────────────────────────────────┘
+```
+
+### Footer bar — contextual keybinds
+
+| Screen | Hints shown |
+|--------|-------------|
+| IDLE | `i` message · `p` commands · `a` approval · `e` events · `F2` skin |
+| IN_SESSION | above + `Tab` panels · `↑↓` history · `l` back |
+| APPROVAL | `a` approve · `A` auto · `s` steer · `r` reject · `esc` later |
+
+Right-aligned mode indicator always reads `Soft · blue`.
+
+---
+
+## 3. Animation & Microfeedback
+
+- **Spinner** — `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` cycles at ~110ms. Seen next to "live" in event stream header, next to pending events, next to "paused" during approval.
+- **Streaming cursor** — block `█` blinks at end of in-progress agent message. 50% opacity blink.
+- **Toasts** — brief floating confirmations: "Approved", "Rejected", "Model switched". Appear near bottom center, auto-dismiss ~2.6s.
+- **Panel transitions** — active panel border highlight shifts when switching focus with `Tab`.
+
+---
+
+# BUILD PLAN
+
+---
+
+## Phase 1 — Theming System
+
+### File: NEW `components/Theme.kt`
+
+Centralize all color and style constants into a single `object Theme` so every widget references the same palette. This eliminates the current pattern of scattered `Style.create().blue()` / `.cyan()` / `.gray()` calls.
+
+```kotlin
+object Theme {
+ // Surfaces
+ val bg: Color // xterm 233 (#14161a)
+ val bgDeep: Color // xterm 232 (#0f1115)
+ val panel: Color // xterm 234 (#181b21)
+ val panel2: Color // xterm 235 (#1f232b)
+
+ // Text
+ val fg: Color // xterm 252 (#ced3da)
+ val fgStrong: Color // xterm 255 (#eef1f5)
+ val dim: Color // xterm 243 (#838b96)
+ val faint: Color // xterm 238 (#434a55)
+
+ // Accent
+ val accent: Color // #4a9eff (blue)
+ val accent2: Color // #7aa2f7 (purple-blue)
+
+ // Semantic
+ val ok: Color // green
+ val warn: Color // amber
+ val bad: Color // red
+
+ // Border / selection
+ val border: Color // xterm 236 (#262b33)
+ val sel: Color // xterm 235+ (#21262e)
+
+ // Pre-built Style objects
+ val dimStyle: Style
+ val accentStyle: Style
+ val okStyle: Style
+ val warnStyle: Style
+ val badStyle: Style
+ val fgStrongStyle: Style
+
+ // Event category → color
+ fun categoryColor(category: String): Color
+}
+```
+
+### Implementation Checklist
+
+- [ ] Create `Theme.kt` in `components/`
+- [ ] Define all Color constants (xterm or rgb depending on Mordant 2.6 API)
+- [ ] Define pre-built Style objects for common patterns
+- [ ] Define categoryColor() mapping (Lifecycle→accent2, Context→purple, Inference→accent, Tool→ok, Domain→amber, Approval→warn)
+- [ ] Replace all hardcoded `.blue()`, `.cyan()`, `.gray()`, `.green()`, `.yellow()`, `.red()` calls across all existing components with Theme.* references
+
+---
+
+## Phase 2 — Layout: Three-Row Grid + Horizontal Split
+
+### File modified: `TuiApp.kt`
+
+**Current layout** (vertical stack for all display states):
+
+```
+StatusBar (height=1)
+SessionList (height=min(count,7)+2)
+[WorkflowList (height=6 if visible)]
+[EventHistoryStrip (height=6 if visible)]
+RouterPanel (fill)
+InputBar (height=4)
+```
+
+**Target layout** — 3-row grid for all states:
+
+```
+grid rows: [status bar (length=1)] [main area (fill)] [footer bar (length=1)]
+
+main area: horizontal split [left panel (ratio 1.7)] [right panel (ratio 1.0)]
+```
+
+Which widgets fill the panels depends on display state:
+
+| State | Left panel (1.7) | Right panel (1.0) |
+|-------|------------------|-------------------|
+| IDLE | SessionList + WorkflowPicker | WelcomePanel |
+| IN_SESSION | RouterPanel | EventStreamPanel |
+| APPROVAL | ApprovalSurface (full width, no split) | — |
+
+The input bar moves from being a top-level row **into the left panel's footer** (matching the reference's `Panel.footer` slot). This means each left-panel component is responsible for rendering its own footer containing the input bar.
+
+### Layout code pattern
+
+```kotlin
+private fun renderInSessionLayout(frame: Frame, state: TuiState) {
+ val area = frame.area()
+ val vertRects = Layout.vertical()
+ .constraints(
+ Constraint.length(STATUS_HEIGHT),
+ Constraint.fill(), // main area
+ Constraint.length(FOOTER_HEIGHT),
+ )
+ .split(area)
+
+ val mainRects = Layout.horizontal()
+ .constraints(Constraint.ratio(1.7f), Constraint.ratio(1.0f))
+ .split(vertRects[1])
+
+ frame.renderWidget(statusBarWidget(state), vertRects[0])
+ frame.renderWidget(routerPanelWidget(state), mainRects[0]) // InputBar inside
+ frame.renderWidget(eventStreamPanelWidget(state), mainRects[1])
+ frame.renderWidget(footerBarWidget(state), vertRects[2])
+}
+```
+
+### InputBar as panel footer
+
+Each left-panel widget (RouterPanel, SessionListPanel) renders its own vertical stack:
+- Top: [messages / session rows] (fill)
+- Bottom: [InputBar] (fixed height)
+
+This can be done by splitting the panel's allocated rect into two sub-rects, or combining both into a single Paragraph with a separator.
+
+### Implementation Checklist
+
+- [ ] Import `Layout.horizontal()` in `TuiApp.kt`
+- [ ] IN_SESSION: 3-row grid with horizontal split (RouterPanel 1.7 + EventStreamPanel 1.0)
+- [ ] IDLE: same grid but left = SessionList, right = WelcomePanel
+- [ ] APPROVAL: full-width single column in main area
+- [ ] Move InputBar from top-level widget to left panel footer
+- [ ] Add footer bar rendering as grid row 3
+
+---
+
+## Phase 3 — Status Bar Polish
+
+### File modified: `components/StatusBar.kt`
+
+Upgrade from a simple connection+model line to the full reference `crx-top`:
+
+```
+● connected · cas-segment-store · feat/cas-segments · qwen_coder_14b (local) ctx ████████░░ 64% ▸ active
+```
+
+Segments (separated by `·`):
+1. **Connection** — `●` (green) / `○` (red) + connected/disconnected
+2. **Session name** — bold (shown in IN_SESSION / APPROVAL)
+3. **Branch / workflow ID** — dim
+4. **Model** — accent color + provider label
+5. **Context meter** — bar with accent fill on faint track + percentage
+6. **State badge** — pill: `▸ active` (green) or `⏸ awaiting approval` (amber)
+7. **Background badge** — `+N updated` (dim yellow, shown when backgroundUpdateCount > 0)
+
+### Context meter
+
+Requires `contextUsed` and `contextBudget` in TuiState. Render as:
+
+```
+ctx [████████░░] 64%
+```
+
+Filled portion in accent color, empty portion in faint color.
+
+### Implementation Checklist
+
+- [ ] Add `contextUsed: Int?` and `contextBudget: Int?` to `TuiState`
+- [ ] Replace current status bar spans with Theme references
+- [ ] Add brand text with accented `x` (`corre` + `x`)
+- [ ] Add session name + branch with `·` separators
+- [ ] Add model + provider display
+- [ ] Implement context meter bar (accent-fill on faint track)
+- [ ] Implement state badge pill (warn for awaiting, ok for active)
+- [ ] Keep background update badge, style with Theme.dim/yellow
+
+---
+
+## Phase 4 — Event Stream Panel
+
+### File: NEW `components/EventStreamPanel.kt`
+
+The right column in IN_SESSION state. Structured event list replacing the current raw-text `EventHistoryStrip`.
+
+### Event row format
+
+```
+14:11:02 [Lifecycle] stage.enter implementation (role=coder)
+14:11:19 [Tool] fs.write SegmentStore.kt (+212)
+14:12:02 [Approval] approval.raised T3 · gradle network resolve ⠋[a]
+```
+
+Each row:
+1. **Timestamp** — dim, fixed-width
+2. **Category badge** — colored pill (see categoryColor mapping)
+3. **Type** — bold/bright
+4. **Detail** — normal text, truncated if long
+5. **Pending indicator** — spinner + `[a]` keybind hint for pending approvals
+
+Panel header shows:
+```
+event stream ◌ live
+```
+Or if session has pending approval:
+```
+event stream ⠋ paused
+```
+
+### Implementation Checklist
+
+- [ ] Create `EventStreamPanel.kt` in `components/`
+- [ ] Implement event row rendering with all 5 columns
+- [ ] Wire categoryColor() into badge rendering
+- [ ] Add pending event indicator (spinner + `[a]` hint)
+- [ ] Add "live" / "paused" indicator in panel header
+- [ ] Wire into TuiApp.kt as right column in IN_SESSION layout
+
+---
+
+## Phase 5 — Footer Keybind Bar
+
+### File: NEW `components/FooterBar.kt`
+
+Dedicated bottom row showing contextual keybind hints and current mode indicator. Currently these hints are embedded in InputBar row2 — they move here instead.
+
+### Contextual hints
+
+| State | Hints |
+|-------|-------|
+| IDLE | `i` message · `p` commands · `a` approval · `e` events · `F2` skin |
+| IN_SESSION | above + `Tab` panels · `↑↓` history · `l` back |
+| APPROVAL | `a` approve · `A` auto · `s` steer · `r` reject · `esc` later |
+
+### Keybind chip pattern
+
+```
+ i message p commands a approval
+```
+Key in white-on-blue (accent background, reversed), label in dim text.
+
+### Mode indicator
+
+Right-aligned: `Soft · blue`
+
+### Implementation Checklist
+
+- [ ] Create `FooterBar.kt` in `components/`
+- [ ] Implement contextual hint logic based on display state / input mode / overlay
+- [ ] Add mode indicator (right-aligned)
+- [ ] Wire into TuiApp.kt as `Constraint.length(1)` in grid row 3
+- [ ] Remove all key hint spans from InputBar row2 (keep only session name, chat mode)
+
+---
+
+## Phase 6 — Welcome Panel (IDLE Right Column)
+
+### File: NEW `components/WelcomePanel.kt`
+
+The right column in IDLE state. Shows contextual info when no session is active:
+
+- Brand header (`Correx Agent Harness`) with accented `x`
+- Brief contextual prompt ("select a session or type a name")
+- Recent activity list (completed/active sessions with timestamps + status glyphs)
+- Connection status hint if disconnected
+
+### Implementation Checklist
+
+- [ ] Create `WelcomePanel.kt` in `components/`
+- [ ] Show brand header with accented `x`
+- [ ] Show contextual prompt text
+- [ ] Show recent sessions (if available) or empty-state message
+- [ ] Use Theme colors throughout
+
+---
+
+## Phase 7 — Font Size 14-15
+
+Terminal font size is emulator-controlled, not app-controlled. What we can do:
+
+1. Review all MAX constants for readability at 14pt:
+ - `NAME_MAX = 15` → may need adjustment
+ - `DETAIL_MAX = 60` → review
+ - `DIFF_LINE_MAX_LENGTH = 100` → may need decrease
+ - `MAX_VISIBLE = 7` → may need decrease (fewer rows fit)
+
+2. Add `Theme.fontSize: Int = 14` as documentation / scaling factor for layout constants.
+
+3. If Mordant `Style.Builder` exposes size (needs verification), apply it.
+
+### Implementation Checklist
+
+- [ ] Add `Theme.fontSize` constant
+- [ ] Review and adjust truncation/visibility constants
+- [ ] Verify row estimates fit within target terminal sizes
+
+---
+
+## Phase 8 — Approval Surface Polish
+
+### File modified: `components/ApprovalSurface.kt`
+
+Already the most polished component, needs theme integration:
+
+- Replace `Color.YELLOW` border with `Theme.warn`
+- Replace hardcoded `.green()`, `.red()`, `.yellow()`, `.gray()` with Theme.*
+- Tier badge → `Theme.pill()` pattern
+- Risk scan section → `▲` prefix in amber (matching reference `.risk` class)
+- Key hint bar → `Theme.dimStyle` with framed key labels
+
+### Implementation Checklist
+
+- [ ] Replace all hardcoded color calls with Theme references
+- [ ] Replace `Color.YELLOW` border with `Theme.warn`
+- [ ] Align risk section formatting with reference
+
+---
+
+## Phase 9 — Session List & Workflow List Polish
+
+### Files: `SessionList.kt`, `WorkflowListPanel.kt`
+
+Color substitution — these already use ROUNDED borders:
+
+- `▶` prefix → `Theme.accent` (was `.blue()`)
+- Names → `Theme.fgStrong` (was `.cyan()`)
+- Status labels → `Theme.ok`, `Theme.warn`, `Theme.bad`
+- Overflow / empty text → `Theme.dim`
+- Borders → `Theme.border`
+
+### Implementation Checklist
+
+- [ ] Replace all hardcoded color calls with Theme references in SessionList.kt
+- [ ] Replace all hardcoded color calls with Theme references in WorkflowListPanel.kt
+
+---
+
+# APPENDICES
+
+---
+
+## A. Color Reference
+
+### Palette
+
+| Token | Hex | ANSI | Use |
+|-------|-----|------|-----|
+| `bg` | `#14161a` | xterm 233 | Screen background |
+| `bgDeep` | `#0f1115` | xterm 232 | Top/footer bar |
+| `panel` | `#181b21` | xterm 234 | Panel surface |
+| `panel2` | `#1f232b` | xterm 235 | Secondary surface |
+| `fg` | `#ced3da` | xterm 252 | Body text |
+| `fgStrong` | `#eef1f5` | xterm 255 | Headings, emphasis |
+| `dim` | `#838b96` | xterm 243 | Muted text |
+| `faint` | `#434a55` | xterm 238 | Borders, placeholders |
+| `border` | `#262b33` | xterm 236 | Panel borders |
+| `sel` | `#21262e` | xterm 235+ | Selection highlight |
+| `accent` | `#4a9eff` | rgb(74,158,255) | Blue accent |
+| `accent2` | `#7aa2f7` | rgb(122,162,247) | Purple-blue secondary |
+| `ok` | `#7fd88f` | rgb(127,216,143) | Success |
+| `warn` | `#e8c06a` | rgb(232,192,106) | Warning |
+| `bad` | `#e87f7f` | rgb(232,127,127) | Error |
+
+### Event category → color
+
+| Category | Color | Hex |
+|----------|-------|-----|
+| Lifecycle | accent2 | `#7aa2f7` |
+| Context | purple | `#c98fd9` |
+| Inference | accent | `#4a9eff` |
+| Tool | ok | `#7fd88f` |
+| Domain | amber | `#e8b765` |
+| Approval | warn | `#e8c06a` |
+
+---
+
+## B. Keybind Reference
+
+| Key | Context | Action |
+|-----|---------|--------|
+| `↑↓` | Session list | Navigate sessions |
+| `↑↓` | Input mode (IN_SESSION) | History navigation |
+| `Enter` | Session list | Enter selected session |
+| `Enter` | Input bar | Submit message / start session |
+| `Tab` | Any | Toggle filter mode / focus panel |
+| `i` | No overlay | Focus input bar |
+| `p`, `/`, `:` | No overlay | Open command palette |
+| `a` | No overlay | Open pending approval |
+| `e` | No overlay | Open event inspector overlay |
+| `c` | No overlay | Open context pack inspector |
+| `g` | No overlay | Open stage graph |
+| `m` | No overlay | Open model registry |
+| `t` | No overlay | Open tool palette |
+| `a` | Approval | Approve |
+| `A` | Approval | Auto-approve session |
+| `s` | Approval | Steer + approve |
+| `r` | Approval | Reject |
+| `ctrl+a` | Approval | Approve (alt) |
+| `ctrl+r` | Approval | Reject (alt) |
+| `ctrl+e` | Any | Toggle event strip |
+| `ctrl+n` | IDLE | Start new session |
+| `ctrl+l` | IN_SESSION | Return to session list |
+| `ctrl+q` | Any | Quit |
+| `ctrl+x` | Approval | Toggle diff expand |
+| `F2` | Any | Cycle chrome direction |
+| `F3` | Any | Cycle accent color |
+| `Escape` | Overlay | Close overlay |
+| `Escape` | Input focus | Blur input |
+
+---
+
+## C. State Changes
+
+### TuiState additions
+
+```kotlin
+data class TuiState(
+ // ... existing fields ...
+ val contextUsed: Int? = null,
+ val contextBudget: Int? = null,
+ val eventStreamExpanded: Boolean = false,
+)
+```
+
+`contextUsed` / `contextBudget` feed the context meter. Populated from `ServerMessage` snapshot data during snapshot phase.
+
+---
+
+## D. Implementation Order
+
+| Phase | Files | Dependencies | Effort |
+|-------|-------|-------------|--------|
+| **1. Theme** | `Theme.kt` (new) | None | Medium |
+| **2. Layout** | `TuiApp.kt`, left panel components | Phase 1 | Large |
+| **3. Status bar** | `StatusBar.kt`, `TuiState.kt` | Phase 1 | Medium |
+| **4. Event stream** | `EventStreamPanel.kt` (new) | Phase 1, Phase 2 | Medium |
+| **5. Footer bar** | `FooterBar.kt` (new), `InputBar.kt` | Phase 1, Phase 2 | Small |
+| **6. Welcome panel** | `WelcomePanel.kt` (new) | Phase 1, Phase 2 | Small |
+| **7. Font** | `Theme.kt` (amend), constants review | Phase 1 | Small |
+| **8. Approval** | `ApprovalSurface.kt` | Phase 1 | Small |
+| **9. List polish** | `SessionList.kt`, `WorkflowListPanel.kt` | Phase 1 | Small |
+
+---
+
+## E. Constraints & Risks
+
+1. **Mordant Color API**: `Color.rgb(r, g, b)` depends on Mordant 2.6.0. If unavailable, fall back to `Color.xterm(n)` for closest ANSI color. Verify during Phase 1.
+2. **Horizontal split**: `Layout.horizontal()` is part of Tambouille 0.3.0 (currently used in build.gradle). If unavailable, simulate with two-column Paragraph or verify API existence before Phase 2.
+3. **Context meter**: Requires upstream data (context used/budget) that may not be in current server protocol. May need to stub until data arrives.
+4. **Interactive event clicks**: Tambouille supports widget click callbacks. Event row selection can be added if interactivity is needed.