Skip to content

Commit fffb288

Browse files
committed
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.
1 parent 02a3f85 commit fffb288

6 files changed

Lines changed: 69 additions & 19 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: 1 addition & 1 deletion
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
}

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
@@ -261,7 +261,8 @@ export interface ItemDescription {
261261
readonly what: string;
262262
/** What choosing it costs or changes. One line. Omit when there is nothing true to say. */
263263
readonly impact?: string;
264-
/** "consequence" paints impact in UI.action — billing, trust, anything that spends or extends reach. */
264+
/** "consequence" paints impact in UI.warning — billing, trust, anything that spends or extends reach. */
265+
265266
readonly tone?: "plain" | "consequence";
266267
}
267268

@@ -1280,12 +1281,21 @@ function overlayTitleLine(
12801281
interior: number,
12811282
hints: readonly string[] = DEFAULT_OVERLAY_HINTS,
12821283
): string {
1284+
const trimmed = title.trim();
1285+
// Empty/blank title: paint hints alone — no leading " · " from a missing title.
1286+
if (trimmed.length === 0) {
1287+
for (const hint of hints) {
1288+
const line = ` ${hint}`;
1289+
if (line.length <= interior) return line;
1290+
}
1291+
return " ";
1292+
}
12831293
const suffixes = [...hints.map((h) => ` · ${h}`), ""];
12841294
for (const suffix of suffixes) {
1285-
const line = ` ${title}${suffix}`;
1295+
const line = ` ${trimmed}${suffix}`;
12861296
if (line.length <= interior) return line;
12871297
}
1288-
return ` ${middleEllipsis(title, Math.max(1, interior - 1))}`;
1298+
return ` ${middleEllipsis(trimmed, Math.max(1, interior - 1))}`;
12891299
}
12901300

12911301
const DEFAULT_OVERLAY_HINTS = ["Esc cancel · Enter choose", "Esc · Enter"] as const;
@@ -1419,7 +1429,8 @@ export function describeZoneLines(
14191429
fgs.push(UI.textDim);
14201430
}
14211431
if (desc.impact !== undefined && width >= DESCRIPTION_ZONE_IMPACT_MIN_WIDTH) {
1422-
const impactFg = desc.tone === "consequence" ? UI.action : UI.textFaint;
1432+
const impactFg = desc.tone === "consequence" ? UI.warning : UI.textFaint;
1433+
14231434
for (const line of wrapWords(desc.impact, width)) {
14241435
if (lines.length >= DESCRIPTION_ZONE_LINES) break;
14251436
lines.push(line);
@@ -5401,14 +5412,14 @@ export function createAppShell(renderer: ShellRenderer, options?: AppShellOption
54015412
// which would leave a half-overlay the operator cannot dismiss.
54025413
overflow: "hidden",
54035414
border: true,
5404-
borderColor: UI.action,
5415+
borderColor: UI.textDim,
54055416
backgroundColor: UI.ground,
54065417
visible: false,
54075418
});
54085419
const overlayTitle = new TextRenderable(ctx, {
54095420
id: "shell-overlay-title",
54105421
content: " overlay",
5411-
fg: UI.action,
5422+
fg: UI.textDim,
54125423
});
54135424
const overlayBody = new BoxRenderable(ctx, {
54145425
id: "shell-overlay-body",

0 commit comments

Comments
 (0)