docs: add visual design reference (TUI mockups)
This commit is contained in:
@@ -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 />);
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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 };
|
||||||
@@ -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 });
|
||||||
@@ -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 };
|
||||||
@@ -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 2–3 short options (auto-falls-back to
|
||||||
|
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
|
||||||
|
// options are many or long. For color tweaks always curate 3-4 options rather than
|
||||||
|
// a free picker; an option can also be a whole 2–5 color palette (the stored value
|
||||||
|
// is the array). The Tweak* controls are a floor, not a ceiling — build custom
|
||||||
|
// controls inside the panel if a tweak calls for UI they don't cover.
|
||||||
|
/* END USAGE */
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const __TWEAKS_STYLE = `
|
||||||
|
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||||
|
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||||
|
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
||||||
|
background:rgba(250,249,247,.78);color:#29261b;
|
||||||
|
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||||
|
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||||
|
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||||
|
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||||
|
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||||
|
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||||
|
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||||
|
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||||
|
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||||
|
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||||
|
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||||
|
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||||
|
.twk-body::-webkit-scrollbar{width:8px}
|
||||||
|
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||||
|
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||||
|
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||||
|
color:rgba(41,38,27,.72)}
|
||||||
|
.twk-lbl>span:first-child{font-weight:500}
|
||||||
|
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||||
|
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||||
|
.twk-sect:first-child{padding-top:0}
|
||||||
|
|
||||||
|
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||||
|
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||||
|
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||||
|
select.twk-field{padding-right:22px;
|
||||||
|
background-image:url("data:image/svg+xml;utf8,<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,
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user