Skip to content

Commit d7f01a7

Browse files
Calm decision-overlay orange so only the dithered subject spends it (#633)
* 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. * 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.
1 parent eaef326 commit d7f01a7

6 files changed

Lines changed: 86 additions & 23 deletions

File tree

docs/TUI.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,10 @@ Color is a small, deliberate palette, not decoration
161161
(`src/tui/theme.ts`). Dimmed text is a dimmed cream, never a neutral
162162
gray, so every emphasis level keeps the same warm hue. Orange
163163
(`UI.action`) is spent once per screen: it marks the session identity,
164-
a leading `/command` or `@mention` in the prompt, and whatever is currently
165-
awaiting a human decision (an approval subject, an active choice) — nothing
166-
else competes with it. Standing caution (`mcp !`, `plugin !`, the context
167-
meter's 61–80 band) uses `UI.warning`; the meter turns `UI.error` at 81–100.
164+
a leading `/command` or `@mention` in the prompt, and the dithered subject
165+
of a decision surface (permission or operator ask) — nothing else competes
166+
with it. Standing caution (`mcp !`, `plugin !`, the context
167+
meter's 61–80 band, consequence impact under a list) uses `UI.warning`; the meter turns `UI.error` at 81–100.
168168
Ongoing, non-decision status uses the bronze/sand/ember chrome ramp and green
169169
(`UI.done`) for completion.
170170
The one deliberate exception is diff removals, where orange is content (the
@@ -292,9 +292,12 @@ approval.
292292
The decision surfaces (permission approval, operator question) are the one
293293
framed content in the shell, and they are shaped rather than merely listed
294294
(`src/tui/overlay-body.ts`): a dithered header (`░▒▓`) carries the
295-
subject in the action color, a blank row separates it from context, and each
296-
choice gets one row with the active choice marked by a solid block (``)
297-
rather than a background fill.
295+
subject in the action color — the only Breakthrough Orange on the card.
296+
The overlay host border and title use calm dim chrome (`UI.textDim`);
297+
consequence impact in the description zone paints `UI.warning` (sand), not
298+
orange. A blank row separates the subject from context, and each choice gets
299+
one row with the active choice marked by a solid block (``) rather than a
300+
background fill (cream text, not orange).
298301

299302
## How selectors should work
300303

src/tui/description-zone.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,12 @@ describe("describeZoneLines", () => {
111111
expect(fgs[1]).toBe(UI.textFaint);
112112
});
113113

114-
test("consequence tone paints the impact line in UI.action", () => {
114+
test("consequence tone paints the impact line in UI.warning", () => {
115115
const { fgs } = describeZoneLines(
116116
{ what: "sub-agent cap.", impact: "raising it spends more tokens.", tone: "consequence" },
117117
60,
118118
);
119-
expect(fgs[1]).toBe(UI.action);
119+
expect(fgs[1]).toBe(UI.warning);
120120
});
121121

122122
test("a what that wraps to both lines drops impact, same as narrow width would", () => {

src/tui/landing.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ describe("landing screen", () => {
442442
expect(nowAt.every((index) => index > 0)).toBe(true);
443443
expect(nowAt).toEqual([...nowAt].sort((a, b) => a - b));
444444
expect(new Set(nowAt).size).toBe(nowAt.length);
445-
expect(h.captureCharFrame()).toContain("operator");
445+
expect(h.captureCharFrame()).toContain("Esc cancel");
446446
} finally {
447447
shell.dispose();
448448
}
@@ -661,10 +661,23 @@ describe("landing screen", () => {
661661
await settle(h);
662662

663663
expect(isLanding(shell)).toBe(false);
664-
const frame = h.captureCharFrame();
665-
expect(frame).toContain("mcp github did not connect");
666-
expect(frame).not.toContain("command");
667-
expect(frame).not.toContain("overlay");
664+
// Assert on the flushed notice row(s) — not the full char frame. The
665+
// footer/chrome can echo the process cwd, and a worktree path that
666+
// happens to contain "overlay" (or "command") must not false-positive
667+
// the plumbing-label invariant.
668+
const noticeNeedle = "mcp github did not connect";
669+
const noticeRows = shell.streamLog.filter((row) => row.text.includes(noticeNeedle));
670+
expect(noticeRows.length).toBeGreaterThan(0);
671+
for (const row of noticeRows) {
672+
expect(row.meta).not.toBe("command");
673+
expect(row.meta).not.toBe("overlay");
674+
}
675+
const painted = rows(h).filter((line) => line.includes(noticeNeedle));
676+
expect(painted.length).toBeGreaterThan(0);
677+
for (const line of painted) {
678+
expect(line).not.toContain("command");
679+
expect(line).not.toContain("overlay");
680+
}
668681
} finally {
669682
shell.dispose();
670683
}

src/tui/overlays.test.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Wave 5: primary overlays — open / navigate / Esc restore + resize floors.
33
*/
44
import { describe, expect, test } from "bun:test";
5+
import { rgbToHex } from "@opentui/core";
56
import { IDLE_TRANSCRIPT_FLOOR, OVERLAY_TRANSCRIPT_FLOOR } from "./geometry/index";
67
import { focusOwner, scrollLease } from "./focus/index";
78
import { withTestRenderer } from "./harness";
@@ -25,6 +26,38 @@ import {
2526
type OverlaySelection,
2627
} from "./shell";
2728
import { visibleSlice } from "./list-viewport";
29+
import { UI } from "./theme";
30+
31+
function colorHex(c: unknown): string {
32+
if (typeof c === "string") return c.toLowerCase();
33+
return rgbToHex(c as Parameters<typeof rgbToHex>[0])
34+
.toLowerCase()
35+
.slice(0, 7);
36+
}
37+
38+
describe("overlay host chrome", () => {
39+
test("border and title stay textDim after create and open", async () => {
40+
await withTestRenderer(
41+
async (h) => {
42+
const shell = createAppShell(h.renderer, {
43+
terminal: { columns: 80, rows: 24 },
44+
wireKeys: false,
45+
});
46+
try {
47+
expect(colorHex(shell.overlayHost.borderColor)).toBe(UI.textDim);
48+
expect(colorHex(shell.overlayTitle.fg)).toBe(UI.textDim);
49+
50+
openOperatorOverlay(shell);
51+
expect(colorHex(shell.overlayHost.borderColor)).toBe(UI.textDim);
52+
expect(colorHex(shell.overlayTitle.fg)).toBe(UI.textDim);
53+
} finally {
54+
shell.dispose();
55+
}
56+
},
57+
{ width: 80, height: 24 },
58+
);
59+
});
60+
});
2861

2962
describe("wrapOverlayBody", () => {
3063
test("splits long lines and caps", () => {
@@ -165,13 +198,16 @@ describe("operator question overlay", () => {
165198

166199
await h.renderOnce();
167200
const frame = h.captureCharFrame();
168-
expect(frame).toContain("operator");
169-
// Body fragment visible
201+
// Title chrome dropped — subject + hints carry the ask.
202+
expect(frame).not.toContain("operator question");
203+
// Body / subject fragment visible
170204
expect(frame).toMatch(/destructive|working tree|git reset/i);
171205
// Choice visible
172206
expect(frame).toMatch(/Cancel|Allow/);
173207
// The overlay carries its own keys now that there is no hint strip.
174208
expect(frame).toContain("Esc cancel");
209+
// Empty title must not leave a leading middle-dot before the hints.
210+
expect(frame).not.toMatch(/·\s*Esc cancel/);
175211

176212
// Esc restore: closeInsetOverlay is the Esc path (same as key handler).
177213
closeInsetOverlay(shell);

src/tui/overlays.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ export function openOperatorOverlay(shell: AppShell, opts?: OpenOperatorOpts): v
143143
const stranded = choices.length === 0 && opts?.onTextAnswer === undefined;
144144
openListOverlay(shell, {
145145
kind: "operator",
146-
title: "operator question",
146+
title: "",
147147
body: stranded ? `${body}\n\n${NO_WAY_TO_ANSWER}` : body,
148148
items: choices,
149149
activeIndex: opts?.activeIndex ?? 0,

src/tui/shell.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,8 @@ export interface ItemDescription {
262262
readonly what: string;
263263
/** What choosing it costs or changes. One line. Omit when there is nothing true to say. */
264264
readonly impact?: string;
265-
/** "consequence" paints impact in UI.action — billing, trust, anything that spends or extends reach. */
265+
/** "consequence" paints impact in UI.warning — billing, trust, anything that spends or extends reach. */
266+
266267
readonly tone?: "plain" | "consequence";
267268
}
268269

@@ -1303,12 +1304,21 @@ function overlayTitleLine(
13031304
interior: number,
13041305
hints: readonly string[] = DEFAULT_OVERLAY_HINTS,
13051306
): string {
1307+
const trimmed = title.trim();
1308+
// Empty/blank title: paint hints alone — no leading " · " from a missing title.
1309+
if (trimmed.length === 0) {
1310+
for (const hint of hints) {
1311+
const line = ` ${hint}`;
1312+
if (line.length <= interior) return line;
1313+
}
1314+
return " ";
1315+
}
13061316
const suffixes = [...hints.map((h) => ` · ${h}`), ""];
13071317
for (const suffix of suffixes) {
1308-
const line = ` ${title}${suffix}`;
1318+
const line = ` ${trimmed}${suffix}`;
13091319
if (line.length <= interior) return line;
13101320
}
1311-
return ` ${middleEllipsis(title, Math.max(1, interior - 1))}`;
1321+
return ` ${middleEllipsis(trimmed, Math.max(1, interior - 1))}`;
13121322
}
13131323

13141324
const DEFAULT_OVERLAY_HINTS = ["Esc cancel · Enter choose", "Esc · Enter"] as const;
@@ -1442,7 +1452,8 @@ export function describeZoneLines(
14421452
fgs.push(UI.textDim);
14431453
}
14441454
if (desc.impact !== undefined && width >= DESCRIPTION_ZONE_IMPACT_MIN_WIDTH) {
1445-
const impactFg = desc.tone === "consequence" ? UI.action : UI.textFaint;
1455+
const impactFg = desc.tone === "consequence" ? UI.warning : UI.textFaint;
1456+
14461457
for (const line of wrapWords(desc.impact, width)) {
14471458
if (lines.length >= DESCRIPTION_ZONE_LINES) break;
14481459
lines.push(line);
@@ -5431,14 +5442,14 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption
54315442
// which would leave a half-overlay the operator cannot dismiss.
54325443
overflow: "hidden",
54335444
border: true,
5434-
borderColor: UI.action,
5445+
borderColor: UI.textDim,
54355446
backgroundColor: UI.ground,
54365447
visible: false,
54375448
});
54385449
const overlayTitle = new TextRenderable(ctx, {
54395450
id: "shell-overlay-title",
54405451
content: " overlay",
5441-
fg: UI.action,
5452+
fg: UI.textDim,
54425453
});
54435454
const overlayBody = new BoxRenderable(ctx, {
54445455
id: "shell-overlay-body",

0 commit comments

Comments
 (0)