docs: add visual design reference (TUI mockups)

This commit is contained in:
2026-06-01 23:23:29 +04:00
parent 91ededa4ca
commit 198cf95725
8 changed files with 2225 additions and 0 deletions
+317
View File
@@ -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 (
<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 />);
+239
View File
@@ -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; }
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>correx — agent harness TUI</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="correx.css" />
<style>
html, body { height: 100%; margin: 0; background: #070a0e; }
#root { position: fixed; inset: 0; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="themes.jsx"></script>
<script type="text/babel" src="overlays.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>
+114
View File
@@ -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 <key>.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 };
+243
View File
@@ -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 (
<div className={"scrim" + (center ? " center" : "")} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="ovl" style={width ? { width } : null} onMouseDown={(e) => e.stopPropagation()}>
<div className="ovl-h">
<span className="tag">{tag}</span>
<span className="ti">{title}</span>
<span className="esc">{esc} to close</span>
</div>
<div className="ovl-b">{children}</div>
</div>
</div>
);
}
/* ── 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 (
<div className="scrim" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="ovl" style={{ width: "min(560px,94%)" }} onMouseDown={(e) => e.stopPropagation()}>
<div className="cmd-input">
<span className="pr"></span>
<input ref={inp} value={q} placeholder="Jump to a capability…" onChange={(e) => setQ(e.target.value)} onKeyDown={key} />
</div>
<div className="cmd-list">
{list.length === 0 && <div className="hint-empty">no matches</div>}
{list.map((c, n) => (
<div key={c.id} className={"cmd-it" + (n === i ? " sel" : "")} onMouseEnter={() => setI(n)} onClick={() => onRun(c.id)}>
<span className="ci-k">{c.key}</span>
<span className="ci-l">{c.label}</span>
<span className="ci-h">{c.hint}</span>
</div>
))}
</div>
</div>
</div>
);
}
/* ── approval gate (modal interrupt) ── */
function ApprovalModal({ data, onApprove, onReject, onSteer, onClose }) {
const [steer, setSteer] = useState("");
const sref = useRef(null);
return (
<div className="scrim center" onMouseDown={(e) => e.stopPropagation()}>
<div className="ovl appr" onMouseDown={(e) => e.stopPropagation()}>
<div className="appr-tier">
<span className="appr-badge">{data.tier}</span>
<div>
<div className="lab">Approval required · <b>{data.tierLabel}</b></div>
<div className="sub">{data.intent}</div>
</div>
<span className="esc" style={{ marginLeft: "auto" }}>execution paused</span>
</div>
<div className="ovl-b">
<div className="appr-cmd"><span className="pr">$ </span>{data.command}</div>
<div style={{ color: "var(--dim)", fontSize: 12.5, marginBottom: 6 }}>{data.detail}</div>
<dl className="kv">
<dt>tool</dt><dd>{data.tool} <span className="tag-tier tier-T3">{data.tier}</span></dd>
<dt>reversible</dt><dd>{data.reversible ? "yes" : "no"}</dd>
<dt>policy</dt><dd style={{ color: "var(--dim)" }}>{data.policy}</dd>
</dl>
<div style={{ margin: "10px 0 4px", color: "var(--dim)", fontSize: 11, letterSpacing: ".08em", textTransform: "uppercase" }}>semantic risk scan</div>
{data.risks.map((r, n) => <div className="risk" key={n}>{r}</div>)}
<div className="steer">
<span style={{ color: "var(--accent)" }}></span>
<input ref={sref} value={steer} placeholder='steer + approve, e.g. "ok, but lock the version"'
onChange={(e) => setSteer(e.target.value)}
onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter" && steer.trim()) onSteer(steer.trim()); if (e.key === "Escape") sref.current.blur(); }} />
</div>
</div>
<div className="appr-acts">
<button className="btn ok" onClick={() => onApprove(false)}>approve <kbd>a</kbd></button>
<button className="btn ghost" onClick={() => onApprove(true)}>auto-approve session <kbd>A</kbd></button>
<button className="btn" onClick={() => steer.trim() ? onSteer(steer.trim()) : sref.current.focus()}>steer <kbd>s</kbd></button>
<button className="btn bad" onClick={onReject}>reject <kbd>r</kbd></button>
</div>
</div>
</div>
);
}
/* ── 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 (
<Overlay tag="events" title="Event stream inspector" onClose={onClose} width="min(820px,95%)">
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 10 }}>
{cats.map((c) => <span key={c} className={"pill" + (c === cat ? " live" : "")} style={{ cursor: "pointer" }} onClick={() => setCat(c)}>{c}</span>)}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.3fr 1fr", gap: 14 }}>
<div>
{list.map((e) => (
<div key={e.id} className={"ev" + (e === sel ? " sel" : "")} onClick={() => setSel(e)}>
<span className="ev-t">{e.t}</span>
<span className={"ev-cat cat-" + e.cat}>{e.cat}</span>
<span className="ev-tx"><b>{e.type}</b> {e.text}</span>
</div>
))}
</div>
<div className="appr-cmd" style={{ margin: 0, alignSelf: "start", fontSize: 12 }}>
<pre style={{ whiteSpace: "pre-wrap", margin: 0, color: "var(--fg)" }}>{JSON.stringify(payload, null, 2)}</pre>
</div>
</div>
</Overlay>
);
}
/* ── context pack inspector ── */
function ContextPackInspector({ pack, onClose }) {
const pct = Math.round((pack.used / pack.budget) * 100);
return (
<Overlay tag="context" title="Context Pack — what the model receives" onClose={onClose} width="min(760px,95%)">
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 12 }}>
<div style={{ color: "var(--fg-strong)", fontSize: 15 }}>{pack.used.toLocaleString()} <span style={{ color: "var(--dim)", fontSize: 12 }}>/ {pack.budget.toLocaleString()} tok</span></div>
<div className="mono-bar" style={{ flex: 1 }}><i style={{ width: pct + "%" }} /></div>
<div style={{ color: "var(--accent)" }}>{pct}%</div>
</div>
{pack.layers.map((l) => (
<div className="row" key={l.id} style={{ gridTemplateColumns: "38px 150px 1fr 64px" }}>
<span style={{ color: "var(--accent)", fontWeight: 600 }}>{l.id}</span>
<span style={{ color: "var(--fg)" }}>{l.name}<br /><span style={{ color: "var(--faint)", fontSize: 11 }}>{l.mode}</span></span>
<span style={{ color: "var(--dim)", fontSize: 12 }}>{l.items.join(" · ")}</span>
<span style={{ color: "var(--dim)", textAlign: "right" }}>{l.tok}t</span>
</div>
))}
<div style={{ marginTop: 12, color: "var(--dim)", fontSize: 11, letterSpacing: ".08em", textTransform: "uppercase" }}>excluded by synthesis</div>
{pack.dropped.map((d, n) => <div key={n} style={{ color: "var(--faint)", fontSize: 12.5, padding: "2px 0" }}> {d}</div>)}
</Overlay>
);
}
/* ── stage / transition graph ── */
function StageGraph({ stages, transitions, onClose }) {
const glyph = { done: "✔", active: "▸", pending: "·" };
return (
<Overlay tag="graph" title="Stage / transition graph" onClose={onClose} width="min(720px,95%)">
<div style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 18 }}>
<div>
{stages.map((s, n) => (
<div key={s.id}>
<div style={{ display: "flex", alignItems: "baseline", gap: 10, padding: "5px 0" }}>
<span style={{ width: 16, color: s.status === "active" ? "var(--accent)" : s.status === "done" ? "var(--ok)" : "var(--faint)" }}>{glyph[s.status]}</span>
<span style={{ color: s.status === "pending" ? "var(--dim)" : "var(--fg-strong)", fontWeight: s.status === "active" ? 600 : 400, minWidth: 120 }}>{s.id}</span>
<span style={{ color: "var(--faint)", fontSize: 11 }}>role:{s.role}</span>
{s.status === "active" && <span className="pill live">running · {s.enter}</span>}
<span style={{ color: "var(--dim)", fontSize: 12, marginLeft: "auto" }}>{s.note}</span>
</div>
{n < stages.length - 1 && <div style={{ marginLeft: 7, color: "var(--faint)", lineHeight: 1 }}></div>}
</div>
))}
</div>
<div>
<div style={{ color: "var(--dim)", fontSize: 11, letterSpacing: ".08em", textTransform: "uppercase", marginBottom: 8 }}>transition rules</div>
<div style={{ color: "var(--faint)", fontSize: 11, marginBottom: 10 }}>no hardcoded graph rules eval per artifact</div>
{transitions.map((t, n) => (
<div key={n} className="appr-cmd" style={{ margin: "0 0 8px", fontSize: 12 }}>
<span style={{ color: "var(--accent-2)" }}>when</span> {t.when}<br />
<span style={{ color: "var(--accent)" }}> goto</span> {t.goto}
</div>
))}
</div>
</div>
</Overlay>
);
}
/* ── model registry / provider switch ── */
function ModelRegistry({ models, active, onSwitch, onClose }) {
return (
<Overlay tag="models" title="Model registry" onClose={onClose} width="min(800px,95%)">
<div style={{ color: "var(--faint)", fontSize: 12, marginBottom: 8 }}>models are stateless engines switching re-synthesizes the Context Pack for the new window.</div>
{models.map((m) => (
<div className={"row"} key={m.id} style={{ gridTemplateColumns: "1fr auto", cursor: m.id === active ? "default" : "pointer", opacity: m.status === "cold" ? 0.7 : 1 }} onClick={() => m.id !== active && onSwitch(m.id)}>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<span style={{ color: m.id === active ? "var(--accent)" : "var(--fg-strong)", fontWeight: 600 }}>{m.id}</span>
<span className={"pill " + (m.provider === "local" ? "live" : "")}>{m.provider}</span>
{m.id === active && <span className="pill on">active</span>}
<span className="pill off">{m.status}</span>
</div>
<div style={{ color: "var(--dim)", fontSize: 11.5, marginTop: 3 }}>{m.caps.join(" · ")} ctx {m.ctx.toLocaleString()} · t={m.temp} · {m.gpu}</div>
</div>
<div style={{ textAlign: "right", color: "var(--faint)", fontSize: 11 }}>
sessions {m.parallel}{m.id !== active && <div style={{ color: "var(--accent)" }}>switch </div>}
</div>
</div>
))}
</Overlay>
);
}
/* ── tool palette ── */
function ToolPalette({ tools, onToggle, onClose }) {
return (
<Overlay tag="tools" title="Tool palette" onClose={onClose} width="min(720px,95%)">
<div style={{ color: "var(--faint)", fontSize: 12, marginBottom: 8 }}>config-driven · capability-scoped. tier sets the approval gate; disabled tools are invisible to the agent.</div>
{tools.map((t) => (
<div className="row" key={t.id} style={{ gridTemplateColumns: "auto 1fr auto auto", cursor: "pointer", opacity: t.enabled ? 1 : 0.55 }} onClick={() => onToggle(t.id)}>
<span className={"tag-tier tier-" + t.tier}>{t.tier}</span>
<span><b style={{ color: "var(--fg-strong)", fontWeight: 600 }}>{t.id}</b> <span style={{ color: "var(--dim)", fontSize: 12 }}> {t.desc}</span></span>
<span className="pill" style={{ color: t.mode === "deny" ? "var(--bad)" : t.mode === "prompt" ? "var(--warn)" : "var(--ok)" }}>{t.mode}</span>
<span className={"pill " + (t.enabled ? "on" : "off")}>{t.enabled ? "enabled" : "disabled"}</span>
</div>
))}
</Overlay>
);
}
Object.assign(window, { Overlay, CommandPalette, ApprovalModal, EventInspector, ContextPackInspector, StageGraph, ModelRegistry, ToolPalette });
+114
View File
@@ -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 };
+540
View File
@@ -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 <input type="range">, 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 (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// TweakRadio is the segmented control for 23 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 25 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,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
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 (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
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) {
// <select> 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 <TweakSelect label={label} value={value} options={options}
onChange={(s) => 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 (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
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 (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// 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 }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// 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 (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> 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 (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{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 (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
+631
View File
@@ -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.