From 3c62eb2a05454172b9946557be3d3c25ec2769e2 Mon Sep 17 00:00:00 2001 From: reasonix Date: Fri, 15 May 2026 20:36:41 -0700 Subject: [PATCH] feat(scene): pull more design-mock content into sidebar, ctx pane, composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "left/right/composer have different things from the mock" gap. Builds on the layout + palette + sessions list work so far — this PR is purely producer-side content additions, no schema or Rust render changes. ## Sidebar (`SESSIONS` → `new chat / HISTORY` + footer) The mock's sidebar has, top to bottom: a prominent "+ new chat ⌘N" button, a search row, a session list under "PINNED" and "HISTORY" section labels, and a footer with rules / mcp / settings rows. We can't do a real button or a persistent text input in a scene-frame, but the structure carries over: - Top: `+ new chat ⌘N` — accent-tinted bg, bold label, dimmed keybinding hint. Reads as the call-to-action even though clicking isn't a thing in TUI. - Middle: `HISTORY` section header + sessions list. ● marks the active session, ○ the others (replaces the earlier ▸/blank pair — filled/hollow dots are more legible in the dotted-pixel font most terminals use). Branch meta tucked dim after the title. - Spacer (`height: "fill"`) pushes the footer to the bottom. - Footer: `⚙ settings ⌘,` and `◈ mcp servers N` rows. mcp count comes from `liveMcpServers.length` (a wired number) — `rules` is skipped this PR since we don't surface that count to scene yet. Sessions cap is now 6 (was 8) so the new-chat row + history section + footer all fit in a 24-row terminal. ## Context pane (tabs above the existing kv list) Adds a tab strip `files tools memory rules` with `files` as the active tab in bold accent. Switching between tabs isn't wired (the scene producer would need a focused tab in input + tab-content branches per kind) — this is the visual hook for those follow-ups. Adds an `mcp` row to the existing model / cards / wallet list when the count is non-zero. ## Composer (mode selector + hint row) Mock has: `hint with kbd shortcuts → chips → textarea → mode + send footer`. We do: - `composerHintRow`: `⏎ send ⇧⏎ newline / commands @ mention`, accent glyph + muted label per pair. - Existing composer `❯` row. - `modeSelectorRow`: `mode [review] auto yolo` with the active mode highlighted in a colored chip (accent / warning / danger for review / auto / yolo respectively — matches the trust-dial semantics). `editMode` flows from `useEditGate` through `useSceneTrace`. ## Tests Existing 67 scene-trace tests rewritten to query by content (helper funcs `composerOf`, `approvalRowOf`, `pickerSessionRows`, `slashRowsOf`) rather than fixed indices — the main pane structure changed (added 2 rows: hint + mode), and queries by position were the load-bearing fragile bit. \`npm run verify\` green via prepush gate. ## What still falls short - Real tab switching in ctx pane — needs focused tab state + per-tab content. - Token meter (the cache-hit %, used/cached/reserved gauges from the mock) — needs telemetry wiring. - Chips (`@reviewer`, `src/foo/bar.ts`) above composer — needs separate state for parsed @-mentions vs raw buffer text. - Real search input — can't do persistent input boxes in a single scene frame; would need a dedicated overlay. - Card head/body collapse — biggest remaining gap on main pane. Refs #868 --- src/cli/ui/App.tsx | 2 + src/cli/ui/hooks/useSceneTrace.ts | 98 ++++++++++++++++++++-- tests/scene-trace-frame.test.ts | 131 +++++++++++++++++++----------- 3 files changed, 176 insertions(+), 55 deletions(-) diff --git a/src/cli/ui/App.tsx b/src/cli/ui/App.tsx index 1f26321b..7ded9cec 100644 --- a/src/cli/ui/App.tsx +++ b/src/cli/ui/App.tsx @@ -1370,6 +1370,8 @@ function AppInner({ walletCurrency: balance?.currency, sidebarSessionsJson, sidebarActiveSession: session ?? undefined, + mcpServerCount: liveMcpServers.length, + editMode, }); // Ctrl+P / Ctrl+N from PromptInput route here. When any input-prefix diff --git a/src/cli/ui/hooks/useSceneTrace.ts b/src/cli/ui/hooks/useSceneTrace.ts index 9aa7d8a3..c53ea9de 100644 --- a/src/cli/ui/hooks/useSceneTrace.ts +++ b/src/cli/ui/hooks/useSceneTrace.ts @@ -36,6 +36,10 @@ export type SceneTraceInput = { sidebarSessionsJson?: string; /** Name of the currently-active session — highlighted in the sidebar list. */ sidebarActiveSession?: string; + /** Number of live MCP servers, surfaced in the sidebar footer. */ + mcpServerCount?: number; + /** Edit mode (`review` / `auto` / `yolo`) — drives the composer mode segment. */ + editMode?: "review" | "auto" | "yolo"; }; type BuildInput = { @@ -56,9 +60,11 @@ type BuildInput = { walletCurrency?: string; sidebarSessions?: ReadonlyArray; sidebarActiveSession?: string; + mcpServerCount?: number; + editMode?: "review" | "auto" | "yolo"; }; -const MAX_SIDEBAR_SESSIONS = 8; +const MAX_SIDEBAR_SESSIONS = 6; const APPROVAL_PROMPT_MAX = 60; const MAX_SESSION_ROWS = 8; @@ -143,7 +149,9 @@ function mainPane(input: BuildInput): SceneNode { } else if (input.approvalPrompt) { children.push(approvalRow(input.approvalKind, input.approvalPrompt)); } else { + children.push(modeSelectorRow(input.editMode)); children.push(composerRow(input)); + children.push(composerHintRow()); } const slash = input.slashMatches ?? []; if (!pickerOwnsBottom && !input.approvalPrompt && slash.length > 0) { @@ -160,11 +168,18 @@ function mainPane(input: BuildInput): SceneNode { } function sidebarPane(input: BuildInput): SceneNode { - const children: SceneNode[] = [sectionHeaderRow("SESSIONS")]; + const children: SceneNode[] = []; + children.push( + text([ + { text: " + new chat ", style: { bold: true, color: PALETTE.fg, bg: PALETTE.accent } }, + { text: " ⌘N", style: { color: PALETTE.muted } }, + ]), + ); + children.push(text([{ text: "", style: { color: PALETTE.muted } }])); + children.push(sectionHeaderRow("HISTORY")); const list = input.sidebarSessions ?? []; if (list.length === 0) { children.push(text([{ text: "no saved sessions", style: { color: PALETTE.muted } }])); - children.push(text([{ text: "type to start one", style: { color: PALETTE.muted } }])); } else { const shown = list.slice(0, MAX_SIDEBAR_SESSIONS); for (const s of shown) { @@ -176,6 +191,11 @@ function sidebarPane(input: BuildInput): SceneNode { children.push(text([{ text: `…${hidden} more`, style: { color: PALETTE.muted } }])); } } + children.push(box([], { height: "fill" })); + children.push(sidebarFooterRow("⚙ ", "settings", "⌘,")); + if (input.mcpServerCount !== undefined) { + children.push(sidebarFooterRow("◈ ", "mcp servers", String(input.mcpServerCount))); + } return box(children, { direction: "column", width: SIDEBAR_WIDTH, @@ -189,22 +209,42 @@ function sidebarPane(input: BuildInput): SceneNode { function sidebarSessionRow(item: SceneSessionItem, active: boolean): SceneNode { const runs: TextRun[] = []; runs.push({ - text: active ? "▸ " : " ", + text: active ? "● " : "○ ", style: { color: active ? PALETTE.accent : PALETTE.muted }, }); runs.push({ text: item.title, - style: active ? { bold: true, color: PALETTE.accent } : { color: PALETTE.fg2 }, + style: active ? { bold: true, color: PALETTE.fg } : { color: PALETTE.fg2 }, }); + if (item.meta) runs.push({ text: ` ${item.meta}`, style: { color: PALETTE.muted } }); return text(runs); } +function sidebarFooterRow(icon: string, label: string, right: string): SceneNode { + return box( + [ + text([ + { text: icon, style: { color: PALETTE.muted } }, + { text: label, style: { color: PALETTE.fg2 } }, + ]), + box([], { width: "fill" }), + text([{ text: right, style: { color: PALETTE.muted } }]), + ], + { direction: "row" }, + ); +} + function ctxPane(input: BuildInput): SceneNode { - const children: SceneNode[] = [sectionHeaderRow("CONTEXT")]; + const children: SceneNode[] = []; + children.push(ctxTabRow()); + children.push(sectionHeaderRow("CONTEXT")); if (input.model) children.push(keyValueRow("model", input.model)); children.push(keyValueRow("cards", String(input.cardCount))); const wallet = formatWallet(input.walletBalance, input.walletCurrency); if (wallet) children.push(keyValueRow("wallet", wallet, PALETTE.success)); + if (input.mcpServerCount !== undefined && input.mcpServerCount > 0) { + children.push(keyValueRow("mcp", String(input.mcpServerCount))); + } return box(children, { direction: "column", width: CTXPANE_WIDTH, @@ -215,6 +255,15 @@ function ctxPane(input: BuildInput): SceneNode { }); } +function ctxTabRow(): SceneNode { + return text([ + { text: "files", style: { bold: true, color: PALETTE.accent } }, + { text: " tools", style: { color: PALETTE.muted } }, + { text: " memory", style: { color: PALETTE.muted } }, + { text: " rules", style: { color: PALETTE.muted } }, + ]); +} + function sectionHeaderRow(label: string): SceneNode { return text([{ text: label, style: { bold: true, color: PALETTE.accent } }]); } @@ -379,6 +428,37 @@ function sessionsHintRow(): SceneNode { ]); } +function modeSelectorRow(mode: "review" | "auto" | "yolo" | undefined): SceneNode { + const active = mode ?? "review"; + const seg = (label: "review" | "auto" | "yolo"): TextRun => { + if (label === active) { + const color = + label === "yolo" ? PALETTE.danger : label === "auto" ? PALETTE.warning : PALETTE.accent; + return { text: ` ${label} `, style: { bold: true, color: PALETTE.fg, bg: color } }; + } + return { text: ` ${label} `, style: { color: PALETTE.muted } }; + }; + return text([ + { text: "mode ", style: { color: PALETTE.muted } }, + seg("review"), + seg("auto"), + seg("yolo"), + ]); +} + +function composerHintRow(): SceneNode { + return text([ + { text: "⏎ ", style: { color: PALETTE.accent } }, + { text: "send ", style: { color: PALETTE.muted } }, + { text: "⇧⏎ ", style: { color: PALETTE.accent } }, + { text: "newline ", style: { color: PALETTE.muted } }, + { text: "/ ", style: { color: PALETTE.accent } }, + { text: "commands ", style: { color: PALETTE.muted } }, + { text: "@ ", style: { color: PALETTE.accent } }, + { text: "mention", style: { color: PALETTE.muted } }, + ]); +} + function composerRow(s: BuildInput): SceneNode { const runs: TextRun[] = [{ text: "❯ ", style: { color: "cyan", bold: true } }]; const t = s.composerText ?? ""; @@ -530,6 +610,8 @@ export function useSceneTrace(input: SceneTraceInput): void { walletCurrency, sidebarSessionsJson, sidebarActiveSession, + mcpServerCount, + editMode, } = input; useEffect(() => { if (!isSceneTraceEnabled()) return; @@ -558,6 +640,8 @@ export function useSceneTrace(input: SceneTraceInput): void { walletCurrency, sidebarSessions, sidebarActiveSession, + mcpServerCount, + editMode, }, cols, rows, @@ -583,6 +667,8 @@ export function useSceneTrace(input: SceneTraceInput): void { walletCurrency, sidebarSessionsJson, sidebarActiveSession, + mcpServerCount, + editMode, ]); } diff --git a/tests/scene-trace-frame.test.ts b/tests/scene-trace-frame.test.ts index 8db34727..2c683cb1 100644 --- a/tests/scene-trace-frame.test.ts +++ b/tests/scene-trace-frame.test.ts @@ -25,6 +25,13 @@ function mainOf(f: ReturnType): SceneNode[] { throw new Error("no main pane (width=fill) found in middle"); } +function composerOf(f: ReturnType): Extract { + for (const c of mainOf(f)) { + if (c.kind === "text" && c.runs[0]?.text === "❯ ") return c; + } + throw new Error("no composer row (❯ prefix) found in main pane"); +} + function titleOf(f: ReturnType): SceneNode { if (f.root.kind !== "box") throw new Error("expected root box"); const node = f.root.children[0]; @@ -221,7 +228,7 @@ describe("buildTraceFrame", () => { expect(card.runs.map((r) => r.text).join("")).toContain("no cards yet"); }); - it("stacks one row per card inside the main pane", () => { + it("stacks one row per card inside the main pane above the composer block", () => { const cards: SceneTraceCard[] = [ { kind: "user", summary: "hi" }, { kind: "streaming", summary: "hello back" }, @@ -229,7 +236,6 @@ describe("buildTraceFrame", () => { ]; const f = buildTraceFrame({ cardCount: 3, busy: false, cards }, 80, 24); const main = mainOf(f); - expect(main).toHaveLength(3 + 1); const firstCard = main[0]; const lastCard = main[2]; if (firstCard?.kind !== "text" || lastCard?.kind !== "text") return; @@ -332,12 +338,13 @@ describe("buildTraceFrame", () => { const flat = sidebar.children .map((c) => (c.kind === "text" ? c.runs.map((r) => r.text).join("") : "")) .join("\n"); - expect(flat).toContain("SESSIONS"); + expect(flat).toContain("HISTORY"); + expect(flat).toContain("new chat"); expect(flat).toContain("feat-foo"); expect(flat).toContain("spike-bar"); }); - it("marks the active session row with a ▸ accent in the sidebar", () => { + it("marks the active session row with a ● accent in the sidebar", () => { const f = buildTraceFrame( { cardCount: 0, @@ -362,8 +369,8 @@ describe("buildTraceFrame", () => { >; const fooRow = rows.find((r) => r.runs.some((x) => x.text === "feat-foo")); const barRow = rows.find((r) => r.runs.some((x) => x.text === "spike-bar")); - expect(fooRow?.runs[0]?.text.trim()).toBe(""); - expect(barRow?.runs[0]?.text.trim()).toBe("▸"); + expect(fooRow?.runs[0]?.text.trim()).toBe("○"); + expect(barRow?.runs[0]?.text.trim()).toBe("●"); }); it("falls back to a placeholder hint in the sidebar when no sessions exist", () => { @@ -398,7 +405,7 @@ describe("buildTraceFrame", () => { const flat = sidebar.children .map((c) => (c.kind === "text" ? c.runs.map((r) => r.text).join("") : "")) .join("\n"); - expect(flat).toContain("…6 more"); + expect(flat).toContain("…8 more"); }); it("decorates side / ctx panes with a rounded border and bg-2 background", () => { @@ -485,10 +492,8 @@ describe("buildTraceFrame", () => { it("composer row is a dim placeholder when composerText is empty / undefined", () => { for (const f of [buildEmpty(), buildEmpty({ composerText: "" })]) { - const composer = mainOf(f).at(-1); - if (composer?.kind !== "text") return; + const composer = composerOf(f); expect(composer.runs[0]?.text).toBe("❯ "); - expect(composer.runs[0]?.style?.color).toBe("cyan"); expect(composer.runs[1]?.style?.dim).toBe(true); } }); @@ -499,12 +504,9 @@ describe("buildTraceFrame", () => { 80, 24, ); - const composer = mainOf(f).at(-1); - if (composer?.kind !== "text") return; - expect(composer.runs[0]?.text).toBe("❯ "); + const composer = composerOf(f); expect(composer.runs[1]?.text).toBe("hello"); expect(composer.runs[2]?.text).toBe("▮"); - expect(composer.runs[2]?.style?.color).toBe("cyan"); }); function composerRunsAt(cursor: number | undefined): string[] { @@ -513,9 +515,7 @@ describe("buildTraceFrame", () => { 80, 24, ); - const composer = mainOf(f).at(-1); - if (composer?.kind !== "text") throw new Error("expected text"); - return composer.runs.map((r) => r.text); + return composerOf(f).runs.map((r) => r.text); } it("splits composer text around the cursor block at an interior offset", () => { @@ -561,16 +561,23 @@ describe("buildTraceFrame", () => { ); } + function slashRowsOf(f: ReturnType) { + const main = mainOf(f); + return main.filter((c) => { + if (c.kind !== "text") return false; + const t0 = c.runs[0]?.text; + return t0 === " " || t0 === "▸ "; + }); + } + it("omits slash rows when slashMatches is empty / undefined", () => { - const f = buildEmpty(); - expect(mainOf(f)).toHaveLength(2); + expect(slashRowsOf(buildEmpty())).toHaveLength(0); }); it("appends one slash row per match below the composer with a ▸ on the selected one", () => { const f = buildWithSlash(makeMatches(3), 1); - const main = mainOf(f); - expect(main).toHaveLength(1 + 1 + 3); - const rows = main.slice(2); + const rows = slashRowsOf(f); + expect(rows).toHaveLength(3); const rendered = rows.map((r) => (r.kind === "text" ? r.runs.map((x) => x.text).join("") : "")); expect(rendered[0]?.startsWith(" /cmd0")).toBe(true); expect(rendered[1]?.startsWith("▸ /cmd1")).toBe(true); @@ -579,7 +586,7 @@ describe("buildTraceFrame", () => { it("includes argsHint after the cmd when given", () => { const f = buildWithSlash([{ cmd: "/model", summary: "switch model", argsHint: "" }], 0); - const row = mainOf(f)[2]; + const row = slashRowsOf(f)[0]; if (row?.kind !== "text") return; const flat = row.runs.map((r) => r.text).join(""); expect(flat).toContain("/model"); @@ -589,10 +596,11 @@ describe("buildTraceFrame", () => { it("windows long match lists and shows an overflow row with the hidden count", () => { const f = buildWithSlash(makeMatches(20), 0); + const rows = slashRowsOf(f); + expect(rows).toHaveLength(6); const main = mainOf(f); - expect(main).toHaveLength(1 + 1 + 6 + 1); - const overflow = main.at(-1); - if (overflow?.kind !== "text") return; + const overflow = main.find((c) => c.kind === "text" && c.runs[0]?.text?.startsWith("…")); + if (overflow?.kind !== "text") throw new Error("expected overflow row"); expect(overflow.runs.map((r) => r.text).join("")).toContain("…14 more"); }); @@ -623,7 +631,7 @@ describe("buildTraceFrame", () => { it("clamps an out-of-range slashSelectedIndex", () => { const f = buildWithSlash(makeMatches(3), 99); - const rows = mainOf(f).slice(2); + const rows = slashRowsOf(f); const flat = rows.map((r) => (r.kind === "text" ? r.runs.map((x) => x.text).join("") : "")); expect(flat[2]?.startsWith("▸ /cmd2")).toBe(true); }); @@ -645,26 +653,32 @@ describe("buildTraceFrame approval modal", () => { ); } + function approvalRowOf(f: ReturnType) { + for (const c of mainOf(f)) { + if (c.kind === "text" && c.runs[0]?.text === "❓ ") return c; + } + throw new Error("no approval row found"); + } + it("replaces the composer row with an approval row when approvalPrompt is set", () => { const f = buildWithApproval("shell", "rm -rf /tmp/x"); - const main = mainOf(f); - expect(main).toHaveLength(2); - const row = main[1]; - if (row?.kind !== "text") return; + const row = approvalRowOf(f); const flat = row.runs.map((r) => r.text).join(""); expect(flat).toContain("❓"); expect(flat).toContain("[shell]"); expect(flat).toContain("rm -rf /tmp/x"); expect(flat).toContain("[y/n]"); - expect(flat).not.toContain("❯"); - expect(flat).not.toContain("typing…"); + const allFlat = mainOf(f) + .map((c) => (c.kind === "text" ? c.runs.map((r) => r.text).join("") : "")) + .join(" | "); + expect(allFlat).not.toContain("❯"); + expect(allFlat).not.toContain("typing…"); }); it("clips an overlong approval prompt to 60 chars with an ellipsis", () => { const long = "x".repeat(120); const f = buildWithApproval("shell", long); - const row = mainOf(f)[1]; - if (row?.kind !== "text") return; + const row = approvalRowOf(f); const promptRun = row.runs.find((r) => r.text.includes("x")); expect(promptRun?.text).toHaveLength(60); expect(promptRun?.text.endsWith("…")).toBe(true); @@ -672,8 +686,7 @@ describe("buildTraceFrame approval modal", () => { it("omits the kind tag when approvalKind is undefined", () => { const f = buildWithApproval(undefined, "go ahead?"); - const row = mainOf(f)[1]; - if (row?.kind !== "text") return; + const row = approvalRowOf(f); const flat = row.runs.map((r) => r.text).join(""); expect(flat).not.toContain("[]"); expect(flat).toContain("go ahead?"); @@ -694,7 +707,10 @@ describe("buildTraceFrame approval modal", () => { 80, 24, ); - expect(mainOf(f)).toHaveLength(2); + const flat = mainOf(f) + .map((c) => (c.kind === "text" ? c.runs.map((r) => r.text).join("") : "")) + .join(" | "); + expect(flat).not.toContain("/help"); }); }); @@ -721,20 +737,37 @@ describe("buildTraceFrame sessions picker", () => { ); } + function pickerHeaderOf(f: ReturnType) { + for (const c of mainOf(f)) { + if (c.kind === "text") { + const flat = c.runs.map((r) => r.text).join(""); + if (flat.includes("sessions") && flat.includes("saved")) return c; + } + } + throw new Error("no picker header row found"); + } + + function pickerSessionRows(f: ReturnType) { + return mainOf(f).filter((c) => { + if (c.kind !== "text") return false; + const t0 = c.runs[0]?.text; + return t0 === "▸ " || t0 === " "; + }); + } + it("replaces composer with a header + one row per session + a hint footer", () => { const f = buildWithSessions(makeSessions(3), 0); - const main = mainOf(f); - expect(main).toHaveLength(1 + 1 + 3 + 1); - const header = main[1]; - if (header?.kind !== "text") return; + const header = pickerHeaderOf(f); const headerFlat = header.runs.map((r) => r.text).join(""); expect(headerFlat).toContain("sessions"); expect(headerFlat).toContain("(3 saved)"); - const row0 = main[2]; + const rows = pickerSessionRows(f); + expect(rows).toHaveLength(3); + const row0 = rows[0]; if (row0?.kind !== "text") return; expect(row0.runs[0]?.text).toBe("▸ "); expect(row0.runs[1]?.text).toBe("session-0"); - const hint = main.at(-1); + const hint = mainOf(f).at(-1); if (hint?.kind !== "text") return; const hintFlat = hint.runs.map((r) => r.text).join(""); expect(hintFlat).toContain("navigate"); @@ -743,10 +776,9 @@ describe("buildTraceFrame sessions picker", () => { it("windows a long session list at MAX_SESSION_ROWS (8) with an overflow row", () => { const f = buildWithSessions(makeSessions(20), 15); - const main = mainOf(f); - expect(main).toHaveLength(1 + 1 + 8 + 1 + 1); - const overflow = main.at(-2); - if (overflow?.kind !== "text") return; + expect(pickerSessionRows(f)).toHaveLength(8); + const overflow = mainOf(f).find((c) => c.kind === "text" && c.runs[0]?.text?.startsWith("…")); + if (overflow?.kind !== "text") throw new Error("expected overflow row"); expect(overflow.runs.map((r) => r.text).join("")).toContain("…12 more"); }); @@ -774,7 +806,8 @@ describe("buildTraceFrame sessions picker", () => { it("renders meta after the title when given", () => { const f = buildWithSessions([{ title: "feat-foo", meta: "feat · 12 turns" }], 0); - const row = mainOf(f)[2]; + const rows = pickerSessionRows(f); + const row = rows[0]; if (row?.kind !== "text") return; const flat = row.runs.map((r) => r.text).join(""); expect(flat).toContain("feat-foo");