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