From c4efc0fd328a13057e9181a13158b677a7481742 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 12:27:39 -0700 Subject: [PATCH 1/4] Make ask_operator use a full-shell overlay Inset mode clipped long questions and option lists. Operator asks now open full_shell; permission gates stay inset. Agents must put long rationale in chat first, then ask with short labels. --- src/agent/director.ts | 10 +++++-- src/agent/directors/skywalker/package.ts | 4 +-- src/agent/prompt-contract.ts | 1 + src/agent/prompts.ts | 4 ++- src/tui/overlays.test.ts | 1 + src/tui/overlays.ts | 3 ++ src/tui/shell.ts | 35 ++++++++++++++++++++++-- 7 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/agent/director.ts b/src/agent/director.ts index 87b1e3cfa..d0566614d 100644 --- a/src/agent/director.ts +++ b/src/agent/director.ts @@ -114,7 +114,10 @@ const PathArgSchema = type({ path: "string" }); export const askOperatorDefinition: ToolDefinition = { name: "ask_operator", description: - "Pause execution and ask the operator a clarifying question. Execution resumes when the operator selects an option. " + + "Pause execution and ask the operator a short clarifying question with short option labels. " + + "Put any long rationale, trade-offs, or context in a normal transcript reply first, then call this " + + "with only a brief question and brief option labels — the overlay is not a place for essays. " + + "Execution resumes when the operator selects an option. " + "If the question is really asking permission to run one specific shell command, pass that exact command as `command` " + "instead of just describing it in the option text — approval here then covers the matching run_shell call too, so the " + "operator is not asked to approve the same action twice.", @@ -123,11 +126,12 @@ export const askOperatorDefinition: ToolDefinition = { properties: { question: { type: "string", - description: "The question to ask the operator", + description: + "Short question shown in the overlay (one or two lines). Put long rationale in a transcript reply first.", }, options: { type: "array", - description: "List of options the operator can choose from", + description: "Short option labels the operator can choose from (keep each label brief)", items: { type: "string" }, minItems: 1, }, diff --git a/src/agent/directors/skywalker/package.ts b/src/agent/directors/skywalker/package.ts index 9d5bdec90..351a5c8a0 100644 --- a/src/agent/directors/skywalker/package.ts +++ b/src/agent/directors/skywalker/package.ts @@ -54,7 +54,7 @@ Quick routing: - After multi-file builder landings → default a critic (or greybeard when architecture is in play) on the diff/criteria in a fresh context Prefer typed spawn: intent, success_criteria, do_not, report_focus, agent when specialist. -Parallelize independent lanes with spawn_agent, then wait_agents. manage_tasks for your checklist. ask_operator when blocked or ambiguous. +Parallelize independent lanes with spawn_agent, then wait_agents. manage_tasks for your checklist. ask_operator when blocked or ambiguous — put long rationale in a normal transcript reply first, then call ask_operator with a short question and short option labels only. # Fetch URLs (primary-mounted) @@ -117,7 +117,7 @@ Docs/design (PRODUCT.md, ARCHITECTURE.md, docs/design/*, brand) still spawn shak ## If ORCHESTRATION → coordinate -Track with manage_tasks. Parallelize independent lanes via spawn_agent + wait_agents. After each spawn wave, update the operator before blocking. Escalate blockers with ask_operator. This is your core role. +Track with manage_tasks. Parallelize independent lanes via spawn_agent + wait_agents. After each spawn wave, update the operator before blocking. Escalate blockers with ask_operator (chat rationale first, then short ask_operator). This is your core role. ## If COMMUNICATION → answer directly diff --git a/src/agent/prompt-contract.ts b/src/agent/prompt-contract.ts index ca9d4f35b..f5e0e2c1f 100644 --- a/src/agent/prompt-contract.ts +++ b/src/agent/prompt-contract.ts @@ -11,6 +11,7 @@ export const CHAT_PROMPT_QUALITY_MARKERS = [ "Scope and conventions:", "DIY tiny/single-file/one-route", "ask_operator only when permission blocks you", + "short question and short option labels only", "Touch only code required for the task", "load the style and philosophy skills", "grep or search_files", diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 41e8c9c7b..2334d908d 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -134,6 +134,7 @@ export function buildGuidelines( ] : [ "- Clear, bounded coding requests: proceed autonomously; use ask_operator only when permission blocks you or the request is genuinely ambiguous (missing repro, conflicting instructions, destructive choice).", + "- Before ask_operator: put long rationale in a normal transcript reply first, then call ask_operator with a short question and short option labels only.", "- Questions, reviews, and product/visual feedback: answer or diagnose first; do not edit until the user wants a change.", "- Preserve unrelated user edits; never revert changes you did not make unless asked.", "- Unexpected changes in files you did not touch: stop and ask_operator.", @@ -219,7 +220,8 @@ const TOOL_SUMMARIES: Record = { "find agent profiles by role or team before spawning with task(agent=...); results include full system prompt / body so you need not read_file plugin roots outside the workspace", manage_tasks: "maintain your work checklist — create/replace, update status, append, cancel", submit_output: "signal the task is complete — the only way to finish", - ask_operator: "pause and ask the user when blocked or genuinely ambiguous", + ask_operator: + "pause and ask the user when blocked or genuinely ambiguous; put long rationale in a transcript reply first, then call with a short question and short option labels only", present: "dynamically render aligned/structured output using the layout primitives (stack/row/grid/text etc)", tool_search: "load more tools by capability when you need them", diff --git a/src/tui/overlays.test.ts b/src/tui/overlays.test.ts index 87982755c..51abe5d44 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -192,6 +192,7 @@ describe("operator question overlay", () => { try { openOperatorOverlay(shell); expect(shell.overlayKind).toBe("operator"); + expect(shell.layout.overlayMode).toBe("full_shell"); expect(shell.overlayBodyLines.length).toBeGreaterThan(0); expect(shell.overlayItems.length).toBeGreaterThan(3); expect(focusOwner(shell.focus)).toBe("overlay"); diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index fc3fe0784..124157b3a 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -148,6 +148,9 @@ export function openOperatorOverlay(shell: AppShell, opts?: OpenOperatorOpts): v items: choices, activeIndex: opts?.activeIndex ?? 0, frameId: "overlay-operator", + // Full shell so long questions and option lists stay readable (CL-7067). + // Permission gates keep the default inset path. + overlayMode: "full_shell", ...(opts?.itemIds !== undefined ? { itemIds: opts.itemIds } : {}), ...(opts?.onAccept !== undefined ? { onAccept: opts.onAccept } : {}), ...(opts?.onTextAnswer !== undefined ? { onTextAnswer: opts.onTextAnswer } : {}), diff --git a/src/tui/shell.ts b/src/tui/shell.ts index e5c9bcea8..689db1d83 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -1282,8 +1282,12 @@ function relayoutOverlayHost(shell: AppShell, itemCount: number): void { const perItem = overlayRowsPerItem(shell.overlayKind); const hostRows = overlayHostRows(shell, shell.overlayBodyLines.length, itemCount * perItem); const minHostRows = overlayMinHostRows(shell, shell.overlayBodyLines.length, itemCount > 0); + // Preserve the mode the open chose (operator uses full_shell; others inset). + // Hardcoding inset here would collapse a full_shell ask on every list refresh. + const bag = internals.get(shell); + const mode: OverlayMode = bag?.overlayMode === "full_shell" ? "full_shell" : "inset"; relayout(shell, { - overlayMode: "inset", + overlayMode: mode, overlayBodyRows: hostRows, overlayMinBodyRows: minHostRows, }); @@ -1937,6 +1941,8 @@ interface PriorOverlaySnapshot { readonly onCancel: (() => void) | null; readonly addProviderHint: boolean; readonly setDefaultHint: boolean; + /** Geometry mode the primary used before the palette stacked over it. */ + readonly overlayMode: OverlayMode; } interface ShellInternals { @@ -3429,6 +3435,16 @@ function decisionContextBudget( ): number { const fixedChrome = OVERLAY_HOST_BORDER_ROWS + overlayTitleRows(kind) + DECISION_HEADER_AND_TRAILER_ROWS; + // full_shell owns residual after chrome — drop the inset fraction cap so a + // longer question can use the extra rows instead of staying clipped at eight. + const bag = internals.get(shell); + if (bag?.overlayMode === "full_shell") { + const maxOverlayRows = Math.max(0, terminalHeight - PROMPT_BASE_ROWS); + const baseline = + maxOverlayRows - DECISION_CHOICE_ROWS - fixedChrome - DECISION_CONTEXT_BLANK_ROWS; + const fullCap = Math.max(DECISION_CONTEXT_ROWS, Math.floor(terminalHeight / 2)); + return Math.max(0, Math.min(fullCap, baseline)); + } // The resolver never lets the overlay host past the fraction cap even when // the transcript floor and every other zone have already given up their // rows, so that cap — not just the prompt floor — bounds how much context @@ -3554,6 +3570,12 @@ export interface OpenListOverlayOpts { * server needs authorization. */ readonly echoChoice?: boolean; + /** + * Geometry mode for this open. Defaults to inset. Operator asks use + * full_shell so long questions and option lists stay readable; permission + * gates stay inset unless a caller opts in. + */ + readonly overlayMode?: "inset" | "full_shell"; /** * Claim printable keys for a `>` filter row so the list narrows as you type. * Opt-in per open (model picker, palette). Overlays without it keep j/k @@ -3608,6 +3630,7 @@ export function openListOverlay(shell: AppShell, opts?: OpenListOverlayOpts): vo onCancel: bag.overlayOnCancel, addProviderHint: bag.overlayAddProviderHint, setDefaultHint: bag.overlaySetDefaultHint, + overlayMode: bag.overlayMode === "full_shell" ? "full_shell" : "inset", }; } // Leave prior overlay focus frame; palette will stack above it. @@ -3627,6 +3650,9 @@ export function openListOverlay(shell: AppShell, opts?: OpenListOverlayOpts): vo const bag = internals.get(shell); if (bag) { + // Mode must land before applyOverlayBodyText so decisionContextBudget can + // size the body against full_shell vs inset. Palette is always inset. + bag.overlayMode = !isPalette && opts?.overlayMode === "full_shell" ? "full_shell" : "inset"; // Palette open does not own primary accept; leave prior snapshot's callback. if (!isPalette) { bag.overlayItemIds = opts?.itemIds ? [...opts.itemIds] : []; @@ -4105,6 +4131,7 @@ export function closeInsetOverlay(shell: AppShell): void { bag.overlayOnCancel = prior.onCancel; bag.overlayAddProviderHint = prior.addProviderHint; bag.overlaySetDefaultHint = prior.setDefaultHint; + bag.overlayMode = prior.overlayMode === "full_shell" ? "full_shell" : "inset"; // If focus was not stacked (edge case), re-open overlay frame. if (focusOwner(shell.focus) !== "overlay") { shell.focus = openOverlay(shell.focus, OVERLAY_FRAME_ID, { @@ -4116,7 +4143,7 @@ export function closeInsetOverlay(shell: AppShell): void { const hostRows = overlayHostRows(shell, prior.bodyLines.length, listH); const minHostRows = overlayMinHostRows(shell, prior.bodyLines.length, prior.list.count > 0); relayout(shell, { - overlayMode: "inset", + overlayMode: bag.overlayMode, overlayBodyRows: hostRows, overlayMinBodyRows: minHostRows, }); @@ -4225,8 +4252,10 @@ export function setOverlayBody(shell: AppShell, text: string, maxLines = 8): voi shell.overlayBodyLines.length, shell.overlayItems.length > 0, ); + const bag = internals.get(shell); + const mode: OverlayMode = bag?.overlayMode === "full_shell" ? "full_shell" : "inset"; relayout(shell, { - overlayMode: "inset", + overlayMode: mode, overlayBodyRows: hostRows, overlayMinBodyRows: minHostRows, }); From 22e75031f5d2f1a892e41e3f63f3decd6dbfef4f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 12:58:29 -0700 Subject: [PATCH 2/4] Retarget landing inset overlay test to permissions Operator asks now open full_shell (CL-7067), which hides the landing. The slide-only-as-needed coverage belongs on the inset permission path. --- src/tui/landing.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 998890cd6..8153f4410 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -23,7 +23,7 @@ import { surfaceSystemNotice, toggleTasksPanel, } from "./shell"; -import { makeOperatorQuestion, openOperatorOverlay } from "./overlays"; +import { makeOperatorQuestion, openOperatorOverlay, openPermissionsOverlay } from "./overlays"; import { LANDING_HINTS, LANDING_SUGGESTIONS, @@ -429,7 +429,11 @@ describe("landing screen", () => { // together before the overlay opens. expect(was).toEqual([...was].sort((a, b) => a - b)); - openOperatorOverlay(shell); + // Inset permission overlay: landing stays visible and only slides as + // far as the overlay's content needs. Operator asks use full_shell + // (CL-7067) and hide the landing instead — that path is covered in + // overlays.test.ts. + openPermissionsOverlay(shell, { items: ["Allow once", "Allow session", "Deny"] }); await settle(h); const after = rows(h); // Every landing anchor is still on screen and in the same relative From 0d0266cc7141a3e2deaa92b203f858d7c9361cf2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 13:56:31 -0700 Subject: [PATCH 3/4] Harden landing overlay tests for inset pressure and full_shell budget A three-item empty permissions open left message delta 0, so the slide test would pass without the float moving. The many-choices landing case still opened operator full_shell, which hides landing. Retarget both to heavy inset permissions, and pin that full_shell keeps more decision context rows than inset for the same long body. --- src/tui/landing.test.ts | 51 ++++++++++++++++++++++++++--------- src/tui/overlays.test.ts | 58 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 13 deletions(-) diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index 8153f4410..fb8300e70 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -23,7 +23,7 @@ import { surfaceSystemNotice, toggleTasksPanel, } from "./shell"; -import { makeOperatorQuestion, openOperatorOverlay, openPermissionsOverlay } from "./overlays"; +import { makePermissionItems, openPermissionsOverlay } from "./overlays"; import { LANDING_HINTS, LANDING_SUGGESTIONS, @@ -429,11 +429,26 @@ describe("landing screen", () => { // together before the overlay opens. expect(was).toEqual([...was].sort((a, b) => a - b)); - // Inset permission overlay: landing stays visible and only slides as - // far as the overlay's content needs. Operator asks use full_shell - // (CL-7067) and hide the landing instead — that path is covered in - // overlays.test.ts. - openPermissionsOverlay(shell, { items: ["Allow once", "Allow session", "Deny"] }); + // Heavy inset permission overlay: many choices plus a multi-line body + // so the float must take real headroom from the landing split. A + // three-item empty body leaves message delta 0 and would pass even if + // the split never slid. Operator asks use full_shell (CL-7067) and + // hide the landing instead — that path is covered in overlays.test.ts. + const heavyBody = [ + "run_shell", + "Run shell command", + "Proposed: git reset --hard origin/main && rm -rf node_modules", + "Files at risk: 128 modified, 12 untracked.", + "Continue only if you accept discarding local work.", + "Also note: this path was requested by the explore agent.", + "Scopes include session, project, and once-only grants.", + "Review carefully before approving this request.", + ].join("\n"); + openPermissionsOverlay(shell, { + items: makePermissionItems(16), + body: heavyBody, + }); + expect(shell.layout.overlayMode).toBe("inset"); await settle(h); const after = rows(h); // Every landing anchor is still on screen and in the same relative @@ -446,6 +461,9 @@ 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); + // Real geometry pressure: the prompt field moves so the inset can + // claim rows the even split would not have given it. + expect(nowAt[0]).not.toBe(was[0]); expect(h.captureCharFrame()).toContain("Esc cancel"); } finally { shell.dispose(); @@ -455,12 +473,14 @@ describe("landing screen", () => { ); }); - // A question with more choices than the even top/bottom split would leave - // room for used to get its list starved down to whatever that split - // happened to allow — as little as one or two choices — because the float - // only asked the split for one choice row of headroom. It now asks for the - // overlay's real, already fraction-capped content height, so a terminal + // An inset permission list with more choices than the even top/bottom split + // would leave room for used to get its list starved down to whatever that + // split happened to allow — as little as one or two choices — because the + // float only asked the split for one choice row of headroom. It now asks for + // the overlay's real, already fraction-capped content height, so a terminal // tall enough for that content shows every choice without scrolling. + // Operator full_shell hides the landing (CL-7067); this coverage stays on + // the inset permission path that still floats over landing. test("a landing overlay with many choices shows them all when there is room", async () => { await withTestRenderer( async (h) => { @@ -470,10 +490,15 @@ describe("landing screen", () => { run: "idle", }); try { - openOperatorOverlay(shell); + const items = makePermissionItems(8); + openPermissionsOverlay(shell, { + items, + body: "run_shell\nRun shell command\nbun test src/tui", + }); + expect(shell.layout.overlayMode).toBe("inset"); await settle(h); const frame = h.captureCharFrame(); - for (const choice of makeOperatorQuestion().choices) { + for (const choice of items) { expect(frame).toContain(choice); } } finally { diff --git a/src/tui/overlays.test.ts b/src/tui/overlays.test.ts index 51abe5d44..fd61f0231 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -223,6 +223,64 @@ describe("operator question overlay", () => { { width: 80, height: 24 }, ); }); + + test("full_shell decisionContext budget shows more body rows than inset", async () => { + // Same long body under both modes: inset stays capped at DECISION_CONTEXT_ROWS + // (8), while full_shell raises the cap with terminal height so a long ask + // stays readable (CL-7067). Mode-only asserts are not enough — pin the + // budget branch that actually shapes overlayBodyLines. + const longBody = [ + "Should we proceed with the destructive reset of the working tree?", + ...Array.from({ length: 24 }, (_, i) => `Context line ${i + 1}.`), + ].join("\n"); + const choices = ["Cancel — keep working tree", "Allow this once", "Allow for this session"] as const; + const size = { width: 80, height: 40 } as const; + + async function bodyLineCount(mode: "inset" | "full_shell"): Promise<{ + readonly lines: number; + readonly body: readonly string[]; + readonly frame: string; + }> { + return withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: size.width, rows: size.height }, + wireKeys: false, + run: "idle", + }); + try { + openListOverlay(shell, { + kind: "operator", + title: "", + body: longBody, + items: [...choices], + overlayMode: mode, + frameId: `overlay-operator-${mode}`, + }); + expect(shell.layout.overlayMode).toBe(mode); + await h.renderOnce(); + await h.renderOnce(); + return { + lines: shell.overlayBodyLines.length, + body: shell.overlayBodyLines, + frame: h.captureCharFrame(), + }; + } finally { + shell.dispose(); + } + }, size); + } + + const inset = await bodyLineCount("inset"); + const full = await bodyLineCount("full_shell"); + expect(full.lines).toBeGreaterThan(inset.lines); + // Short, non-wrapping context lines map 1:1 to the budget. Inset caps at + // eight context rows (then dither + tail); full_shell's raised cap keeps a + // mid-body line the inset path drops. + expect(full.body.some((line) => line.includes("Context line 12."))).toBe(true); + expect(inset.body.some((line) => line.includes("Context line 12."))).toBe(false); + expect(full.frame).toContain("Context line 12."); + expect(inset.frame).not.toContain("Context line 12."); + }); }); describe("model / provider picker", () => { From afef3611a1416dc7cbe827a60ec0d998e3bdc232 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 14:04:02 -0700 Subject: [PATCH 4/4] Format overlays.test.ts choices array for Prettier --- src/tui/overlays.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tui/overlays.test.ts b/src/tui/overlays.test.ts index fd61f0231..c1ff850ed 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -233,7 +233,11 @@ describe("operator question overlay", () => { "Should we proceed with the destructive reset of the working tree?", ...Array.from({ length: 24 }, (_, i) => `Context line ${i + 1}.`), ].join("\n"); - const choices = ["Cancel — keep working tree", "Allow this once", "Allow for this session"] as const; + const choices = [ + "Cancel — keep working tree", + "Allow this once", + "Allow for this session", + ] as const; const size = { width: 80, height: 40 } as const; async function bodyLineCount(mode: "inset" | "full_shell"): Promise<{