Skip to content

Commit 9735b90

Browse files
committed
Stop labelling transcript rows with the permission subsystem
The expand dump's body already names the tool; the gutter is the operator's column, not the wiring's. A closed-set test is the lock the previous two audits lacked.
1 parent df0dbe4 commit 9735b90

5 files changed

Lines changed: 168 additions & 6 deletions

File tree

docs/TUI.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,9 @@ outcome. Grey `permission` / `operator` recap cards restated the same ask
311311
after it was already decided. Expanding a collapsed payload while the
312312
overlay is open still writes the full payload into the scrollable
313313
transcript, because that text would otherwise be unreachable before
314-
approval.
314+
approval. That dump carries no gutter label; transcript gutter labels
315+
are a closed operator-facing set (`src/tui/gutter-labels.test.ts` is
316+
the lock).
315317

316318
The decision surfaces (permission approval, operator question) are the one
317319
framed content in the shell, and they are shaped rather than merely listed

src/tui/gate-wire.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
permissionChoicesFromRequest,
3131
wireGates,
3232
} from "./gate-wire.js";
33+
import { streamRowGutter } from "./stream.js";
3334

3435
const baseRequest = (overrides: Partial<PermissionRequest> = {}): PermissionRequest => ({
3536
tool: "run_shell",
@@ -337,6 +338,13 @@ describe("wireGates", () => {
337338
const streamed = shell.streamLog.map((r) => r.text).join("\n");
338339
expect(streamed).toContain("alpha");
339340

341+
const dumped = shell.streamLog.filter((r) => r.text.includes("alpha"));
342+
expect(dumped.length).toBeGreaterThan(0);
343+
for (const row of dumped) {
344+
expect(row.meta).toBeUndefined();
345+
expect(streamRowGutter(row, { width: 80, multiAgent: false }).content).toBe("");
346+
}
347+
340348
expect(toggleOverlayExpand(shell)).toBe(true);
341349
expect(shell.overlayBodyLines.join("\n")).not.toContain("alpha");
342350

src/tui/gate-wire.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,6 @@ export function wireGates(
330330
appendStreamRow(shell, {
331331
role: "system",
332332
text: permissionBodyFromRequest(ev.request, { expanded: true }),
333-
meta: "permission",
334333
});
335334
};
336335

src/tui/gutter-labels.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* The transcript never labels a row with the machinery that produced it.
3+
* Adding a painted chrome label means editing this list on purpose.
4+
*/
5+
import { join } from "node:path";
6+
import { describe, expect, test } from "bun:test";
7+
import { withTestRenderer } from "./harness.js";
8+
import type { PrimaryOverlayKind } from "./overlays.js";
9+
import {
10+
makePermissionItems,
11+
openModelPickerOverlay,
12+
openOperatorOverlay,
13+
openPermissionsOverlay,
14+
} from "./overlays.js";
15+
import { acceptOverlaySelection, createAppShell, type AppShell } from "./shell.js";
16+
import { streamRowGutter, type RowLayout } from "./stream.js";
17+
18+
const OVERLAY_KIND_GUTTER = {
19+
permissions: "permissions",
20+
operator: "operator",
21+
model_picker: "model picker",
22+
add_provider: "add provider",
23+
demo: "demo",
24+
palette: "palette",
25+
settings: "settings",
26+
help: "help",
27+
plugins: "plugins",
28+
resume: "resume",
29+
mentions: "mentions",
30+
copy: "copy",
31+
hooks: "hooks",
32+
mcp: "mcp",
33+
plugin_credentials: "plugin credentials",
34+
} as const satisfies Record<PrimaryOverlayKind, string>;
35+
36+
const CHROME_LITERALS = ["error", "plan", "report", "stop", "observe"] as const;
37+
38+
const PERMITTED_CHROME_GUTTER_LABELS = [...CHROME_LITERALS, ...Object.values(OVERLAY_KIND_GUTTER)];
39+
40+
const STORED_NOT_GUTTER = [
41+
"thinking",
42+
"steer",
43+
"queue",
44+
"steering",
45+
"following-up",
46+
"reinject",
47+
"cancelled",
48+
];
49+
50+
const FORBIDDEN = ["permission", "command", "overlay"];
51+
52+
const LAYOUT: RowLayout = { width: 80, multiAgent: false };
53+
54+
const IMMEDIATE_META = /meta:\s*["']([^"']+)["']/g;
55+
const TERNARY_META = /meta:\s*[^,\n]+\?\s*["']([^"']+)["']\s*:\s*["']([^"']+)["']/g;
56+
57+
function sortedSet(values: Iterable<string>): string[] {
58+
return [...new Set(values)].sort();
59+
}
60+
61+
async function assertEchoRecap(open: (shell: AppShell) => void, word: string): Promise<void> {
62+
await withTestRenderer(
63+
async (h) => {
64+
const shell = createAppShell(h.renderer, {
65+
terminal: { columns: 80, rows: 24 },
66+
wireKeys: false,
67+
});
68+
try {
69+
open(shell);
70+
acceptOverlaySelection(shell);
71+
const row = shell.streamLog.at(-1);
72+
expect(row).toBeDefined();
73+
if (row === undefined) return;
74+
expect(row.meta).toBe(word);
75+
expect(FORBIDDEN).not.toContain(row.meta);
76+
expect(streamRowGutter(row, LAYOUT).content.trim()).toBe(word);
77+
} finally {
78+
shell.dispose();
79+
}
80+
},
81+
{ width: 80, height: 24 },
82+
);
83+
}
84+
85+
describe("transcript gutter labels", () => {
86+
test("production meta literals are a closed operator-facing set", async () => {
87+
const tuiDir = import.meta.dirname;
88+
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan(tuiDir));
89+
const captured = new Set<string>();
90+
for (const relative of files) {
91+
const base = relative.split("/").pop() ?? relative;
92+
if (base.endsWith(".test.ts") || base === "demo.ts") continue;
93+
const source = await Bun.file(join(tuiDir, relative)).text();
94+
for (const match of source.matchAll(IMMEDIATE_META)) {
95+
const token = match[1];
96+
if (token !== undefined) captured.add(token);
97+
}
98+
for (const match of source.matchAll(TERNARY_META)) {
99+
if (match[1] !== undefined) captured.add(match[1]);
100+
if (match[2] !== undefined) captured.add(match[2]);
101+
}
102+
}
103+
104+
expect(FORBIDDEN.filter((token) => captured.has(token))).toEqual([]);
105+
const permitted = new Set<string>(PERMITTED_CHROME_GUTTER_LABELS);
106+
expect(FORBIDDEN.filter((token) => permitted.has(token))).toEqual([]);
107+
108+
// Painted overlay words are decided here, not inferred from the literal scan.
109+
expect(sortedSet(Object.values(OVERLAY_KIND_GUTTER))).toEqual(
110+
sortedSet(Object.keys(OVERLAY_KIND_GUTTER).map((kind) => kind.replace(/_/g, " "))),
111+
);
112+
113+
expect(sortedSet(captured)).toEqual(sortedSet([...CHROME_LITERALS, ...STORED_NOT_GUTTER]));
114+
});
115+
116+
test("an expand dump with no meta paints an empty gutter", () => {
117+
expect(streamRowGutter({ role: "system", text: "payload" }, LAYOUT).content).toBe("");
118+
});
119+
120+
test("thinking rows paint an empty gutter", () => {
121+
expect(
122+
streamRowGutter({ role: "system", text: "chain of thought", meta: "thinking" }, LAYOUT)
123+
.content,
124+
).toBe("");
125+
});
126+
127+
test("permitted chrome paints in the gutter", () => {
128+
const gutter = streamRowGutter({ role: "system", text: "failed", meta: "error" }, LAYOUT);
129+
expect(gutter.content.length).toBeGreaterThan(0);
130+
expect(gutter.content.trim()).toBe("error");
131+
});
132+
133+
test("a default-echo permissions recap paints the overlay word", async () => {
134+
await assertEchoRecap(
135+
(shell) => openPermissionsOverlay(shell, { items: makePermissionItems(3) }),
136+
OVERLAY_KIND_GUTTER.permissions,
137+
);
138+
});
139+
140+
test("a default-echo operator recap paints the overlay word", async () => {
141+
await assertEchoRecap(
142+
(shell) => openOperatorOverlay(shell, { choices: ["A", "B"] }),
143+
OVERLAY_KIND_GUTTER.operator,
144+
);
145+
});
146+
147+
test("a default-echo model-picker recap paints the overlay word", async () => {
148+
await assertEchoRecap(
149+
(shell) => openModelPickerOverlay(shell, { items: ["claude-sonnet-4"] }),
150+
OVERLAY_KIND_GUTTER.model_picker,
151+
);
152+
});
153+
});

src/tui/landing.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -676,10 +676,10 @@ describe("landing screen", () => {
676676
});
677677

678678
test("a flushed startup notice never carries a plumbing gutter label", async () => {
679-
// The transcript must never label a row "command": a system row's text
680-
// already says what it is, and the meta column is the operator's, not the
681-
// wiring's. (MCP notices still use the notice strip; plugin skill-miss
682-
// summaries do not.)
679+
// Flushed startup notices still deny the plumbing labels `command` and
680+
// `overlay` on this path. The product-wide lock is gutter-labels.test.ts.
681+
// (MCP notices still use the notice strip; plugin skill-miss summaries
682+
// do not.)
683683
await withTestRenderer(async (h) => {
684684
const shell = createAppShell(h.renderer, {
685685
terminal: { columns: 80, rows: 24 },

0 commit comments

Comments
 (0)