From 91f1251286c1d97404ba4cc0bcd03b2276c0f647 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 09:09:02 -0700 Subject: [PATCH 1/2] Show operator questions as an inset overlay --- CHANGELOG.md | 5 +++++ src/tui/landing.test.ts | 5 +---- src/tui/overlays.test.ts | 8 ++++---- src/tui/overlays.ts | 4 +--- src/tui/shell.ts | 7 +++---- 5 files changed, 14 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb208888..c4cb7d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### TUI + +- `ask_operator` is an inset overlay again so the transcript stays visible while + the operator answers. + ## [0.3.7] - 2026-08-27 ### Fixed diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index fb8300e7..37898418 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -432,8 +432,7 @@ describe("landing screen", () => { // 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. + // the split never slid. const heavyBody = [ "run_shell", "Run shell command", @@ -479,8 +478,6 @@ describe("landing screen", () => { // 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) => { diff --git a/src/tui/overlays.test.ts b/src/tui/overlays.test.ts index c1ff850e..38f0e7e0 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -192,7 +192,8 @@ describe("operator question overlay", () => { try { openOperatorOverlay(shell); expect(shell.overlayKind).toBe("operator"); - expect(shell.layout.overlayMode).toBe("full_shell"); + expect(shell.layout.overlayMode).toBe("inset"); + expect(shell.layout.transcriptHeight).toBeGreaterThanOrEqual(OVERLAY_TRANSCRIPT_FLOOR); expect(shell.overlayBodyLines.length).toBeGreaterThan(0); expect(shell.overlayItems.length).toBeGreaterThan(3); expect(focusOwner(shell.focus)).toBe("overlay"); @@ -226,9 +227,8 @@ describe("operator question overlay", () => { 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. + // (8), while full_shell raises the cap with terminal height. 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}.`), diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index 124157b3..354b3a84 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -148,9 +148,7 @@ 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", + // Chat-first: keep the transcript visible while the operator answers. ...(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 0a9a40de..064e0d68 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -1282,7 +1282,7 @@ 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). + // Preserve the mode the open chose. // 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"; @@ -3571,9 +3571,8 @@ export interface OpenListOverlayOpts { */ 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. + * Geometry mode for this open. Defaults to inset. Pass `full_shell` to hide + * the transcript and give residual rows to the overlay host. */ readonly overlayMode?: "inset" | "full_shell"; /** From 659edec7a2b9244713e03c3fd6aa1908a6a6b532 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 12:34:53 -0700 Subject: [PATCH 2/2] Remove full_shell overlay mode; every overlay is inset Drop OverlayMode full_shell from geometry resolve, shell relayout/budget paths, and OpenListOverlayOpts. Delete the mode-specific geometry and overlays tests. Document the removal in Unreleased. --- CHANGELOG.md | 1 + src/tui/geometry.test.ts | 12 -------- src/tui/geometry/resolve.ts | 42 +------------------------ src/tui/overlays.test.ts | 61 ------------------------------------- src/tui/shell.ts | 34 ++------------------- 5 files changed, 5 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cb7d6e..cab50858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename - `ask_operator` is an inset overlay again so the transcript stays visible while the operator answers. +- The `full_shell` overlay mode is removed; every overlay is inset. ## [0.3.7] - 2026-08-27 diff --git a/src/tui/geometry.test.ts b/src/tui/geometry.test.ts index 17ea2a52..548d3c02 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -438,18 +438,6 @@ describe("resolveGeometry — overlay modes", () => { }); expect(layout.overlayHeight).toBeGreaterThanOrEqual(5); }); - - test("full_shell hides transcript and gives residual to overlay_host", () => { - const layout = idle80x24({ - overlay: { mode: "full_shell", bodyRows: 20 }, - }); - expect(layout.overlayMode).toBe("full_shell"); - expect(layout.transcriptHeight).toBe(0); - expect(layout.heights.prompt).toBe(0); - expect(layout.heights.notice).toBe(0); - expect(layout.overlayHeight).toBeGreaterThan(0); - expect(layout.overlayHeight + layout.chromeHeight).toBe(24); - }); }); describe("resolveGeometry — resize / residual", () => { diff --git a/src/tui/geometry/resolve.ts b/src/tui/geometry/resolve.ts index 298d13c5..e48afd8d 100644 --- a/src/tui/geometry/resolve.ts +++ b/src/tui/geometry/resolve.ts @@ -22,7 +22,7 @@ export interface TerminalSize { readonly rows: number; } -export type OverlayMode = "closed" | "inset" | "full_shell"; +export type OverlayMode = "closed" | "inset"; export interface OverlayInput { readonly mode: OverlayMode; @@ -173,7 +173,6 @@ function sumChrome(heights: MutableHeights): number { } function transcriptFloorFor(mode: OverlayMode, terminalRows: number): number { - if (mode === "full_shell") return 0; if (mode === "inset") { // Proposed ≥ 8 on 24-row; scale gently on shorter terminals. if (terminalRows < 24) @@ -199,11 +198,6 @@ function desiredOverlayHeight( const requested = input.overlay?.bodyRows ?? Math.floor(rows * 0.4); const fracCap = Math.floor(rows * OVERLAY_MAX_FRACTION); - if (mode === "full_shell") { - // Overlay owns residual after any remaining chrome (usually 0 after hide). - return Math.max(0, rows - chrome); - } - // inset: leave transcript floor; never exceed fraction cap const floorSafe = Math.max(0, rows - chrome - floor); return clamp(requested, 0, Math.min(fracCap, floorSafe)); @@ -326,40 +320,6 @@ export function resolveGeometry(input: GeometryInput): GeometryLayout { const sideMargin = resolveSideMargin(terminal.columns); const layoutMode: LayoutMode = "stack"; - // Full-shell modal: hide transcript and bottom chrome; overlay owns residual. - if (mode === "full_shell") { - heights.transcript = 0; - heights.task = 0; - heights.agents = 0; - heights.plugin_banner = 0; - heights.command_banner = 0; - heights.settings_notice = 0; - heights.progress = 0; - heights.progress_divider = 0; - heights.notice = 0; - heights.prompt = 0; - const chrome = sumChrome(heights); - heights.overlay_host = Math.max(0, terminal.rows - chrome); - const regions = assignRects(heights, terminal); - return { - terminal, - transcriptHeight: 0, - chromeHeight: chrome, - overlayHeight: heights.overlay_host, - regions, - heights, - collapsed, - overlayMode: mode, - transcriptFloor: floor, - sideMargin, - contentWidth, - layoutMode, - chatWidth: contentWidth, - railWidth: 0, - railGutter: 0, - }; - } - // Cap prompt growth against floor before overlay allocation. const promptCap = Math.max(PROMPT_BASE_ROWS, Math.floor(terminal.rows * PROMPT_CAP_FRACTION)); if (heights.prompt > promptCap) heights.prompt = promptCap; diff --git a/src/tui/overlays.test.ts b/src/tui/overlays.test.ts index 38f0e7e0..add77649 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -224,67 +224,6 @@ 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. 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", () => { diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 064e0d68..bd0eeccc 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -1282,12 +1282,8 @@ 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. - // 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: mode, + overlayMode: "inset", overlayBodyRows: hostRows, overlayMinBodyRows: minHostRows, }); @@ -1941,8 +1937,6 @@ 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 { @@ -3435,16 +3429,6 @@ 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 @@ -3570,11 +3554,6 @@ export interface OpenListOverlayOpts { * server needs authorization. */ readonly echoChoice?: boolean; - /** - * Geometry mode for this open. Defaults to inset. Pass `full_shell` to hide - * the transcript and give residual rows to the overlay host. - */ - 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 @@ -3629,7 +3608,6 @@ 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. @@ -3649,9 +3627,6 @@ 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] : []; @@ -4130,7 +4105,6 @@ 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, { @@ -4142,7 +4116,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: bag.overlayMode, + overlayMode: "inset", overlayBodyRows: hostRows, overlayMinBodyRows: minHostRows, }); @@ -4251,10 +4225,8 @@ 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: mode, + overlayMode: "inset", overlayBodyRows: hostRows, overlayMinBodyRows: minHostRows, });