Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/tui/description-zone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
23 changes: 18 additions & 5 deletions src/tui/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
}
Expand Down
40 changes: 38 additions & 2 deletions src/tui/overlays.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<typeof rgbToHex>[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", () => {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/tui/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 17 additions & 6 deletions src/tui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
Loading