Skip to content

Commit dc4e79d

Browse files
committed
Wrap operator-question choices instead of ellipsizing them
The overlay was collapsing long option labels with a middle ellipsis, so interview and permission choices became unreadable. Choices now wrap to a shared height. ask_operator rejects labels over 48 characters and the interview skill puts trade-offs in the transcript, not in the option string.
1 parent afce776 commit dc4e79d

14 files changed

Lines changed: 293 additions & 100 deletions

File tree

docs/TUI.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,9 +317,12 @@ framed content in the shell, and they are shaped rather than merely listed
317317
subject in the action color — the only Breakthrough Orange on the card.
318318
The overlay host border and title use calm dim chrome (`UI.textDim`);
319319
consequence impact in the description zone paints `UI.warning` (sand), not
320-
orange. A blank row separates the subject from context, and each choice gets
321-
one row with the active choice marked by a solid block (``) rather than a
322-
background fill (cream text, not orange).
320+
orange. A blank row separates the subject from context. Choices wrap on word
321+
boundaries — never middle-ellipsized — to a shared row count at the current
322+
width (minimum two rows so short labels still breathe; a taller wrap raises
323+
every choice to the same height so list paging stays a simple multiple). The
324+
active choice is marked by a solid block (``) rather than a background fill
325+
(cream text, not orange).
323326

324327
## How selectors should work
325328

plugins/corbits-skills/skills/interview/SKILL.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ Each question is one `ask_operator` call: `question` (string) plus `options` (ar
3737

3838
**Quality bar for options:**
3939

40-
- Mutually exclusive and concrete — not "yes / no / maybe"
40+
- Mutually exclusive **short labels** — not "yes / no / maybe"
4141
- Each option a real, defensible choice — not a strawman
42-
- Put trade-offs in the option string itself ("simpler but less flexible", "consistent with existing patterns")`options` are strings, not `{ label, description }` objects
42+
- Trade-offs, rationale, and context go in the **preceding transcript reply**, then `ask_operator` with a brief question and brief labels. Do not put essays in the option string`options` are strings, not `{ label, description }` objects
4343
- Ground options in the topic and context — do not invent generic options when concrete ones exist
4444
- Combination options only when the dimension genuinely permits more than one answer
4545
- If you have a recommendation, put it first and label it
@@ -90,34 +90,34 @@ After emitting the findings, stop. Do not load other skills, invoke other agents
9090

9191
**Invocation:** `use_skill(name="interview")` with the topic in the conversation, or `/interview notification system; backend is Node/Postgres, internal users only, must integrate with existing auth`
9292

93-
**Round 1** (three parallel `ask_operator` calls — independent dimensions, so ask together):
93+
**Round 1** (three parallel `ask_operator` calls — independent dimensions, so ask together). Trade-offs belong in the transcript before the calls, not in the labels — e.g. "critical vs activity vs re-engagement; never-miss vs real-time vs per-event opt-in; in-app vs email vs webhook."
9494

9595
```
9696
ask_operator({
9797
question: "What is the primary goal of the notification system?",
9898
options: [
99-
"Alert on critical events — Errors, security issues, SLA breaches",
100-
"Keep users informed of activity — Mentions, replies, updates",
101-
"Drive user re-engagement — Digests, reminders, summaries"
99+
"Alert on critical events",
100+
"Keep users informed of activity",
101+
"Drive user re-engagement"
102102
]
103103
})
104104
105105
ask_operator({
106106
question: "If you had to pick one, which matters most?",
107107
options: [
108-
"Reliability of delivery (recommended) — Never miss a notification, even if delayed",
109-
"Latency — Real-time, even if some are dropped under load",
110-
"User control — Fine-grained per-event opt-in/out"
108+
"Reliability of delivery (recommended)",
109+
"Latency",
110+
"User control"
111111
]
112112
})
113113
114114
ask_operator({
115115
question: "Which delivery channels do you want?",
116116
options: [
117-
"In-app — Notification center in the UI",
118-
"Email — Per-event or digest",
117+
"In-app",
118+
"Email",
119119
"In-app + Email",
120-
"Webhook — Outbound HTTP to a user-configured endpoint",
120+
"Webhook",
121121
"All of the above"
122122
]
123123
})

src/agent/director.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export const askOperatorDefinition: ToolDefinition = {
118118
"Pause execution and ask the operator a short clarifying question with short option labels. " +
119119
"Put any long rationale, trade-offs, or context in a normal transcript reply first, then call this " +
120120
"with only a brief question and brief option labels — the overlay is not a place for essays. " +
121+
"Each option label must be at most 48 characters. " +
121122
"Execution resumes when the operator selects an option.",
122123
inputSchema: {
123124
type: "object",
@@ -129,8 +130,9 @@ export const askOperatorDefinition: ToolDefinition = {
129130
},
130131
options: {
131132
type: "array",
132-
description: "Short option labels the operator can choose from (keep each label brief)",
133-
items: { type: "string" },
133+
description:
134+
"Short option labels the operator can choose from (at most 48 characters each)",
135+
items: { type: "string", maxLength: 48 },
134136
minItems: 1,
135137
},
136138
},

src/agent/tools.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ const AskOperatorArgs = type({
8787
options: "string[]",
8888
});
8989

90+
/** Cap on each ask_operator option label (UTF-16 code units). */
91+
export const ASK_OPERATOR_OPTION_MAX_CHARS = 48;
92+
93+
/** Cap on the ask_operator question (UTF-16 code units). */
94+
export const ASK_OPERATOR_QUESTION_MAX_CHARS = 160;
95+
9096
const SubmitOutputArgs = type({
9197
"summary?": "string",
9298
"step?": "string",
@@ -451,6 +457,23 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
451457
if (options.length === 0) {
452458
return "Error: ask_operator requires at least one option.";
453459
}
460+
if (question.length > ASK_OPERATOR_QUESTION_MAX_CHARS) {
461+
return (
462+
`Error: ask_operator question is ${question.length} characters; ` +
463+
`keep it to ${ASK_OPERATOR_QUESTION_MAX_CHARS} or fewer. ` +
464+
"Put the essay in a transcript reply first, then retry with a brief question."
465+
);
466+
}
467+
for (let i = 0; i < options.length; i++) {
468+
const option = options[i] ?? "";
469+
if (option.length > ASK_OPERATOR_OPTION_MAX_CHARS) {
470+
return (
471+
`Error: ask_operator option ${i + 1} is ${option.length} characters; ` +
472+
`keep each label to ${ASK_OPERATOR_OPTION_MAX_CHARS} or fewer. ` +
473+
"Put the essay in a transcript reply first, then retry with short option labels."
474+
);
475+
}
476+
}
454477
const result = await onOperatorGate(question, options);
455478
if (result.kind === "cancel") {
456479
return "The operator dismissed the question without answering. Do not ask it again; proceed with your best judgment or continue with other work.";

src/director.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ describe("ask_operator definition", () => {
7777
expect(schema.properties).not.toHaveProperty("command");
7878
expect(askOperatorDefinition.description).not.toMatch(/pre-authoriz/i);
7979
expect(askOperatorDefinition.description).not.toMatch(/`command`/);
80+
expect(askOperatorDefinition.description).toMatch(/at most 48 characters/);
81+
const options = schema.properties?.options as { items?: { maxLength?: number } };
82+
expect(options.items?.maxLength).toBe(48);
8083
});
8184
});
8285

src/prompts.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,16 @@ test("buildAvailableTools lists exactly the tools it is given", () => {
266266
});
267267

268268
test("sub-agent prompt carries the report-back contract and harness facts", () => {
269-
const prompt = buildSubAgentSystemPrompt();
269+
// Context interpolates cwd. A worktree path containing "ask_operator" would
270+
// poison this check even when the prompt does not advertise the tool.
271+
const prompt = buildSubAgentSystemPrompt(undefined, {
272+
cwd: "/repo/root",
273+
platform: "Darwin 25.4.0",
274+
arch: "arm64",
275+
runtime: "Bun 1.2.0",
276+
date: new Date(2026, 5, 5),
277+
isGitRepo: false,
278+
});
270279
expect(prompt).toContain("short-lived child agent dispatched by Corbits Code");
271280
expect(prompt).toContain("Reporting back:");
272281
expect(prompt).toContain("only thing returned to the parent");

src/tui/gate-wire.test.ts

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -304,46 +304,49 @@ describe("wireGates", () => {
304304
});
305305

306306
test("permission.gate paints the collapsed body and expands it on toggle", async () => {
307-
await withTestRenderer(async (h) => {
308-
const shell = createAppShell(h.renderer, {
309-
terminal: { columns: 100, rows: 40 },
310-
run: "idle",
311-
});
312-
const emitter = new EventEmitter();
313-
const request: PermissionRequest = {
314-
tool: "run_shell",
315-
action: "Run shell command",
316-
subject: "echo start && cat > notes.txt <<EOF\nalpha\nbeta\nEOF",
317-
scopes: [],
318-
};
319-
try {
320-
const dispose = wireGates(emitter, shell);
321-
emitter.emit("permission.gate", { request, resolve: () => {} });
307+
await withTestRenderer(
308+
async (h) => {
309+
const shell = createAppShell(h.renderer, {
310+
terminal: { columns: 100, rows: 40 },
311+
run: "idle",
312+
});
313+
const emitter = new EventEmitter();
314+
const request: PermissionRequest = {
315+
tool: "run_shell",
316+
action: "Run shell command",
317+
subject: "echo start && cat > notes.txt <<EOF\nalpha\nbeta\nEOF",
318+
scopes: [],
319+
};
320+
try {
321+
const dispose = wireGates(emitter, shell);
322+
emitter.emit("permission.gate", { request, resolve: () => {} });
322323

323-
const collapsed = shell.overlayBodyLines.join("\n");
324-
expect(collapsed).toContain("1) echo start");
325-
expect(collapsed).toContain("<heredoc, 2 lines>");
326-
expect(collapsed).not.toContain("alpha");
324+
const collapsed = shell.overlayBodyLines.join("\n");
325+
expect(collapsed).toContain("1) echo start");
326+
expect(collapsed).toContain("<heredoc, 2 lines>");
327+
expect(collapsed).not.toContain("alpha");
327328

328-
expect(toggleOverlayExpand(shell)).toBe(true);
329-
const expanded = shell.overlayBodyLines.join("\n");
330-
expect(expanded).toContain("<heredoc, 2 lines>");
331-
expect(expanded).toContain("alpha");
332-
expect(expanded).toContain("beta");
329+
expect(toggleOverlayExpand(shell)).toBe(true);
330+
const expanded = shell.overlayBodyLines.join("\n");
331+
expect(expanded).toContain("<heredoc, 2 lines>");
332+
expect(expanded).toContain("alpha");
333+
expect(expanded).toContain("beta");
333334

334-
// Full text also lands in the scrollable transcript, which no
335-
// overlay height cap can clip.
336-
const streamed = shell.streamLog.map((r) => r.text).join("\n");
337-
expect(streamed).toContain("alpha");
335+
// Full text also lands in the scrollable transcript, which no
336+
// overlay height cap can clip.
337+
const streamed = shell.streamLog.map((r) => r.text).join("\n");
338+
expect(streamed).toContain("alpha");
338339

339-
expect(toggleOverlayExpand(shell)).toBe(true);
340-
expect(shell.overlayBodyLines.join("\n")).not.toContain("alpha");
340+
expect(toggleOverlayExpand(shell)).toBe(true);
341+
expect(shell.overlayBodyLines.join("\n")).not.toContain("alpha");
341342

342-
dispose();
343-
} finally {
344-
shell.dispose();
345-
}
346-
});
343+
dispose();
344+
} finally {
345+
shell.dispose();
346+
}
347+
},
348+
{ width: 100, height: 40 },
349+
);
347350
});
348351

349352
test("operator.gate opens overlay and resolves selection through onAccept", async () => {
@@ -1294,9 +1297,9 @@ describe("permission overlay height", () => {
12941297
const tall = await hostRowsFor(60, 1);
12951298
expect(short).toBe(tall);
12961299

1297-
// Two extra choices cost exactly two extra rows: the choices are
1298-
// single-spaced, so the list is one row per item.
1299-
expect(await hostRowsFor(60, 3)).toBe(tall + 2);
1300+
// Two extra choices cost exactly four extra rows: each choice occupies
1301+
// two rows (wrap padding), so the list is a simple multiple of item count.
1302+
expect(await hostRowsFor(60, 3)).toBe(tall + 4);
13001303
});
13011304

13021305
test("caps rather than growing, and the list scrolls inside the cap", async () => {

src/tui/landing.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ describe("landing screen", () => {
489489
await withTestRenderer(
490490
async (h) => {
491491
const shell = createAppShell(h.renderer, {
492-
terminal: { columns: 100, rows: 36 },
492+
terminal: { columns: 100, rows: 48 },
493493
wireKeys: false,
494494
run: "idle",
495495
});
@@ -509,7 +509,7 @@ describe("landing screen", () => {
509509
shell.dispose();
510510
}
511511
},
512-
{ width: 100, height: 36 },
512+
{ width: 100, height: 48 },
513513
);
514514
});
515515

src/tui/overlay-body-cache-staleness.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type AppShell,
1515
} from "./shell.js";
1616
import { openPermissionsOverlay, makePermissionItems } from "./overlays.js";
17+
import { DECISION_CHOICE_ROWS } from "./overlay-body.js";
1718

1819
function primeSession(shell: AppShell): void {
1920
appendStreamRow(shell, { role: "assistant", text: "session underway" });
@@ -24,17 +25,23 @@ describe("decision overlay body cache survives a stacked palette", () => {
2425
await withTestRenderer(
2526
async (h) => {
2627
const shell = createAppShell(h.renderer, {
27-
terminal: { columns: 80, rows: 24 },
28+
terminal: { columns: 80, rows: 32 },
2829
run: "idle",
2930
});
3031
try {
3132
primeSession(shell);
33+
const items = makePermissionItems(3);
3234
openPermissionsOverlay(shell, {
33-
items: makePermissionItems(3),
35+
items,
3436
body: "run_shell\nRun shell command\nSome context about the risky command.",
3537
});
3638
await h.renderOnce();
3739
expect(shell.overlayBodyLines.length).toBeGreaterThan(0);
40+
const hostBefore = shell.layout.overlayHeight;
41+
// Chrome is border (2) + title (1) + body lines; list is N * perItem.
42+
expect(hostBefore).toBe(
43+
shell.overlayBodyLines.length + 3 + items.length * DECISION_CHOICE_ROWS,
44+
);
3845

3946
// Stack a palette over the open permissions overlay — its own
4047
// (empty) body must not overwrite the approval's cached raw text.
@@ -50,6 +57,8 @@ describe("decision overlay body cache survives a stacked palette", () => {
5057
await h.renderOnce();
5158
expect(shell.overlayKind).toBe("permissions");
5259
expect(shell.overlayBodyLines.length).toBeGreaterThan(0);
60+
expect(shell.layout.overlayHeight).toBe(hostBefore);
61+
expect(shell.overlayList?.height).toBe(items.length);
5362

5463
// Resize: the body must still show the permission context, not be
5564
// blanked by re-shaping from the palette's stale empty cache.
@@ -62,7 +71,7 @@ describe("decision overlay body cache survives a stacked palette", () => {
6271
shell.dispose();
6372
}
6473
},
65-
{ width: 80, height: 24 },
74+
{ width: 80, height: 32 },
6675
);
6776
});
6877
});

0 commit comments

Comments
 (0)