From fffb2881a0d2dfc79fb49fa6cfec3c46c362db2b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 10:00:50 -0700 Subject: [PATCH 1/2] Calm decision-overlay orange so only the dithered subject spends it Demote overlay host border/title to textDim, paint consequence impact as warning, and drop the redundant operator-question title chrome. Lock overlay host borderColor and title fg to UI.textDim in overlays.test. --- docs/TUI.md | 17 ++++++++------ src/tui/description-zone.test.ts | 4 ++-- src/tui/landing.test.ts | 2 +- src/tui/overlays.test.ts | 40 ++++++++++++++++++++++++++++++-- src/tui/overlays.ts | 2 +- src/tui/shell.ts | 23 +++++++++++++----- 6 files changed, 69 insertions(+), 19 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index a57c17f03..a734dfc1d 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -161,10 +161,10 @@ Color is a small, deliberate palette, not decoration (`src/tui/theme.ts`). Dimmed text is a dimmed cream, never a neutral gray, so every emphasis level keeps the same warm hue. Orange (`UI.action`) is spent once per screen: it marks the session identity, -a leading `/command` or `@mention` in the prompt, and whatever is currently -awaiting a human decision (an approval subject, an active choice) — nothing -else competes with it. Standing caution (`mcp !`, `plugin !`, the context -meter's 61–80 band) uses `UI.warning`; the meter turns `UI.error` at 81–100. +a leading `/command` or `@mention` in the prompt, and the dithered subject +of a decision surface (permission or operator ask) — nothing else competes +with it. Standing caution (`mcp !`, `plugin !`, the context +meter's 61–80 band, consequence impact under a list) uses `UI.warning`; the meter turns `UI.error` at 81–100. Ongoing, non-decision status uses the bronze/sand/ember chrome ramp and green (`UI.done`) for completion. The one deliberate exception is diff removals, where orange is content (the @@ -292,9 +292,12 @@ approval. The decision surfaces (permission approval, operator question) are the one framed content in the shell, and they are shaped rather than merely listed (`src/tui/overlay-body.ts`): a dithered header (`░▒▓`) carries the -subject in the action color, a blank row separates it from context, and each -choice gets one row with the active choice marked by a solid block (`█`) -rather than a background fill. +subject in the action color — the only Breakthrough Orange on the card. +The overlay host border and title use calm dim chrome (`UI.textDim`); +consequence impact in the description zone paints `UI.warning` (sand), not +orange. A blank row separates the subject from context, and each choice gets +one row with the active choice marked by a solid block (`█`) rather than a +background fill (cream text, not orange). ## How selectors should work diff --git a/src/tui/description-zone.test.ts b/src/tui/description-zone.test.ts index e913430e4..77835a883 100644 --- a/src/tui/description-zone.test.ts +++ b/src/tui/description-zone.test.ts @@ -111,12 +111,12 @@ describe("describeZoneLines", () => { expect(fgs[1]).toBe(UI.textFaint); }); - test("consequence tone paints the impact line in UI.action", () => { + test("consequence tone paints the impact line in UI.warning", () => { const { fgs } = describeZoneLines( { what: "sub-agent cap.", impact: "raising it spends more tokens.", tone: "consequence" }, 60, ); - expect(fgs[1]).toBe(UI.action); + expect(fgs[1]).toBe(UI.warning); }); test("a what that wraps to both lines drops impact, same as narrow width would", () => { diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 8caf6052f..2173c1aac 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -442,7 +442,7 @@ describe("landing screen", () => { expect(nowAt.every((index) => index > 0)).toBe(true); expect(nowAt).toEqual([...nowAt].sort((a, b) => a - b)); expect(new Set(nowAt).size).toBe(nowAt.length); - expect(h.captureCharFrame()).toContain("operator"); + expect(h.captureCharFrame()).toContain("Esc cancel"); } finally { shell.dispose(); } diff --git a/src/tui/overlays.test.ts b/src/tui/overlays.test.ts index 5e7d610cb..87982755c 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -2,6 +2,7 @@ * Wave 5: primary overlays — open / navigate / Esc restore + resize floors. */ import { describe, expect, test } from "bun:test"; +import { rgbToHex } from "@opentui/core"; import { IDLE_TRANSCRIPT_FLOOR, OVERLAY_TRANSCRIPT_FLOOR } from "./geometry/index"; import { focusOwner, scrollLease } from "./focus/index"; import { withTestRenderer } from "./harness"; @@ -25,6 +26,38 @@ import { type OverlaySelection, } from "./shell"; import { visibleSlice } from "./list-viewport"; +import { UI } from "./theme"; + +function colorHex(c: unknown): string { + if (typeof c === "string") return c.toLowerCase(); + return rgbToHex(c as Parameters[0]) + .toLowerCase() + .slice(0, 7); +} + +describe("overlay host chrome", () => { + test("border and title stay textDim after create and open", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }); + try { + expect(colorHex(shell.overlayHost.borderColor)).toBe(UI.textDim); + expect(colorHex(shell.overlayTitle.fg)).toBe(UI.textDim); + + openOperatorOverlay(shell); + expect(colorHex(shell.overlayHost.borderColor)).toBe(UI.textDim); + expect(colorHex(shell.overlayTitle.fg)).toBe(UI.textDim); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); +}); describe("wrapOverlayBody", () => { test("splits long lines and caps", () => { @@ -165,13 +198,16 @@ describe("operator question overlay", () => { await h.renderOnce(); const frame = h.captureCharFrame(); - expect(frame).toContain("operator"); - // Body fragment visible + // Title chrome dropped — subject + hints carry the ask. + expect(frame).not.toContain("operator question"); + // Body / subject fragment visible expect(frame).toMatch(/destructive|working tree|git reset/i); // Choice visible expect(frame).toMatch(/Cancel|Allow/); // The overlay carries its own keys now that there is no hint strip. expect(frame).toContain("Esc cancel"); + // Empty title must not leave a leading middle-dot before the hints. + expect(frame).not.toMatch(/·\s*Esc cancel/); // Esc restore: closeInsetOverlay is the Esc path (same as key handler). closeInsetOverlay(shell); diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index f014310a9..fc3fe0784 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -143,7 +143,7 @@ export function openOperatorOverlay(shell: AppShell, opts?: OpenOperatorOpts): v const stranded = choices.length === 0 && opts?.onTextAnswer === undefined; openListOverlay(shell, { kind: "operator", - title: "operator question", + title: "", body: stranded ? `${body}\n\n${NO_WAY_TO_ANSWER}` : body, items: choices, activeIndex: opts?.activeIndex ?? 0, diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 30b87f97a..006afb378 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -261,7 +261,8 @@ export interface ItemDescription { readonly what: string; /** What choosing it costs or changes. One line. Omit when there is nothing true to say. */ readonly impact?: string; - /** "consequence" paints impact in UI.action — billing, trust, anything that spends or extends reach. */ + /** "consequence" paints impact in UI.warning — billing, trust, anything that spends or extends reach. */ + readonly tone?: "plain" | "consequence"; } @@ -1280,12 +1281,21 @@ function overlayTitleLine( interior: number, hints: readonly string[] = DEFAULT_OVERLAY_HINTS, ): string { + const trimmed = title.trim(); + // Empty/blank title: paint hints alone — no leading " · " from a missing title. + if (trimmed.length === 0) { + for (const hint of hints) { + const line = ` ${hint}`; + if (line.length <= interior) return line; + } + return " "; + } const suffixes = [...hints.map((h) => ` · ${h}`), ""]; for (const suffix of suffixes) { - const line = ` ${title}${suffix}`; + const line = ` ${trimmed}${suffix}`; if (line.length <= interior) return line; } - return ` ${middleEllipsis(title, Math.max(1, interior - 1))}`; + return ` ${middleEllipsis(trimmed, Math.max(1, interior - 1))}`; } const DEFAULT_OVERLAY_HINTS = ["Esc cancel · Enter choose", "Esc · Enter"] as const; @@ -1419,7 +1429,8 @@ export function describeZoneLines( fgs.push(UI.textDim); } if (desc.impact !== undefined && width >= DESCRIPTION_ZONE_IMPACT_MIN_WIDTH) { - const impactFg = desc.tone === "consequence" ? UI.action : UI.textFaint; + const impactFg = desc.tone === "consequence" ? UI.warning : UI.textFaint; + for (const line of wrapWords(desc.impact, width)) { if (lines.length >= DESCRIPTION_ZONE_LINES) break; lines.push(line); @@ -5401,14 +5412,14 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption // which would leave a half-overlay the operator cannot dismiss. overflow: "hidden", border: true, - borderColor: UI.action, + borderColor: UI.textDim, backgroundColor: UI.ground, visible: false, }); const overlayTitle = new TextRenderable(ctx, { id: "shell-overlay-title", content: " overlay", - fg: UI.action, + fg: UI.textDim, }); const overlayBody = new BoxRenderable(ctx, { id: "shell-overlay-body", From 9454ea5db127b710c65c4b8d35494c27624bc644 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 11:33:29 -0700 Subject: [PATCH 2/2] Harden landing notice gutter assert against cwd paths Full-frame not.toContain("overlay") false-positives when the worktree path itself contains that substring in chrome. Assert on the flushed notice row model and painted notice lines instead. --- src/tui/landing.test.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 2173c1aac..998890cd6 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -661,10 +661,23 @@ describe("landing screen", () => { await settle(h); expect(isLanding(shell)).toBe(false); - const frame = h.captureCharFrame(); - expect(frame).toContain("mcp github did not connect"); - expect(frame).not.toContain("command"); - expect(frame).not.toContain("overlay"); + // Assert on the flushed notice row(s) — not the full char frame. The + // footer/chrome can echo the process cwd, and a worktree path that + // happens to contain "overlay" (or "command") must not false-positive + // the plumbing-label invariant. + const noticeNeedle = "mcp github did not connect"; + const noticeRows = shell.streamLog.filter((row) => row.text.includes(noticeNeedle)); + expect(noticeRows.length).toBeGreaterThan(0); + for (const row of noticeRows) { + expect(row.meta).not.toBe("command"); + expect(row.meta).not.toBe("overlay"); + } + const painted = rows(h).filter((line) => line.includes(noticeNeedle)); + expect(painted.length).toBeGreaterThan(0); + for (const line of painted) { + expect(line).not.toContain("command"); + expect(line).not.toContain("overlay"); + } } finally { shell.dispose(); }