From d22bc7a34270cc7b3dcea66dfb03e3c169a7366c Mon Sep 17 00:00:00 2001 From: Norin Lavaee Date: Sat, 13 Jun 2026 00:06:45 -0700 Subject: [PATCH 1/5] fix(workflows): yield overlay for host custom ui Add a host inline custom UI focus-state seam and make the workflow graph overlay yield while parent inline questions are active. Preserve synchronous custom UI factory invocation, clean up host state on all exits, and avoid host-state churn for pre-aborted requests.\n\nAdd regression coverage for overlay yield/restore, focus suppression, factory timing, abort behavior, and pre-aborted custom UI calls.\n\nFixes #1353 Assistant-model: GPT-5.5 --- .../coding-agent/src/core/extensions/index.ts | 2 + .../coding-agent/src/core/extensions/types.ts | 16 + .../src/modes/interactive/interactive-mode.ts | 76 ++- .../test/interactive-mode-status.test.ts | 161 +++++ packages/workflows/src/extension/wiring.ts | 11 + packages/workflows/src/tui/overlay-adapter.ts | 72 ++- ...thub-com-bastani-inc-atomic-issues-1353.md | 611 ++++++++++++++++++ test/integration/overlay-entrypoints.test.ts | 282 ++++++++ 8 files changed, 1228 insertions(+), 3 deletions(-) create mode 100644 specs/2026-06-13-fix-issue-https-github-com-bastani-inc-atomic-issues-1353.md diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 71cf80f8a..bfed829fc 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -81,6 +81,8 @@ export type { GetAllToolsHandler, GetCommandsHandler, GetThinkingLevelHandler, + HostCustomUiState, + HostCustomUiStateListener, GrepToolCallEvent, GrepToolResultEvent, // Events - Input diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 6e7ab34a3..9c17e2aff 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -149,6 +149,16 @@ export interface ChatRenderSettings { getCustomMessageRenderer(customType: string): MessageRenderer | undefined; } +/** Host-owned inline custom UI focus state exposed to overlays without prompt content. */ +export interface HostCustomUiState { + /** Number of active non-overlay host custom UI mounts. */ + blockingInlineCustomUiDepth: number; + /** True when at least one non-overlay host custom UI currently owns focus. */ + blockingInlineCustomUiActive: boolean; +} + +export type HostCustomUiStateListener = (state: HostCustomUiState) => void; + /** * UI context for extensions to request interactive UI. * Each mode (interactive, RPC, print) provides its own implementation. @@ -169,6 +179,12 @@ export interface ExtensionUIContext { /** Request an interactive repaint after extension-owned state changes. */ requestRender(): void; + /** Get host-owned inline custom UI focus state, if the mode exposes it. */ + getHostCustomUiState?(): HostCustomUiState; + + /** Observe host-owned inline custom UI focus state changes. Returns an unsubscribe function. */ + onHostCustomUiStateChange?(listener: HostCustomUiStateListener): () => void; + /** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */ onTerminalInput(handler: TerminalInputHandler): () => void; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8701604de..f8b381fde 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -81,6 +81,8 @@ import type { ExtensionRunner, ExtensionUIContext, ExtensionUIDialogOptions, + HostCustomUiState, + HostCustomUiStateListener, ProjectTrustContext, ExtensionWidgetOptions, } from "../../core/extensions/index.ts"; @@ -440,6 +442,8 @@ export class InteractiveMode { private extensionInput: ExtensionInputComponent | undefined = undefined; private extensionEditor: ExtensionEditorComponent | undefined = undefined; private extensionTerminalInputUnsubscribers = new Set<() => void>(); + private blockingInlineCustomUiDepth = 0; + private hostCustomUiStateListeners = new Set(); // Extension widgets (components rendered above/below the editor) private extensionWidgetsAbove = new Map< @@ -2515,6 +2519,47 @@ export class InteractiveMode { this.extensionTerminalInputUnsubscribers.clear(); } + private getHostCustomUiState(): HostCustomUiState { + return { + blockingInlineCustomUiDepth: this.blockingInlineCustomUiDepth, + blockingInlineCustomUiActive: this.blockingInlineCustomUiDepth > 0, + }; + } + + private notifyHostCustomUiStateListeners(): void { + const state = this.getHostCustomUiState(); + for (const listener of this.hostCustomUiStateListeners) { + try { + listener(state); + } catch { + /* ignore observer errors */ + } + } + } + + private beginHostInlineCustomUi(): () => void { + let released = false; + this.blockingInlineCustomUiDepth++; + this.notifyHostCustomUiStateListeners(); + return () => { + if (released) return; + released = true; + this.blockingInlineCustomUiDepth = Math.max( + 0, + this.blockingInlineCustomUiDepth - 1, + ); + this.notifyHostCustomUiStateListeners(); + }; + } + + private onHostCustomUiStateChange( + listener: HostCustomUiStateListener, + ): () => void { + this.hostCustomUiStateListeners.add(listener); + return () => { + this.hostCustomUiStateListeners.delete(listener); + }; + } private createProjectTrustContext(cwd: string): ProjectTrustContext { const ui = this.createExtensionUIContext(); @@ -2544,6 +2589,9 @@ export class InteractiveMode { this.showExtensionInput(title, placeholder, opts), notify: (message, type) => this.showExtensionNotify(message, type), requestRender: () => this.ui.requestRender(), + getHostCustomUiState: () => this.getHostCustomUiState(), + onHostCustomUiStateChange: (listener) => + this.onHostCustomUiStateChange(listener), onTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler), setStatus: (key, text) => this.setExtensionStatus(key, text), @@ -2919,6 +2967,7 @@ export class InteractiveMode { let component: (Component & { dispose?(): void }) | undefined; let closed = false; let mounted = false; + let releaseHostInlineCustomUi: (() => void) | undefined; const disposeComponent = () => { try { @@ -2928,6 +2977,10 @@ export class InteractiveMode { } }; + const releaseHostCustomUi = () => { + releaseHostInlineCustomUi?.(); + }; + const cleanupAbortListener = () => { options?.signal?.removeEventListener("abort", abortCustomUi); }; @@ -2943,8 +2996,9 @@ export class InteractiveMode { closed = true; cleanupAbortListener(); closeMountedUi(); - resolve(result); disposeComponent(); + releaseHostCustomUi(); + resolve(result); }; const rejectAndClose = (reason: unknown) => { @@ -2953,6 +3007,7 @@ export class InteractiveMode { cleanupAbortListener(); closeMountedUi(); disposeComponent(); + releaseHostCustomUi(); reject(reason); }; @@ -2960,13 +3015,30 @@ export class InteractiveMode { rejectAndClose(options?.signal?.reason ?? new Error("Extension custom UI aborted")); } + if (options?.signal?.aborted) { + abortCustomUi(); + return; + } + releaseHostInlineCustomUi = isOverlay + ? undefined + : this.beginHostInlineCustomUi(); if (options?.signal?.aborted) { abortCustomUi(); return; } options?.signal?.addEventListener("abort", abortCustomUi, { once: true }); - Promise.resolve(factory(this.ui, theme, this.keybindings, close)) + let factoryResult: + | (Component & { dispose?(): void }) + | Promise; + try { + factoryResult = factory(this.ui, theme, this.keybindings, close); + } catch (err) { + rejectAndClose(err); + return; + } + + Promise.resolve(factoryResult) .then((c) => { if (closed) { try { diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 53d2ff4af..cd8678478 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -240,6 +240,167 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { }); }); +describe("InteractiveMode.showExtensionCustom host custom UI state", () => { + function createCustomUiHostFixture() { + const fakeThis: any = { + editor: { + getText: vi.fn(() => "draft"), + setText: vi.fn(), + }, + editorContainer: { + clear: vi.fn(), + addChild: vi.fn(), + }, + keybindings: {}, + ui: { + setFocus: vi.fn(), + requestRender: vi.fn(), + }, + blockingInlineCustomUiDepth: 0, + hostCustomUiStateListeners: new Set(), + }; + Object.setPrototypeOf(fakeThis, (InteractiveMode as any).prototype); + return fakeThis; + } + + test("runs the custom UI factory synchronously before returning", async () => { + const fakeThis = createCustomUiHostFixture(); + let returned = false; + let factoryCalled = false; + const component = { + render: () => [], + invalidate: vi.fn(), + dispose: vi.fn(), + }; + + const promise = (InteractiveMode as any).prototype.showExtensionCustom.call( + fakeThis, + (_tui: unknown, _theme: unknown, _keybindings: unknown, done: (result: string) => void) => { + expect(returned).toBe(false); + factoryCalled = true; + done("done"); + return component; + }, + ); + returned = true; + + expect(factoryCalled).toBe(true); + await expect(promise).resolves.toBe("done"); + }); + + test("does not invoke the custom UI factory or notify host state listeners when the signal is already aborted", async () => { + const fakeThis = createCustomUiHostFixture(); + const states: Array<{ blockingInlineCustomUiActive: boolean; blockingInlineCustomUiDepth: number }> = []; + fakeThis.onHostCustomUiStateChange((state: (typeof states)[number]) => states.push({ ...state })); + const controller = new AbortController(); + const failure = new Error("already aborted"); + let factoryCalled = false; + controller.abort(failure); + + await expect( + (InteractiveMode as any).prototype.showExtensionCustom.call( + fakeThis, + () => { + factoryCalled = true; + return { render: () => [], invalidate: vi.fn() }; + }, + { signal: controller.signal }, + ), + ).rejects.toBe(failure); + + expect(factoryCalled).toBe(false); + expect(states).toEqual([]); + expect(fakeThis.getHostCustomUiState()).toEqual({ + blockingInlineCustomUiActive: false, + blockingInlineCustomUiDepth: 0, + }); + }); + + test("immediate abort after custom() returns cannot run a deferred factory", async () => { + const fakeThis = createCustomUiHostFixture(); + const controller = new AbortController(); + const failure = new Error("aborted after return"); + let factoryCalls = 0; + const component = { + render: () => [], + invalidate: vi.fn(), + dispose: vi.fn(), + }; + + const promise = (InteractiveMode as any).prototype.showExtensionCustom.call( + fakeThis, + () => { + factoryCalls++; + return component; + }, + { signal: controller.signal }, + ); + expect(factoryCalls).toBe(1); + + controller.abort(failure); + await expect(promise).rejects.toBe(failure); + await Promise.resolve(); + + expect(factoryCalls).toBe(1); + expect(fakeThis.getHostCustomUiState()).toEqual({ + blockingInlineCustomUiActive: false, + blockingInlineCustomUiDepth: 0, + }); + }); + + test("releases host state when a non-overlay custom UI factory throws synchronously", async () => { + const fakeThis = createCustomUiHostFixture(); + const states: Array<{ blockingInlineCustomUiActive: boolean; blockingInlineCustomUiDepth: number }> = []; + fakeThis.onHostCustomUiStateChange((state: (typeof states)[number]) => states.push({ ...state })); + const failure = new Error("factory failed synchronously"); + + await expect( + (InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, () => { + expect(fakeThis.getHostCustomUiState()).toMatchObject({ + blockingInlineCustomUiActive: true, + blockingInlineCustomUiDepth: 1, + }); + throw failure; + }), + ).rejects.toBe(failure); + + expect(fakeThis.getHostCustomUiState()).toEqual({ + blockingInlineCustomUiActive: false, + blockingInlineCustomUiDepth: 0, + }); + expect(states).toEqual([ + { blockingInlineCustomUiActive: true, blockingInlineCustomUiDepth: 1 }, + { blockingInlineCustomUiActive: false, blockingInlineCustomUiDepth: 0 }, + ]); + }); + + test("releases host state when a non-overlay custom UI factory rejects asynchronously", async () => { + const fakeThis = createCustomUiHostFixture(); + const states: Array<{ blockingInlineCustomUiActive: boolean; blockingInlineCustomUiDepth: number }> = []; + fakeThis.onHostCustomUiStateChange((state: (typeof states)[number]) => states.push({ ...state })); + const failure = new Error("factory rejected asynchronously"); + + await expect( + (InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, () => { + expect(fakeThis.getHostCustomUiState()).toMatchObject({ + blockingInlineCustomUiActive: true, + blockingInlineCustomUiDepth: 1, + }); + return Promise.reject(failure); + }), + ).rejects.toBe(failure); + + expect(fakeThis.getHostCustomUiState()).toEqual({ + blockingInlineCustomUiActive: false, + blockingInlineCustomUiDepth: 0, + }); + expect(states).toEqual([ + { blockingInlineCustomUiActive: true, blockingInlineCustomUiDepth: 1 }, + { blockingInlineCustomUiActive: false, blockingInlineCustomUiDepth: 0 }, + ]); + }); +}); + describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => { test("stores wrapper factories and rebuilds autocomplete immediately", () => { const wrapper: AutocompleteProviderFactory = (current) => current; diff --git a/packages/workflows/src/extension/wiring.ts b/packages/workflows/src/extension/wiring.ts index 271613da3..bac47b4d1 100644 --- a/packages/workflows/src/extension/wiring.ts +++ b/packages/workflows/src/extension/wiring.ts @@ -499,6 +499,13 @@ export interface PiOverlayHandle { * (`overlay-adapter.ts`); inline pickers leave it unset and dismiss * via the factory `done()` callback. */ +export interface PiHostCustomUiState { + blockingInlineCustomUiDepth: number; + blockingInlineCustomUiActive: boolean; +} + +export type PiHostCustomUiStateListener = (state: PiHostCustomUiState) => void; + export interface PiCustomOverlayOptions { /** * `true` mounts a floating popup; `false` mounts a focused @@ -636,6 +643,10 @@ export interface PiUISurface { setTitle?: (title: string) => void; /** Show a custom component or overlay. */ custom?: PiCustomOverlayFunction; + /** Get host-owned inline custom UI focus state, if exposed by the host. */ + getHostCustomUiState?: () => PiHostCustomUiState; + /** Observe host-owned inline custom UI focus state changes, if exposed by the host. */ + onHostCustomUiStateChange?: (listener: PiHostCustomUiStateListener) => () => void; pasteToEditor?: (text: string) => void; setEditorText?: (text: string) => void; getEditorText?: () => string; diff --git a/packages/workflows/src/tui/overlay-adapter.ts b/packages/workflows/src/tui/overlay-adapter.ts index a785139d9..544a60676 100644 --- a/packages/workflows/src/tui/overlay-adapter.ts +++ b/packages/workflows/src/tui/overlay-adapter.ts @@ -31,6 +31,8 @@ import type { PiCustomOverlayFunction, PiCustomOverlayOptions, PiEditorFactory, + PiHostCustomUiState, + PiHostCustomUiStateListener, PiKeybindings, PiOverlayHandle, PiOverlayOptions, @@ -41,6 +43,8 @@ export type OverlayChatRenderSettings = Partial PiHostCustomUiState; + onHostCustomUiStateChange?: (listener: PiHostCustomUiStateListener) => () => void; getEditorComponent?: () => PiEditorFactory | undefined; getChatRenderSettings?: () => OverlayChatRenderSettings | undefined; getFooterDataProvider?: () => ReadonlyFooterDataProvider; @@ -137,9 +141,59 @@ export function buildGraphOverlayAdapter( let currentHandle: PiOverlayHandle | null = null; let mounted = false; let finishMounted: (() => void) | null = null; + let observedUi: OverlayUISurface | undefined; + let unsubscribeHostCustomUi: (() => void) | null = null; + let hostInlineCustomUiActive = false; + let overlayYieldedToHostCustomUi = false; + + function readHostCustomUiActive(ui: OverlayUISurface | undefined = observedUi): boolean { + const state = ui?.getHostCustomUiState?.(); + if (state) hostInlineCustomUiActive = state.blockingInlineCustomUiActive; + return hostInlineCustomUiActive; + } + + function yieldToHostCustomUi(): void { + if (overlayYieldedToHostCustomUi) return; + if (!mounted || currentHandle === null) return; + if (currentHandle.isHidden()) return; + currentView?.setVisible(false); + setMouseScrollTracking(false); + currentHandle.setHidden(true); + currentHandle.unfocus(); + overlayYieldedToHostCustomUi = true; + } + + function restoreAfterHostCustomUi(): void { + if (!overlayYieldedToHostCustomUi) return; + if (readHostCustomUiActive()) return; + overlayYieldedToHostCustomUi = false; + if (!mounted || currentHandle === null) return; + currentView?.setVisible(true); + setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true); + currentHandle.setHidden(false); + currentHandle.focus(); + } + + function observeHostCustomUi(ui: OverlayUISurface | undefined): void { + if (observedUi !== ui) { + unsubscribeHostCustomUi?.(); + unsubscribeHostCustomUi = null; + observedUi = ui; + hostInlineCustomUiActive = false; + if (typeof ui?.onHostCustomUiStateChange === "function") { + unsubscribeHostCustomUi = ui.onHostCustomUiStateChange((state) => { + hostInlineCustomUiActive = state.blockingInlineCustomUiActive; + if (hostInlineCustomUiActive) yieldToHostCustomUi(); + else restoreAfterHostCustomUi(); + }); + } + } + if (readHostCustomUiActive(ui)) yieldToHostCustomUi(); + } function close(): void { setMouseScrollTracking(false); + overlayYieldedToHostCustomUi = false; currentHandle?.hide(); finishMounted?.(); currentView?.dispose(); @@ -167,6 +221,7 @@ export function buildGraphOverlayAdapter( */ function hideMounted(): void { setMouseScrollTracking(false); + overlayYieldedToHostCustomUi = false; if (currentHandle) { currentView?.setVisible(false); currentHandle.setHidden(true); @@ -180,6 +235,7 @@ export function buildGraphOverlayAdapter( } function refocusVisibleOverlayForAwaitingInput(snapshot: StoreSnapshot): void { + if (readHostCustomUiActive()) return; if (currentHandle === null) return; if (currentHandle.isHidden()) return; if (currentHandle.isFocused()) return; @@ -217,9 +273,14 @@ export function buildGraphOverlayAdapter( surface?: OverlayPiSurface, stageId?: string, ): void { + const ui = surface?.ui ?? pi.ui; + observeHostCustomUi(ui); + const hostBlocked = readHostCustomUiActive(ui); + // Already mounted but hidden — flip visibility without remounting. if (mounted && currentHandle?.isHidden()) { currentView?.retarget(runId, stageId); + if (hostBlocked) return; currentView?.setVisible(true); setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true); currentHandle.setHidden(false); @@ -228,6 +289,10 @@ export function buildGraphOverlayAdapter( } if (mounted) { currentView?.retarget(runId, stageId); + if (hostBlocked) { + yieldToHostCustomUi(); + return; + } setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true); // Restore keyboard focus to the visible overlay after retargeting. // pi-tui dispatches key events only to the focused component, so a @@ -239,7 +304,6 @@ export function buildGraphOverlayAdapter( return; } - const ui = surface?.ui ?? pi.ui; const custom = ui?.custom; if (typeof custom !== "function") return; const uiStatus = ui as { setStatus?: (key: string, value: string | undefined) => void } | undefined; @@ -299,6 +363,7 @@ export function buildGraphOverlayAdapter( // so the gate receives input even if focus drifted off the overlay // while the agent's turn was streaming (#1120). requestFocus: () => { + if (readHostCustomUiActive()) return; if (currentHandle?.isHidden() === true) return; // Idempotent: only grab focus if the overlay does not already own it. // A redundant focus() while already focused re-runs pi-tui's focus @@ -320,6 +385,7 @@ export function buildGraphOverlayAdapter( finishMounted = finish; mounted = true; setMouseScrollTracking(view.wantsMouseScrollTracking()); + if (readHostCustomUiActive(ui)) yieldToHostCustomUi(); return makeComponent(view, tui); }; @@ -328,16 +394,20 @@ export function buildGraphOverlayAdapter( overlayOptions: FULLSCREEN_OVERLAY_OPTIONS, onHandle: (handle) => { currentHandle = handle; + if (readHostCustomUiActive(ui)) yieldToHostCustomUi(); }, }; void custom(factory, options); } function toggle(runId: string | null, surface?: OverlayPiSurface): void { + observeHostCustomUi(surface?.ui ?? pi.ui); // Hide without unmounting if we have a handle (no remount means // no scroll-pollution). if (mounted && currentHandle) { const nowHidden = !currentHandle.isHidden(); + if (!nowHidden && readHostCustomUiActive()) return; + if (nowHidden) overlayYieldedToHostCustomUi = false; currentView?.setVisible(!nowHidden); setMouseScrollTracking( nowHidden ? false : currentView?.wantsMouseScrollTracking() ?? true, diff --git a/specs/2026-06-13-fix-issue-https-github-com-bastani-inc-atomic-issues-1353.md b/specs/2026-06-13-fix-issue-https-github-com-bastani-inc-atomic-issues-1353.md new file mode 100644 index 000000000..a1cbab2f8 --- /dev/null +++ b/specs/2026-06-13-fix-issue-https-github-com-bastani-inc-atomic-issues-1353.md @@ -0,0 +1,611 @@ +# Atomic Workflow Overlay Focus Arbitration Technical Design Document / RFC + +| Document Metadata | Details | +| ---------------------- | ------------------------------------------- | +| Author(s) | Norin Lavaee | +| Status | Draft (WIP) | +| Team / Owner | Atomic CLI / Workflows UI | +| Created / Last Updated | 2026-06-12 / 2026-06-12 (Iteration 5 of 10) | + +## 1. Executive Summary + +GitHub issue [bastani-inc/atomic#1353](https://github.com/bastani-inc/atomic/issues/1353) reports that the full-screen workflow graph overlay can freeze when the parent main-chat agent opens `ask_user_question`. The root cause is focus contention: the workflow overlay reclaims focus through `WorkflowGraphOverlayAdapter.requestFocus()` and `StageChatView`’s focus-hold timer while the parent question mounts as an inline `ctx.ui.custom()` replacement in `InteractiveMode.showExtensionCustom()`. + +This RFC proposes a backward-compatible focus arbitration seam between the Atomic interactive host and the workflows overlay. When a host-owned inline custom UI is active, the workflow overlay yields with `setHidden(true)` + `unfocus()` and later restores with `setHidden(false)` + `focus()`. + +Iteration 5 incorporates review round 4: pre-aborted `ctx.ui.custom(..., { signal })` calls must not acquire the host inline focus token. The selected design checks `signal.aborted` before `beginHostInlineCustomUi()`, then invokes the factory synchronously inside `try/catch` and wraps only the returned value in `Promise.resolve(...)`. + +## 2. Context and Motivation + +### 2.1 Current State + +The current working tree contains most of the focus arbitration architecture: + +- **Host custom UI state:** `packages/coding-agent/src/core/extensions/types.ts:153-186` defines optional `HostCustomUiState`, `getHostCustomUiState()`, and `onHostCustomUiStateChange()` APIs. +- **Interactive host state:** `packages/coding-agent/src/modes/interactive/interactive-mode.ts:2540-2552` tracks `blockingInlineCustomUiDepth` and releases state idempotently. +- **Parent custom UI mount path:** `InteractiveMode.showExtensionCustom()` mounts inline custom UI in `packages/coding-agent/src/modes/interactive/interactive-mode.ts:2966-3068`. +- **Current remaining regression:** `showExtensionCustom()` currently initializes `releaseHostInlineCustomUi` with `beginHostInlineCustomUi()` at `packages/coding-agent/src/modes/interactive/interactive-mode.ts:2970-2972`, before checking `options.signal.aborted` at `packages/coding-agent/src/modes/interactive/interactive-mode.ts:3020-3023`. +- **Workflow overlay observer path:** `packages/workflows/src/tui/overlay-adapter.ts:183-188` observes host custom UI active/inactive notifications and calls yield/restore. +- **False churn risk:** A pre-aborted parent custom UI currently emits an active/inactive pair for a UI that will never mount, causing `yieldToHostCustomUi()` at `packages/workflows/src/tui/overlay-adapter.ts:155-163` and `restoreAfterHostCustomUi()` at `packages/workflows/src/tui/overlay-adapter.ts:166-174`. +- **Workflow overlay mount path:** `packages/workflows/src/tui/overlay-adapter.ts:122` builds `WorkflowGraphOverlayAdapter`, which relies on synchronous overlay factory side effects for same-turn no-remount. +- **Stage-local prompt path:** Workflow-stage `ctx.ui.custom()` remains brokered into the attached stage chat through `StageUiBroker` and `StageChatView._showCustomUi()`. +- **Repository hygiene:** Current `git status --short` no longer shows the root generated `*-report.md` artifacts flagged in review round 1. + +**Focus doors:** + +- `showExtensionCustom()` is the host-owned inline custom UI door. +- `beginHostInlineCustomUi()` / its release closure are the host focus ownership token door. +- `WorkflowGraphOverlayAdapter` is the workflow overlay yield/restore door. +- `StageUiBroker` and `StageChatView` remain the stage-local HIL door and must not be treated as parent host-blocking UI. + +### 2.2 The Problem + +- **User Impact:** With the workflow graph overlay open, a parent-session `ask_user_question` can make the TUI appear frozen. Keyboard input no longer reaches the graph, and the structured question cannot be reached or answered. +- **Product Impact:** Workflows become unreliable during clarification flows, especially when a long-running workflow is open while the parent agent asks a planning or scope question. +- **Technical Debt:** Focus ownership must be arbitrated at a single host-owned boundary instead of scattered across `InteractiveMode.showExtensionCustom()`, `WorkflowGraphOverlayAdapter`, `WorkflowAttachPane`, and `StageChatView`. +- **Review Round 4 Regression:** Pre-aborted custom UI calls acquire and release host focus state despite never mounting a component, causing false overlay hide/restore churn. + +### 2.3 Review Findings Addressed + +| Review Round | Finding | Required Resolution | +| ------------ | ------- | ------------------- | +| Round 1 | `[P2] Remove generated review artifacts from the tree` | Resolved; keep root generated reports absent before landing. | +| Round 1 | `[P2] Release host custom UI state on sync factory throws` | Keep cleanup guarantee. | +| Round 2 | Reviewer A findings | No findings. | +| Round 2 | Reviewer B findings | Reviewer infrastructure failure only; superseded by valid later reviews. | +| Round 3 | `[P2] Preserve synchronous custom UI factory invocation` | Use immediate `try/catch` plus `Promise.resolve(factoryResult)`. | +| Round 3 | `[P2] Keep custom UI factories synchronous` | Do not defer factory invocation into a microtask. | +| Round 4 | `[P2] Don’t acquire host focus for pre-aborted custom UI` | Check `options.signal.aborted` before `beginHostInlineCustomUi()` and assert no host state notifications for pre-aborted calls. | + +### Compatibility Posture + +Breaking changes are disallowed. `@bastani/atomic` is a published package with documented extension UI APIs, and workflow users depend on current `/workflow`, `ctx.ui.custom()`, `ask_user_question`, and stage-local HIL behavior. + +## 3. Goals and Non-Goals + +### 3.1 Functional Goals + +- [ ] When the workflow overlay is visible and the parent main-chat agent opens `ask_user_question`, the parent question must be visible and answerable. +- [ ] The workflow overlay must not become input-dead, permanently hidden, or remounted as scrollback noise. +- [ ] The overlay must yield non-destructively with `OverlayHandle.setHidden(true)` / `unfocus()` and restore with `setHidden(false)` / `focus()` only when appropriate. +- [ ] Overlay focus reassertion paths must no-op while a host-owned blocking inline custom UI is active. +- [ ] Host inline custom UI state must always be released on resolve, reject, abort, async factory rejection, and synchronous factory throw. +- [ ] Pre-aborted custom UI calls must not acquire host inline custom UI state or emit active/inactive host state notifications. +- [ ] Custom UI factories must be invoked synchronously in the same call stack as `ctx.ui.custom()` unless the signal is already aborted. +- [ ] `WorkflowGraphOverlayAdapter.open()` called twice in the same turn must not remount duplicate graph overlays. +- [ ] Immediate abort after `ctx.ui.custom()` returns must not allow a previously uninvoked factory to run later. +- [ ] Stage-local workflow prompts must continue to mount inside the attached workflow stage chat and retain existing #1120 focus fixes. +- [ ] Existing `/workflow connect`, F2 overlay open, graph navigation, Ctrl+D hide/detach, and `workflow send` behavior must remain unchanged. +- [ ] Generated root report artifacts must remain out of the repository tree before landing. +- [ ] Validation must use Bun commands only. + +### 3.2 Non-Goals (Out of Scope) + +- [ ] Do not redesign `ask_user_question` question schemas, answer envelopes, or tool semantics. +- [ ] Do not convert every `ask_user_question` dialog into an overlay. +- [ ] Do not add nested workflow graph overlays; workflow `ctx.ui.custom({ overlay: true })` remains unsupported in the graph viewer. +- [ ] Do not change workflow execution, stage scheduling, HIL persistence, or `/workflow send` answer coercion. +- [ ] Do not introduce a public breaking change to `ExtensionUIContext.custom()`, `OverlayHandle`, or workflow APIs. +- [ ] Do not solve general multi-overlay stacking for arbitrary third-party extensions beyond this host-inline-custom-UI conflict. +- [ ] Do not keep internal orchestration reports in the repository root. +- [ ] Do not publish, release, or submit a PR in this stage. + +## 4. Proposed Solution (High-Level Design) + +### 4.1 System Architecture Diagram + +```mermaid +%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#f8f9fa','primaryTextColor':'#2c3e50','primaryBorderColor':'#4a5568','lineColor':'#4a90e2','secondaryColor':'#ffffff','tertiaryColor':'#e9ecef','clusterBkg':'#ffffff','clusterBorder':'#cbd5e0'}}}%% +flowchart TB + classDef person fill:#5a67d8,stroke:#4c51bf,stroke-width:3px,color:#fff,font-weight:600 + classDef host fill:#4a90e2,stroke:#357abd,stroke-width:2.5px,color:#fff,font-weight:600 + classDef workflow fill:#667eea,stroke:#5a67d8,stroke-width:2.5px,color:#fff,font-weight:600 + classDef state fill:#48bb78,stroke:#38a169,stroke-width:2.5px,color:#fff,font-weight:600 + classDef external fill:#718096,stroke:#4a5568,stroke-width:2.5px,color:#fff,font-weight:600,stroke-dasharray:6 3 + + User(("◉
User
keyboard input")):::person + + subgraph AtomicHost["◆ Atomic interactive host — focus airlock"] + direction TB + TUI["pi-tui TUI
single focused component"]:::host + FocusArbiter{{"Host custom UI focus state
the one focus ownership airlock"}}:::host + InteractiveMode["InteractiveMode
showExtensionCustom()"]:::host + PreAbortGate["Pre-abort gate
no token · no factory · no notification"]:::host + FactoryGuard["Synchronous guarded factory
try/catch · no microtask deferral"]:::host + AskTool["ask_user_question
parent ctx.ui.custom inline"]:::host + end + + subgraph Workflows["◆ @bastani/workflows extension"] + direction TB + OverlayAdapter["WorkflowGraphOverlayAdapter
open · toggle · requestFocus"]:::workflow + OverlayHandle["PiOverlayHandle
setHidden · focus · unfocus"]:::state + AttachPane["WorkflowAttachPane
graph ↔ stage chat"]:::workflow + StageChat["StageChatView
stage-local custom UI focus hold"]:::workflow + Broker["StageUiBroker
in-stage ask_user_question"]:::workflow + end + + PiTui{{"@earendil-works/pi-tui
overlay rendering/focus order"}}:::external + + User -->|"raw terminal input"| TUI + TUI --> FocusArbiter + InteractiveMode -->|"mount_main_chat_custom_ui"| PreAbortGate + PreAbortGate -->|"signal already aborted"| FocusArbiter + PreAbortGate -->|"signal live"| FactoryGuard + FactoryGuard -->|"sync component/promise result"| AskTool + FactoryGuard -->|"sync throw"| FocusArbiter + AskTool -->|"begin host inline custom UI"| FocusArbiter + FocusArbiter -->|"active=true"| OverlayAdapter + OverlayAdapter -->|"yield_workflow_overlay_to_host_custom_ui"| OverlayHandle + OverlayHandle -->|"setHidden(true), unfocus()"| PiTui + AskTool -->|"question visible + focused"| TUI + AskTool -->|"resolve/abort/reject"| FocusArbiter + FocusArbiter -->|"active=false"| OverlayAdapter + OverlayAdapter -->|"restore_workflow_overlay_after_host_custom_ui"| OverlayHandle + OverlayHandle -->|"setHidden(false), focus()"| PiTui + + OverlayAdapter --> AttachPane + AttachPane --> StageChat + StageChat -->|"stage-local prompt only"| Broker + StageChat -->|"request_stage_custom_ui_focus
(not host-blocking)"| OverlayAdapter + + style AtomicHost fill:#fff,stroke:#cbd5e0,stroke-width:2px,stroke-dasharray:8 4 + style Workflows fill:#fff,stroke:#cbd5e0,stroke-width:2px,stroke-dasharray:8 4 +``` + +### 4.2 Architectural Pattern + +Use a **single focus-owner arbitration pattern** plus **synchronous factory preservation**: + +- The Atomic host owns the global truth of “a blocking inline custom UI is active.” +- Pre-aborted custom UI requests exit before acquiring host focus ownership. +- Workflow overlays observe host state and yield/restore themselves through their existing `OverlayHandle`. +- Stage-local workflow custom UI remains local to `StageUiBroker` and `StageChatView`. +- Parent inline custom UI factories run synchronously, with synchronous throws caught explicitly. + +### 4.3 Key Components + +| Component | Responsibility | Technology Stack | Justification | +| --------- | -------------- | ---------------- | ------------- | +| `InteractiveMode.showExtensionCustom()` | Mounts extension custom UI and preserves lifecycle ordering | TypeScript, `@earendil-works/pi-tui` | Must remain the host-side focus and factory lifecycle chokepoint. | +| `beginHostInlineCustomUi()` | Creates the host focus ownership token | TypeScript closure | Must only run for live non-overlay custom UI requests. | +| `ExtensionUIContext` | Optional host custom UI state observer API | TypeScript interfaces | Additive/backward-compatible seam for first-party overlays. | +| `WorkflowGraphOverlayAdapter` | Owns workflow overlay handle and focus restoration | TypeScript, workflows TUI | Relies on accurate host state and synchronous overlay factory side effects. | +| `WorkflowAttachPane` | Swaps graph/stage-chat interiors and records visibility | TypeScript component | Existing `setVisible()` integrates with yielding. | +| `StageChatView` | Keeps stage-local prompts focusable in the overlay | TypeScript component | Must preserve existing #1120 mid-turn stage prompt behavior. | +| `ask_user_question` | Parent/session structured question tool | TypeScript tool | Reuses host `ctx.ui.custom()`; no tool schema or answer format changes. | +| Repository hygiene gate | Keeps generated orchestration reports out of commits | Git status / artifact policy | Prevents internal review artifacts from being committed. | + +### 4.4 The Door Set at a Glance (Stranger-Across-Time View) + +`open_workflow_overlay`, `hide_workflow_overlay`, `close_workflow_overlay`, `request_workflow_overlay_focus`, `mount_main_chat_custom_ui`, `refuse_pre_aborted_custom_ui`, `construct_custom_ui_component_synchronously`, `begin_host_inline_custom_ui`, `release_host_inline_custom_ui`, `yield_workflow_overlay_to_host_custom_ui`, `resolve_main_chat_question`, `restore_workflow_overlay_after_host_custom_ui`, `mount_stage_custom_ui`, `answer_stage_custom_ui` + +No door guards an irreversible runtime effect; this change controls UI focus and visibility only. + +## 5. Detailed Design + +### 5.1 The Doors (Entrypoint Contracts) + +```ts +mount_main_chat_custom_ui( + factory: CustomUiFactory, + options?: CustomUiOptions, +): Promise +// Guarantee: mounts a host-owned custom UI and releases host focus ownership on every exit. +// Failures: Aborted | FactoryThrewSynchronously | FactoryRejected | ComponentDisposed | UiUnavailable +// Refusals: overlay custom UI does not become a blocking inline custom UI. +``` + +```ts +refuse_pre_aborted_custom_ui( + signal?: AbortSignal, +): Result +// Guarantee: rejects an already-aborted custom UI request before host focus state changes. +// Failures: Aborted +// Refusals: cannot invoke factory, acquire host token, or notify host state listeners. +``` + +```ts +construct_custom_ui_component_synchronously( + factory: CustomUiFactory, + done: (result: T) => void, +): CustomUiComponent | Promise +// Guarantee: invokes the factory before ctx.ui.custom() returns. +// Failures: FactoryThrewSynchronously +// Refusals: factory invocation cannot be deferred into a microtask. +``` + +```ts +begin_host_inline_custom_ui(): ReleaseHostInlineCustomUi +// Guarantee: marks a live parent inline custom UI as the current focus owner. +// Failures: none; nested calls increment depth. +// Refusals: cannot be called for pre-aborted requests. +``` + +```ts +release_host_inline_custom_ui(release: ReleaseHostInlineCustomUi): void +// Guarantee: releases exactly one host inline custom UI ownership claim. +// Failures: none; duplicate release is a no-op. +// Refusals: depth cannot become negative. +``` + +```ts +on_host_custom_ui_state_change( + listener: (state: HostCustomUiState) => void, +): Unsubscribe +// Guarantee: notifies observers when host inline custom UI active/inactive state changes. +// Failures: ListenerThrows is swallowed without breaking UI cleanup. +// Refusals: observer receives only state, not prompt data. +``` + +```ts +yield_workflow_overlay_to_host_custom_ui(): void +// Guarantee: hides a visible workflow overlay without resolving or remounting it. +// Failures: OverlayNotMounted | OverlayAlreadyHidden | HandleUnavailable +// Refusals: does not cancel workflow runs, brokered prompts, or stage chat state. +``` + +```ts +restore_workflow_overlay_after_host_custom_ui(): void +// Guarantee: restores only the workflow overlay that this host custom UI yield hid. +// Failures: OverlayClosed | HostCustomUiStillActive | UserHiddenOverlay +// Refusals: does not reopen overlays the user explicitly hid or closed. +``` + +```ts +request_workflow_overlay_focus(): void +// Guarantee: focuses the visible workflow overlay only when host focus is not blocked. +// Failures: OverlayNotMounted | OverlayHidden | HostInlineCustomUiActive +// Refusals: cannot steal focus from a parent inline custom UI. +``` + +```ts +mount_stage_custom_ui(request: StageCustomUiRequest): Promise +// Guarantee: mounts a stage-owned custom UI inside the attached workflow stage chat. +// Failures: MissingTuiHost | OverlayModeUnsupported | BrokerRejected | Aborted +// Refusals: stage-local custom UI cannot create a nested overlay. +``` + +**Per-door audit:** + +| Door | (1) Joint | (2) One sentence, no "and" | (3) Honest name | (5) Every exit | (6) Refusals real | (7) Trust transition | (8) One chokepoint | +| ---- | --------- | -------------------------- | --------------- | -------------- | ----------------- | -------------------- | ------------------ | +| `mount_main_chat_custom_ui` | ✅ host modal mount | ✅ mounts a host-owned custom UI | ✅ | abort/reject/resolve/factory throw | ✅ overlay mode excluded from blocking state | ✅ host focus airlock | ✅ parent inline custom UI door | +| `refuse_pre_aborted_custom_ui` | ✅ abort gate | ✅ rejects before host state changes | ✅ | aborted / proceed | ✅ no token, factory, notification | ✅ host focus airlock | ✅ pre-abort chokepoint | +| `construct_custom_ui_component_synchronously` | ✅ factory construction | ✅ invokes factory before return | ✅ | sync throw / returned value | ✅ no microtask deferral | n/a | ✅ factory timing door | +| `begin_host_inline_custom_ui` | ✅ focus ownership claim | ✅ marks live inline UI as focus owner | ✅ | release / nested depth | ✅ pre-aborted requests excluded | ✅ focus ownership airlock | ✅ active-state source | +| `release_host_inline_custom_ui` | ✅ focus ownership release | ✅ releases one active claim | ✅ | duplicate release no-op | ✅ depth clamped at zero | ✅ focus ownership airlock | ✅ cleanup chokepoint | +| `yield_workflow_overlay_to_host_custom_ui` | ✅ focus handoff | ✅ hides workflow overlay without resolving it | ✅ | mounted/hidden/no-handle | ✅ cannot cancel workflow | n/a | ✅ overlay yield chokepoint | +| `restore_workflow_overlay_after_host_custom_ui` | ✅ focus restoration | ✅ restores only auto-yielded overlay | ✅ | closed/still-active/user-hidden | ✅ no reopen of user-hidden overlay | n/a | ✅ overlay restore chokepoint | +| `request_workflow_overlay_focus` | ✅ focus request | ✅ focuses overlay only when allowed | ✅ | hidden/focused/host-blocked | ✅ host block prevents focus steal | n/a | ✅ all overlay re-focus paths use it | +| `mount_stage_custom_ui` | ✅ stage-local HIL mount | ✅ mounts stage UI inside attached chat | ✅ | missing host/unsupported overlay/abort | ✅ nested overlay rejected | n/a | ✅ stage broker path | + +### 5.2 API Interfaces — The Same Doors on the Wire + +This feature has no HTTP/gRPC wire surface. The “wire” is the TUI and extension API surface. + +```ts +// Parent main-chat structured question. +ask_user_question(params) + -> ctx.ui.custom(factory, { signal }) + -> refuse_pre_aborted_custom_ui + -> mount_main_chat_custom_ui +``` + +```ts +// Workflow overlay entrypoints. +F2 +/workflow connect +workflow({ action: "connect" }) + -> GraphOverlayPort.open(runId, ctx) + -> open_workflow_overlay +``` + +```ts +type HostCustomUiState = { + blockingInlineCustomUiDepth: number; + blockingInlineCustomUiActive: boolean; +}; + +type HostCustomUiStateListener = (state: HostCustomUiState) => void; + +interface ExtensionUIContext { + getHostCustomUiState?(): HostCustomUiState; + onHostCustomUiStateChange?( + listener: HostCustomUiStateListener, + ): () => void; +} +``` + +Required `showExtensionCustom()` lifecycle ordering: + +```ts +let releaseHostInlineCustomUi: (() => void) | undefined; + +const releaseHostCustomUi = () => { + releaseHostInlineCustomUi?.(); +}; + +if (options?.signal?.aborted) { + rejectAndClose(options.signal.reason ?? new Error("Extension custom UI aborted")); + return; +} + +options?.signal?.addEventListener("abort", abortCustomUi, { once: true }); + +if (!isOverlay) { + releaseHostInlineCustomUi = this.beginHostInlineCustomUi(); +} + +let factoryResult: + | (Component & { dispose?(): void }) + | Promise; + +try { + factoryResult = factory(this.ui, theme, this.keybindings, close); +} catch (error) { + rejectAndClose(error); + return; +} + +Promise.resolve(factoryResult) + .then((component) => { + if (closed) { + component.dispose?.(); + return; + } + + if (!isOverlay) { + editorContainer.clear(); + editorContainer.addChild(component); + ui.setFocus(component); + mounted = true; + ui.requestRender(); + return; + } + + const handle = ui.showOverlay(component, resolveOptions()); + mounted = true; + options?.onHandle?.(handle); + }) + .catch((error) => { + rejectAndClose(error); + }); +``` + +Explicitly forbidden patterns: + +```ts +// Do not use this: it defers factory side effects past ctx.ui.custom() return. +Promise.resolve().then(() => factory(this.ui, theme, this.keybindings, close)); + +// Do not do this: it emits false host active/inactive for pre-aborted UI. +const release = this.beginHostInlineCustomUi(); +if (options?.signal?.aborted) abortCustomUi(); +``` + +Existing hosts that do not implement the optional observer methods continue to compile and run. Workflows must treat absence as `blockingInlineCustomUiActive === false`. + +### 5.3 Data Model / Schema + +No persistent database schema is required. The change adds ephemeral in-memory UI state. + +| State | Owner | Type | Constraints | Description | +| ----- | ----- | ---- | ----------- | ----------- | +| `blockingInlineCustomUiDepth` | `InteractiveMode` | `number` | integer, `>= 0` | Count of active non-overlay host custom UI mounts. | +| `hostCustomUiStateListeners` | `InteractiveMode` | `Set` | listeners removed on unsubscribe | Broadcasts active/inactive changes to extension overlays. | +| `releaseHostInlineCustomUi` | `showExtensionCustom()` | `(() => void) \| undefined` | assigned only after pre-abort check; idempotent release | Releases the active host custom UI claim. | +| `closed` | `showExtensionCustom()` | `boolean` | monotonic false → true | Ensures resolve/reject/abort/sync-throw cleanup runs once. | +| `mounted` | `showExtensionCustom()` | `boolean` | true only after UI is mounted | Avoids restoring editor for a factory that never mounted. | +| `factoryResult` | `showExtensionCustom()` | `Component \| Promise` | assigned synchronously or routes throw to cleanup | Preserves factory side effects before `ctx.ui.custom()` returns. | +| `overlayYieldedToHostCustomUi` | `WorkflowGraphOverlayAdapter` | `boolean` | true only when this adapter hid the overlay | Prevents restoring overlays hidden by the user. | +| `currentHandle` | `WorkflowGraphOverlayAdapter` | `PiOverlayHandle \| null` | null after close | Existing overlay control handle for `setHidden`, `focus`, `unfocus`, `hide`. | +| `currentView.visible` | `WorkflowAttachPane` | `boolean` | synced through `setVisible()` | Keeps stage attached state/status tags consistent with overlay visibility. | + +### 5.4 Algorithms and State Management + +**Host inline custom UI lifecycle** + +1. `showExtensionCustom()` determines `isOverlay = options?.overlay ?? false`. +2. If the abort signal is already aborted, reject through the normal cleanup path before: + - acquiring `beginHostInlineCustomUi()`; + - registering host active state; + - notifying host custom UI listeners; + - invoking the factory. +3. Register the abort listener only after the pre-abort check. +4. For non-overlay custom UI, use `beginHostInlineCustomUi()` to mark the host as owning focus. +5. Invoke the custom UI factory synchronously inside `try/catch`. +6. If the factory throws synchronously, call `rejectAndClose(error)` and return. +7. Wrap the returned component or promise with `Promise.resolve(factoryResult)`. +8. Mount the component and call `ui.setFocus(component)` as today. +9. On resolve, reject, abort, async factory rejection, or synchronous factory throw: + - remove abort listener; + - restore the editor only if `mounted === true`; + - dispose the component if present; + - release host inline custom UI state if acquired; + - resolve/reject the promise exactly once. + +**Workflow overlay yield** + +1. On observer `active=true`, `WorkflowGraphOverlayAdapter` checks: + - mounted; + - `currentHandle !== null`; + - not already hidden; + - not already yielded. +2. If visible, call: + - `currentView?.setVisible(false)`; + - `setMouseScrollTracking(false)`; + - `currentHandle.setHidden(true)`; + - `currentHandle.unfocus()`; + - set `overlayYieldedToHostCustomUi = true`. + +**Workflow overlay restore** + +1. On observer `active=false`, only restore if `overlayYieldedToHostCustomUi === true`. +2. If still mounted and handle exists: + - `currentView?.setVisible(true)`; + - `setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true)`; + - `currentHandle.setHidden(false)`; + - `currentHandle.focus()`; + - request render. +3. Clear the yielded flag. +4. If the overlay was closed or explicitly hidden by user action, skip restore. + +**Focus request guard** + +Every workflow auto-focus path must consult the host block predicate before calling `focus()`: + +- `refocusVisibleOverlayForAwaitingInput()` in `packages/workflows/src/tui/overlay-adapter.ts`. +- `requestFocus` in `packages/workflows/src/tui/overlay-adapter.ts`. +- mounted-hidden reopen/toggle paths where a host inline custom UI is active. + +**Repository hygiene** + +1. Root generated reports must remain absent: + - `analysis-report.md` + - `bun-preflight-report.md` + - `implementation-report.md` + - `locator-report.md` + - `preflight-report.md` + - `validation-report.md` +2. Future generated reports should be written to `/tmp`, `.atomic/workflows/runs/`, or another ignored artifact directory. +3. `git status --short` must not show untracked root orchestration reports. + +**State machine** + +```mermaid +stateDiagram-v2 + [*] --> RequestReceived + RequestReceived --> RefusedPreAborted: signal already aborted + RequestReceived --> HostInlineActive: live non-overlay custom UI + HostInlineActive --> OverlayYieldedToHostCustomUi: observer active=true + OverlayYieldedToHostCustomUi --> HostInlineSettled: resolve/reject/abort + HostInlineSettled --> OverlayVisibleFocused: release token && observer active=false + RefusedPreAborted --> [*]: no token, no factory, no overlay churn +``` + +## 6. Alternatives Considered + +| Option | Pros | Cons | Reason for Rejection | +| ------ | ---- | ---- | -------------------- | +| **A: Guard overlay `requestFocus()` only** | Smallest code change | Full-screen overlay can still cover the parent inline question | Rejected because it fixes focus stealing but not visibility. | +| **B: Render parent `ask_user_question` above the workflow overlay** | Keeps workflow overlay visible | Changes global `ask_user_question` behavior and requires z-order guarantees | Rejected as broader and riskier. | +| **C: Disable or queue parent `ask_user_question` while workflow overlay is open** | Avoids simultaneous surfaces | Blocks legitimate parent clarification flows | Rejected because the question should remain answerable. | +| **D: Host observer + non-destructive overlay yield/restore (Selected)** | Uses existing `setHidden`/`unfocus`, preserves overlay state | Adds an optional focus-state observer seam | Selected because it solves focus and visibility with bounded scope. | +| **E: Defer factory through `Promise.resolve().then(...)`** | Routes sync throws into promise rejection | Breaks same-turn overlay no-remount and creates abort-before-microtask side effects | Rejected by review round 3. | +| **F: Immediate factory `try/catch` + `Promise.resolve(factoryResult)` (Selected)** | Preserves synchronous factory contract and catches sync throws | Slightly more verbose implementation | Selected because it satisfies both round 1 and round 3 findings. | +| **G: Acquire host focus token before checking pre-abort** | Simple cleanup path | Emits false active/inactive state and causes overlay yield/restore churn for cancelled UI | Rejected by review round 4. | +| **H: Ignore generated report files until PR time** | No code work | High risk of accidentally committing internal artifacts | Rejected by review round 1. | + +## 7. Cross-Cutting Concerns + +### 7.1 Security and Privacy + +- The focus airlock exposes only boolean/depth state, never question text, option labels, answers, or component internals. +- `ask_user_question` result envelopes and workflow HIL answer handling remain unchanged. +- No new network calls, files, tokens, or persistence are introduced. +- Listener failures must not prevent cleanup. +- Existing non-interactive/headless policies remain intact. + +### 7.2 Reliability + +- Host inline custom UI token release must be idempotent. +- Pre-aborted requests must not acquire or release a token. +- Synchronous custom UI factory throws, async rejections, aborts, and normal resolutions must share one cleanup path. +- Custom UI factory invocation must stay synchronous to preserve overlay no-remount guards. +- User-hidden overlays must not be restored by host custom UI deactivation. +- Workflow overlay state must not be remounted or committed to scrollback during yield/restore. + +### 7.3 Accessibility and UX + +- Parent questions should be visually reachable immediately when opened. +- Cancelled pre-aborted questions must not produce visible overlay flicker or unexpected graph focus. +- Restored workflow overlays should return to the same graph/stage-chat state without scrollback duplication. +- Keyboard focus after question close should land on the workflow overlay if it was visible before the question; otherwise it should remain on the editor/default host focus. +- Stage-local workflow questions must continue to show inside the attached stage chat. + +### 7.4 Repository Hygiene + +- Generated reports from agent orchestration are not product artifacts and must not live at repo root when the implementation lands. +- If such reports are useful during development, they should be written under `/tmp` or an ignored run/artifact directory. +- Validation must include a `git status --short` check for unexpected untracked generated reports. + +## Backwards Compatibility + +Breaking changes are disallowed. + +Compatibility-sensitive surfaces that must be preserved: + +- `ExtensionUIContext.custom(factory, options?)` signature in `packages/coding-agent/src/core/extensions/types.ts`. +- The synchronous custom UI factory invocation timing of `ctx.ui.custom()`. +- Pre-aborted `ctx.ui.custom()` calls must reject without mounting, focusing, or emitting host custom UI state. +- `OverlayHandle` methods and semantics documented in `packages/coding-agent/docs/tui.md`. +- `ask_user_question` tool parameters, validation, abort behavior, loader visibility behavior, and response envelope. +- Workflow `ctx.ui.custom()` broker behavior, including rejection of nested `overlay: true`. +- `/workflow` commands, F2 shortcut behavior, graph overlay toggle/hide semantics, and `workflow send`. +- Existing tests around #1120, #1137, #1141, #1148, and #1261. + +The host custom UI observer remains optional and additive. Older/minimal UI contexts that only implement `custom` must continue to work. Workflows must feature-detect observer methods and default to current behavior if unavailable. + +## 8. Test Plan + +- **Unit Tests:** + - Verify synchronous non-overlay custom UI factory throws release host state. + - Verify asynchronous non-overlay custom UI factory rejection releases host state. + - Verify the custom UI factory runs synchronously before `showExtensionCustom()` / `ctx.ui.custom()` returns. + - Verify a pre-aborted signal does not invoke the factory. + - Add/strengthen the pre-aborted signal test to assert no host custom UI state listener events are emitted. + - Verify immediate abort after `ctx.ui.custom()` returns does not cause an uninvoked factory to run later. + - Keep existing `ask_user_question` abort signal and loader restore tests passing. + +- **Integration Tests:** + - Verify visible graph overlay yields/restores around host inline custom UI. + - Verify a user-hidden overlay is not restored by host inactive. + - Verify store-update refocus is suppressed while host inline UI is active. + - Verify stage-chat focus hold is suppressed while host inline UI is active. + - Add a pre-aborted host custom UI / visible overlay regression: assert no `setHidden(true)`, `setHidden(false)`, `unfocus()`, or `focus()` calls occur. + - Verify same-turn no-remount: call `adapter.open("run-1")` and `adapter.open("run-2")` in the same turn through the real/synchronous host custom path and assert only one `ctx.ui.custom` mount occurs. + - Existing tests around visible retarget focus (#1120), toggle `setHidden`, Ctrl+D hide, and full-screen overlay mount must continue to pass. + +- **Repository Hygiene Tests / Checks:** + - Confirm root generated reports are absent. + - Run `git status --short` and confirm no untracked root report files remain. + - Run `git diff --check origin/main`. + +- **Validation Commands:** + 1. `bun test test/integration/overlay-entrypoints.test.ts` + 2. `bun test test/unit/stage-chat-view.test.ts` + 3. `bun test packages/coding-agent/test/interactive-mode-status.test.ts` + 4. `bun test packages/coding-agent/test/ask-user-question-tool.test.ts` + 5. `bun run typecheck` + 6. `git diff --check origin/main` + 7. `git status --short` + +- **Manual Verification:** + 1. Start an interactive Atomic session. + 2. Start a background workflow and open the graph overlay with F2 or `/workflow connect `. + 3. Trigger a parent main-chat `ask_user_question`. + 4. Expected: workflow overlay yields or is hidden, the question is visible and answerable, and after answering the overlay returns focused and navigable. + 5. Repeat with a deliberately broken extension custom UI factory that throws synchronously. + 6. Expected: no stuck `blockingInlineCustomUiActive` state; workflow overlay focus behavior recovers. + 7. Trigger a pre-aborted parent question while the workflow overlay is visible. + 8. Expected: no overlay hide/show flicker and no unexpected focus change. + 9. Trigger two overlay opens in the same command turn. + 10. Expected: no duplicate overlay remount and no scrollback pollution. + +- **Fuzz / Property Tests:** + - Randomize host custom UI active/inactive events with overlay open/hide/close calls and assert: + - overlay handle calls never remount; + - hidden depth never becomes negative; + - `focus()` is never called while host active=true; + - user-hidden overlays are not restored by host inactive=false transitions; + - pre-aborted requests emit zero host state events; + - sync/async factory failures always leave host custom UI depth at zero; + - no factory side effect occurs after cancellation unless the factory already ran synchronously before cancellation. + +## 9. Open Questions / Unresolved Issues + +- [ ] Should `getHostCustomUiState()` / `onHostCustomUiStateChange()` be documented as a supported public extension API or described as an advanced host capability for first-party overlays? `[OWNER: Atomic CLI maintainers]` +- [ ] If multiple extension overlays are visible, should all capturing overlays yield to a parent inline custom UI, or only the workflow graph overlay? `[OWNER: TUI/platform team]` +- [ ] What should happen if the user presses F2 or `/workflow connect` while a parent `ask_user_question` is active? `[OWNER: workflows team]` +- [ ] Should restore focus return to the workflow overlay if the workflow run ended while the parent question was open? `[OWNER: workflows team]` +- [ ] Should `.gitignore` gain a dedicated ignored artifact directory for future agent-generated reports, or should orchestration always write such reports outside the repo? `[OWNER: repo maintainers]` diff --git a/test/integration/overlay-entrypoints.test.ts b/test/integration/overlay-entrypoints.test.ts index a8aeb6b57..cef50f889 100644 --- a/test/integration/overlay-entrypoints.test.ts +++ b/test/integration/overlay-entrypoints.test.ts @@ -26,12 +26,15 @@ import { describe, test } from "bun:test"; import assert from "node:assert/strict"; import { buildGraphOverlayAdapter } from "../../packages/workflows/src/tui/overlay-adapter.js"; import type { OverlayPiSurface } from "../../packages/workflows/src/tui/overlay-adapter.js"; +import { InteractiveMode } from "../../packages/coding-agent/src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../../packages/coding-agent/src/modes/interactive/theme/theme.ts"; import type { PiCustomComponent, PiCustomOverlayFactory, PiCustomOverlayFactoryTui, PiCustomOverlayFunction, PiCustomOverlayOptions, + PiHostCustomUiStateListener, PiOverlayHandle, } from "../../packages/workflows/src/extension/wiring.js"; import { @@ -195,6 +198,92 @@ function buildMockUi(mockOpts: MockUiOpts = {}): { return { ui, calls }; } +function buildInteractiveHostCustomUi(): { + ui: NonNullable; + customMounts: PiCustomOverlayFactory[]; + overlayHandles: Array>; + overlayShows: () => number; + customPromises: Promise[]; +} { + initTheme("dark"); + const customMounts: PiCustomOverlayFactory[] = []; + const customPromises: Promise[] = []; + const overlayHandles: Array> = []; + let overlayShowCount = 0; + const host: any = { + editor: { + getText: () => "", + setText: () => undefined, + }, + editorContainer: { + clear: () => undefined, + addChild: () => undefined, + }, + keybindings: {}, + ui: { + setFocus: () => undefined, + requestRender: () => undefined, + showOverlay: () => { + overlayShowCount++; + const overlayHandle = buildOverlayHandle(); + overlayHandles.push(overlayHandle); + return overlayHandle.handle; + }, + hideOverlay: () => undefined, + }, + blockingInlineCustomUiDepth: 0, + hostCustomUiStateListeners: new Set(), + }; + Object.setPrototypeOf(host, (InteractiveMode as any).prototype); + + const ui = host.ui as NonNullable; + ui.custom = (factoryArg, options) => { + customMounts.push(factoryArg); + const promise = (InteractiveMode as any).prototype.showExtensionCustom.call( + host, + factoryArg, + options, + ) as Promise; + customPromises.push(promise); + return promise; + }; + ui.getHostCustomUiState = () => host.getHostCustomUiState(); + ui.onHostCustomUiStateChange = (listener) => host.onHostCustomUiStateChange(listener); + + return { + ui, + customMounts, + overlayHandles, + overlayShows: () => overlayShowCount, + customPromises, + }; +} + +function attachHostCustomUiState(ui: NonNullable): { + setActive: (active: boolean) => void; +} { + let depth = 0; + const listeners = new Set(); + const snapshot = () => ({ + blockingInlineCustomUiDepth: depth, + blockingInlineCustomUiActive: depth > 0, + }); + ui.getHostCustomUiState = snapshot; + ui.onHostCustomUiStateChange = (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + return { + setActive: (active) => { + depth = active ? 1 : 0; + const state = snapshot(); + for (const listener of listeners) listener(state); + }, + }; +} + /** Create a minimal mock pi ExtensionAPI with the real custom overlay surface. */ function buildMockPi(overrides: Partial = {}): { pi: ExtensionAPI; @@ -492,6 +581,57 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { assert.equal(calls.length, 1); }); + test("same-turn open() calls through InteractiveMode custom path do not remount (#1353)", async () => { + const { ui, customMounts, overlayShows, customPromises } = buildInteractiveHostCustomUi(); + const store = createStore(); + const adapter = buildGraphOverlayAdapter({ ui }, store); + + adapter.open("run-1"); + adapter.open("run-2"); + + assert.equal(customMounts.length, 1, "second same-turn open must not call ctx.ui.custom again"); + await Promise.resolve(); + assert.equal(overlayShows(), 1, "host should mount only one overlay component"); + + adapter.close(); + await Promise.allSettled(customPromises); + }); + + test("pre-aborted host custom UI does not yield or refocus a visible graph overlay (#1353)", async () => { + const { ui, overlayHandles, customPromises } = buildInteractiveHostCustomUi(); + const store = createStore(); + const adapter = buildGraphOverlayAdapter({ ui }, store); + + adapter.open("run-1"); + await Promise.resolve(); + assert.equal(overlayHandles.length, 1, "overlay should be visible before pre-aborted host UI"); + + const { state } = overlayHandles[0]!; + const controller = new AbortController(); + const failure = new Error("already aborted"); + let factoryCalls = 0; + controller.abort(failure); + + const preAborted = ui.custom!( + () => { + factoryCalls++; + return { render: () => [], invalidate: () => undefined }; + }, + { overlay: false, signal: controller.signal } as PiCustomOverlayOptions & { signal: AbortSignal }, + ) as Promise; + + await assert.rejects(preAborted, /already aborted/); + assert.equal(factoryCalls, 0, "pre-aborted inline host UI must not invoke the factory"); + assert.deepEqual(state.setHiddenCalls, [], "pre-abort must not hide or restore the overlay"); + assert.equal(state.unfocusCalls, 0, "pre-abort must not unfocus the overlay"); + assert.equal(state.focusCalls, 0, "pre-abort must not refocus the overlay"); + assert.equal(state.hidden, false); + assert.equal(state.focused, true); + + adapter.close(); + await Promise.allSettled(customPromises); + }); + // Regression for issue #1120: retargeting a visible, mounted overlay must // restore keyboard focus. pi-tui only dispatches key events to the focused // component, so without this the retargeted overlay (e.g. brought to a @@ -517,6 +657,148 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { assert.equal(focusCalls, 1, "visible retarget must restore keyboard focus (#1120)"); }); + test("host inline custom UI hides and restores a visible graph overlay without remounting (#1353)", () => { + const { ui, calls } = buildMockUi(); + const hostCustomUi = attachHostCustomUiState(ui); + const store = createStore(); + const adapter = buildGraphOverlayAdapter({ ui }, store); + + adapter.open("run-1"); + const { handle } = calls[0]!; + let hidden = false; + let focused = true; + const setHiddenCalls: boolean[] = []; + let focusCalls = 0; + let unfocusCalls = 0; + handle.isHidden = () => hidden; + handle.setHidden = (value) => { + setHiddenCalls.push(value); + hidden = value; + }; + handle.isFocused = () => focused; + handle.focus = () => { + focusCalls++; + focused = true; + }; + handle.unfocus = () => { + unfocusCalls++; + focused = false; + }; + + hostCustomUi.setActive(true); + assert.equal(hidden, true); + assert.equal(focused, false); + assert.deepEqual(setHiddenCalls, [true]); + assert.equal(unfocusCalls, 1); + assert.equal(calls.length, 1, "host yield must not remount the overlay"); + + hostCustomUi.setActive(false); + assert.equal(hidden, false); + assert.equal(focused, true); + assert.deepEqual(setHiddenCalls, [true, false]); + assert.equal(focusCalls, 1); + assert.equal(calls.length, 1, "host restore must not remount the overlay"); + }); + + test("host inline custom UI does not restore an overlay hidden by the user (#1353)", () => { + const { ui, calls } = buildMockUi(); + const hostCustomUi = attachHostCustomUiState(ui); + const store = createStore(); + const adapter = buildGraphOverlayAdapter({ ui }, store); + + adapter.open("run-1"); + const { handle } = calls[0]!; + let hidden = false; + const setHiddenCalls: boolean[] = []; + handle.isHidden = () => hidden; + handle.setHidden = (value) => { + setHiddenCalls.push(value); + hidden = value; + }; + handle.unfocus = () => undefined; + handle.focus = () => undefined; + + adapter.toggle("run-1"); + assert.equal(hidden, true); + setHiddenCalls.length = 0; + + hostCustomUi.setActive(true); + hostCustomUi.setActive(false); + + assert.deepEqual(setHiddenCalls, []); + assert.equal(hidden, true, "host inactive must not reveal a user-hidden overlay"); + assert.equal(calls.length, 1); + }); + + test("store-update refocus does not call focus while host inline custom UI is active (#1353)", () => { + const { ui, calls } = buildMockUi(); + let hostActive = false; + ui.getHostCustomUiState = () => ({ + blockingInlineCustomUiDepth: hostActive ? 1 : 0, + blockingInlineCustomUiActive: hostActive, + }); + const store = createStore(); + const runId = "blocked-refocus-run"; + setupSequentialRun(store, runId, 1); + const adapter = buildGraphOverlayAdapter({ ui }, store); + + adapter.open(runId); + const { handle } = calls[0]!; + let focused = false; + let hidden = false; + let focusCalls = 0; + handle.isHidden = () => hidden; + handle.isFocused = () => focused; + handle.focus = () => { + focusCalls++; + focused = true; + }; + handle.setHidden = (value) => { + hidden = value; + }; + + hostActive = true; + + store.recordStagePendingPrompt(runId, "stage-0", { + id: "prompt-1", + kind: "confirm", + message: "approve?", + createdAt: Date.now(), + }); + + assert.equal(focusCalls, 0); + }); + + test("stage-chat focus hold does not call focus while host inline custom UI is active (#1353)", async () => { + const { ui, calls } = buildMockUi(); + let hostActive = false; + ui.getHostCustomUiState = () => ({ + blockingInlineCustomUiDepth: hostActive ? 1 : 0, + blockingInlineCustomUiActive: hostActive, + }); + const store = createStore(); + const runId = "blocked-request-focus-run"; + setupSequentialRun(store, runId, 1); + const adapter = buildGraphOverlayAdapter({ ui }, store); + + adapter.open(runId, undefined, "stage-0"); + const { handle } = calls[0]!; + let focused = false; + let focusCalls = 0; + handle.isHidden = () => false; + handle.isFocused = () => focused; + handle.focus = () => { + focusCalls++; + focused = true; + }; + + hostActive = true; + await delay(180); + adapter.close(); + + assert.equal(focusCalls, 0); + }); + test("visible graph overlay refocuses when detached ctx.ui.editor and confirm prompts appear", async () => { const { ui, calls } = buildMockUi({ rows: 32 }); const store = createStore(); From 9ddbeb1b27df0d723be82463acbd63942e76128c Mon Sep 17 00:00:00 2001 From: Norin Lavaee Date: Sat, 13 Jun 2026 02:54:54 -0700 Subject: [PATCH 2/5] fix(workflows): clean up overlay host question handoff Assistant-model: GPT-5.5 --- packages/workflows/CHANGELOG.md | 4 ++ packages/workflows/src/tui/overlay-adapter.ts | 23 ++++++- test/integration/overlay-entrypoints.test.ts | 62 ++++++++++++++++++- 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 5522f57cc..5e36befa4 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed + +- Fixed workflow graph overlay host-question handoff cleanup and repaint behavior: closing the overlay now unsubscribes host custom-UI listeners, restoring after a host question explicitly requests a render, and the status copy explains that the graph is paused while the user answers the blocking question. + ## [0.8.28] - 2026-06-11 ### Added diff --git a/packages/workflows/src/tui/overlay-adapter.ts b/packages/workflows/src/tui/overlay-adapter.ts index 544a60676..612a558e5 100644 --- a/packages/workflows/src/tui/overlay-adapter.ts +++ b/packages/workflows/src/tui/overlay-adapter.ts @@ -48,6 +48,7 @@ export interface OverlayUISurface { getEditorComponent?: () => PiEditorFactory | undefined; getChatRenderSettings?: () => OverlayChatRenderSettings | undefined; getFooterDataProvider?: () => ReadonlyFooterDataProvider; + setStatus?: (key: string, value: string | undefined) => void; } export interface OverlayPiSurface { @@ -99,6 +100,9 @@ const FULLSCREEN_OVERLAY_OPTIONS: PiOverlayOptions = { const MOUSE_SCROLL_TRACKING_ON = "\x1b[?1000h\x1b[?1006h"; const MOUSE_SCROLL_TRACKING_OFF = "\x1b[?1006l\x1b[?1000l"; +const WORKFLOW_STATUS_KEY = "pi-workflows"; +const HOST_CUSTOM_UI_PAUSED_STATUS = + "Workflow graph paused while you answer this question. Return to the graph after responding."; function setMouseScrollTracking(enabled: boolean): void { if (!process.stdout.isTTY) return; @@ -143,6 +147,7 @@ export function buildGraphOverlayAdapter( let finishMounted: (() => void) | null = null; let observedUi: OverlayUISurface | undefined; let unsubscribeHostCustomUi: (() => void) | null = null; + let currentRequestRender: (() => void) | null = null; let hostInlineCustomUiActive = false; let overlayYieldedToHostCustomUi = false; @@ -160,6 +165,7 @@ export function buildGraphOverlayAdapter( setMouseScrollTracking(false); currentHandle.setHidden(true); currentHandle.unfocus(); + observedUi?.setStatus?.(WORKFLOW_STATUS_KEY, HOST_CUSTOM_UI_PAUSED_STATUS); overlayYieldedToHostCustomUi = true; } @@ -172,6 +178,14 @@ export function buildGraphOverlayAdapter( setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true); currentHandle.setHidden(false); currentHandle.focus(); + currentRequestRender?.(); + } + + function clearHostCustomUiObservation(): void { + unsubscribeHostCustomUi?.(); + unsubscribeHostCustomUi = null; + observedUi = undefined; + hostInlineCustomUiActive = false; } function observeHostCustomUi(ui: OverlayUISurface | undefined): void { @@ -196,11 +210,14 @@ export function buildGraphOverlayAdapter( overlayYieldedToHostCustomUi = false; currentHandle?.hide(); finishMounted?.(); + observedUi?.setStatus?.(WORKFLOW_STATUS_KEY, undefined); currentView?.dispose(); currentHandle = null; finishMounted = null; currentView = null; + currentRequestRender = null; mounted = false; + clearHostCustomUiObservation(); } /** @@ -306,7 +323,7 @@ export function buildGraphOverlayAdapter( const custom = ui?.custom; if (typeof custom !== "function") return; - const uiStatus = ui as { setStatus?: (key: string, value: string | undefined) => void } | undefined; + const uiStatus = ui; let settled = false; const factory = ( @@ -315,15 +332,19 @@ export function buildGraphOverlayAdapter( keybindings: PiKeybindings, done: (result: undefined) => void, ): PiCustomComponent => { + currentRequestRender = () => tui.requestRender?.(); const finish = (): void => { if (settled) return; settled = true; setMouseScrollTracking(false); + observedUi?.setStatus?.(WORKFLOW_STATUS_KEY, undefined); currentView?.dispose(); currentView = null; currentHandle = null; finishMounted = null; + currentRequestRender = null; mounted = false; + clearHostCustomUiObservation(); done(undefined); }; const view = new WorkflowAttachPane({ diff --git a/test/integration/overlay-entrypoints.test.ts b/test/integration/overlay-entrypoints.test.ts index cef50f889..cebccd5b3 100644 --- a/test/integration/overlay-entrypoints.test.ts +++ b/test/integration/overlay-entrypoints.test.ts @@ -163,6 +163,8 @@ interface MockUiOpts { rows?: number; /** Optional terminal-col hint surfaced to the factory's `tui.terminal.columns`. */ columns?: number; + /** Optional observer for custom overlay render requests. */ + onRequestRender?: () => void; } /** @@ -181,7 +183,7 @@ function buildMockUi(mockOpts: MockUiOpts = {}): { const { handle } = buildOverlayHandle(); options.onHandle?.(handle); const tui: PiCustomOverlayFactoryTui = { - requestRender: () => undefined, + requestRender: () => mockOpts.onRequestRender?.(), terminal: mockOpts.rows != null || mockOpts.columns != null ? { rows: mockOpts.rows, columns: mockOpts.columns } @@ -261,6 +263,7 @@ function buildInteractiveHostCustomUi(): { function attachHostCustomUiState(ui: NonNullable): { setActive: (active: boolean) => void; + listenerCount: () => number; } { let depth = 0; const listeners = new Set(); @@ -281,6 +284,7 @@ function attachHostCustomUiState(ui: NonNullable): { const state = snapshot(); for (const listener of listeners) listener(state); }, + listenerCount: () => listeners.size, }; } @@ -658,7 +662,16 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { }); test("host inline custom UI hides and restores a visible graph overlay without remounting (#1353)", () => { - const { ui, calls } = buildMockUi(); + let renderCalls = 0; + const { ui, calls } = buildMockUi({ + onRequestRender: () => { + renderCalls++; + }, + }); + const statusMessages: Array<{ key: string; value: string | undefined }> = []; + ui.setStatus = (key, value) => { + statusMessages.push({ key, value }); + }; const hostCustomUi = attachHostCustomUiState(ui); const store = createStore(); const adapter = buildGraphOverlayAdapter({ ui }, store); @@ -690,6 +703,15 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { assert.equal(focused, false); assert.deepEqual(setHiddenCalls, [true]); assert.equal(unfocusCalls, 1); + assert.ok( + statusMessages.some( + (status) => + status.key === "pi-workflows" && + status.value === + "Workflow graph paused while you answer this question. Return to the graph after responding.", + ), + "yielding to a host question should explain how to return to the graph", + ); assert.equal(calls.length, 1, "host yield must not remount the overlay"); hostCustomUi.setActive(false); @@ -697,9 +719,45 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { assert.equal(focused, true); assert.deepEqual(setHiddenCalls, [true, false]); assert.equal(focusCalls, 1); + assert.equal(renderCalls, 1, "host restore must explicitly request a render"); assert.equal(calls.length, 1, "host restore must not remount the overlay"); }); + test("close unsubscribes from host custom UI state changes (#1353)", () => { + const { ui, calls } = buildMockUi(); + const hostCustomUi = attachHostCustomUiState(ui); + const store = createStore(); + const adapter = buildGraphOverlayAdapter({ ui }, store); + + adapter.open("run-1"); + assert.equal(hostCustomUi.listenerCount(), 1); + + const { handle } = calls[0]!; + const setHiddenCalls: boolean[] = []; + let focusCalls = 0; + let unfocusCalls = 0; + handle.isHidden = () => false; + handle.setHidden = (value) => { + setHiddenCalls.push(value); + }; + handle.focus = () => { + focusCalls++; + }; + handle.unfocus = () => { + unfocusCalls++; + }; + + adapter.close(); + assert.equal(hostCustomUi.listenerCount(), 0); + + hostCustomUi.setActive(true); + hostCustomUi.setActive(false); + + assert.deepEqual(setHiddenCalls, []); + assert.equal(focusCalls, 0); + assert.equal(unfocusCalls, 0); + }); + test("host inline custom UI does not restore an overlay hidden by the user (#1353)", () => { const { ui, calls } = buildMockUi(); const hostCustomUi = attachHostCustomUiState(ui); From 87577e891ded189198baf24e219fffd45a4484ea Mon Sep 17 00:00:00 2001 From: Norin Lavaee Date: Sat, 13 Jun 2026 03:36:12 -0700 Subject: [PATCH 3/5] fix(workflows): restore overlay status after host questions Assistant-model: GPT-5.5 --- packages/workflows/CHANGELOG.md | 2 +- packages/workflows/src/tui/overlay-adapter.ts | 4 +++- .../workflows/src/tui/workflow-attach-pane.ts | 16 ++++++++-------- packages/workflows/src/tui/workflow-status.ts | 2 ++ test/integration/overlay-entrypoints.test.ts | 8 ++++++++ 5 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 packages/workflows/src/tui/workflow-status.ts diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 5e36befa4..52badb01d 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed -- Fixed workflow graph overlay host-question handoff cleanup and repaint behavior: closing the overlay now unsubscribes host custom-UI listeners, restoring after a host question explicitly requests a render, and the status copy explains that the graph is paused while the user answers the blocking question. +- Fixed workflow graph overlay host-question handoff cleanup and repaint behavior: closing the overlay now unsubscribes host custom-UI listeners, restoring after a host question explicitly clears its paused status and requests a render, all overlay teardown paths reset yielded-host-question state, and the status copy explains that the graph is paused while the user answers the blocking question. ## [0.8.28] - 2026-06-11 diff --git a/packages/workflows/src/tui/overlay-adapter.ts b/packages/workflows/src/tui/overlay-adapter.ts index 612a558e5..d268e4e0c 100644 --- a/packages/workflows/src/tui/overlay-adapter.ts +++ b/packages/workflows/src/tui/overlay-adapter.ts @@ -19,6 +19,7 @@ import type { Store } from "../shared/store.js"; import type { StoreSnapshot } from "../shared/store-types.js"; import type { ChatMessageRenderOptions, ReadonlyFooterDataProvider } from "@bastani/atomic"; import { WorkflowAttachPane } from "./workflow-attach-pane.js"; +import { WORKFLOW_STATUS_KEY } from "./workflow-status.js"; import { deriveGraphThemeFromPiTheme } from "./graph-theme.js"; import { killRun as defaultKillRun } from "../runs/background/status.js"; import { cancellationRegistry } from "../runs/background/cancellation-registry.js"; @@ -100,7 +101,6 @@ const FULLSCREEN_OVERLAY_OPTIONS: PiOverlayOptions = { const MOUSE_SCROLL_TRACKING_ON = "\x1b[?1000h\x1b[?1006h"; const MOUSE_SCROLL_TRACKING_OFF = "\x1b[?1006l\x1b[?1000l"; -const WORKFLOW_STATUS_KEY = "pi-workflows"; const HOST_CUSTOM_UI_PAUSED_STATUS = "Workflow graph paused while you answer this question. Return to the graph after responding."; @@ -173,6 +173,7 @@ export function buildGraphOverlayAdapter( if (!overlayYieldedToHostCustomUi) return; if (readHostCustomUiActive()) return; overlayYieldedToHostCustomUi = false; + observedUi?.setStatus?.(WORKFLOW_STATUS_KEY, undefined); if (!mounted || currentHandle === null) return; currentView?.setVisible(true); setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true); @@ -337,6 +338,7 @@ export function buildGraphOverlayAdapter( if (settled) return; settled = true; setMouseScrollTracking(false); + overlayYieldedToHostCustomUi = false; observedUi?.setStatus?.(WORKFLOW_STATUS_KEY, undefined); currentView?.dispose(); currentView = null; diff --git a/packages/workflows/src/tui/workflow-attach-pane.ts b/packages/workflows/src/tui/workflow-attach-pane.ts index 483e81eb2..4306c9372 100644 --- a/packages/workflows/src/tui/workflow-attach-pane.ts +++ b/packages/workflows/src/tui/workflow-attach-pane.ts @@ -38,6 +38,7 @@ import type { import type { StageUiBroker } from "../shared/stage-ui-broker.js"; import type { StageSnapshot, StoreSnapshot } from "../shared/store-types.js"; import { expandWorkflowGraph } from "../shared/expanded-workflow-graph.js"; +import { WORKFLOW_STATUS_KEY } from "./workflow-status.js"; /** * Surface used to write Pi's footer/status tag while the attach pane is @@ -125,7 +126,6 @@ export interface WorkflowAttachPaneOpts { export type WorkflowAttachPaneMode = "graph" | "stage-chat"; -const STATUS_KEY = "pi-workflows"; const ENTER_TRANSITION_QUARANTINE_MS = 200; export class WorkflowAttachPane implements Component { @@ -417,13 +417,13 @@ export class WorkflowAttachPane implements Component { private _setBaseStatus(): void { const runId = this._resolveRunId(); - const name = runId ? `pi-workflows/${this._workflowName(runId)}` : "pi-workflows"; - this.uiStatus?.setStatus?.(STATUS_KEY, name); + const name = runId ? `${WORKFLOW_STATUS_KEY}/${this._workflowName(runId)}` : WORKFLOW_STATUS_KEY; + this.uiStatus?.setStatus?.(WORKFLOW_STATUS_KEY, name); } private _setAttachedStatus(runId: string, stageId: string): void { - const value = `pi-workflows/${this._workflowName(runId)}/${this._stageName(runId, stageId)}`; - this.uiStatus?.setStatus?.(STATUS_KEY, value); + const value = `${WORKFLOW_STATUS_KEY}/${this._workflowName(runId)}/${this._stageName(runId, stageId)}`; + this.uiStatus?.setStatus?.(WORKFLOW_STATUS_KEY, value); } setVisible(visible: boolean): void { @@ -431,11 +431,11 @@ export class WorkflowAttachPane implements Component { if (this.mode === "stage-chat" && this.attachedRunId && this.lastAttachedStageId) { this.store.recordStageAttached(this.attachedRunId, this.lastAttachedStageId, visible); if (visible) this._setAttachedStatus(this.attachedRunId, this.lastAttachedStageId); - else this.uiStatus?.setStatus?.(STATUS_KEY, undefined); + else this.uiStatus?.setStatus?.(WORKFLOW_STATUS_KEY, undefined); return; } if (visible) this._setBaseStatus(); - else this.uiStatus?.setStatus?.(STATUS_KEY, undefined); + else this.uiStatus?.setStatus?.(WORKFLOW_STATUS_KEY, undefined); } private _syncMouseScrollTracking(): void { @@ -609,7 +609,7 @@ export class WorkflowAttachPane implements Component { // back into chat. Without this, every subsequent message header // keeps rendering `pi-workflows/` (or `…/`) until // the next attach replaces the slot. - this.uiStatus?.setStatus?.(STATUS_KEY, undefined); + this.uiStatus?.setStatus?.(WORKFLOW_STATUS_KEY, undefined); } // ---- Test seams ---- diff --git a/packages/workflows/src/tui/workflow-status.ts b/packages/workflows/src/tui/workflow-status.ts new file mode 100644 index 000000000..090d1db69 --- /dev/null +++ b/packages/workflows/src/tui/workflow-status.ts @@ -0,0 +1,2 @@ +/** Shared status slot used by workflow graph/attach UI surfaces. */ +export const WORKFLOW_STATUS_KEY = "pi-workflows"; diff --git a/test/integration/overlay-entrypoints.test.ts b/test/integration/overlay-entrypoints.test.ts index cebccd5b3..6249f4aae 100644 --- a/test/integration/overlay-entrypoints.test.ts +++ b/test/integration/overlay-entrypoints.test.ts @@ -719,6 +719,14 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { assert.equal(focused, true); assert.deepEqual(setHiddenCalls, [true, false]); assert.equal(focusCalls, 1); + assert.deepEqual( + statusMessages.slice(-2), + [ + { key: "pi-workflows", value: undefined }, + { key: "pi-workflows", value: "pi-workflows/workflow" }, + ], + "host restore should explicitly clear its paused status before the pane restores its own status", + ); assert.equal(renderCalls, 1, "host restore must explicitly request a render"); assert.equal(calls.length, 1, "host restore must not remount the overlay"); }); From 31354faa2983560f61436abe86ec2b562af10bfd Mon Sep 17 00:00:00 2001 From: Norin Lavaee Date: Sat, 13 Jun 2026 04:18:15 -0700 Subject: [PATCH 4/5] fix(workflows): defer main chat questions behind graph overlay Keep the workflow graph focused and interactive when main-chat inline custom UI appears. Defer the inline UI focus until the graph overlay is hidden, and show a status hint while the question is pending. Update overlay regression tests for issue #1353 and preserve stage-local HIL focus behavior. --- .../coding-agent/src/core/extensions/types.ts | 9 +- .../src/modes/interactive/interactive-mode.ts | 104 ++++++++++++++++-- .../test/interactive-mode-status.test.ts | 2 + packages/workflows/src/extension/wiring.ts | 5 + packages/workflows/src/tui/overlay-adapter.ts | 65 +++-------- test/integration/overlay-entrypoints.test.ts | 96 +++++++++++----- 6 files changed, 198 insertions(+), 83 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 9c17e2aff..0562fd882 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -153,8 +153,10 @@ export interface ChatRenderSettings { export interface HostCustomUiState { /** Number of active non-overlay host custom UI mounts. */ blockingInlineCustomUiDepth: number; - /** True when at least one non-overlay host custom UI currently owns focus. */ + /** True when at least one non-overlay host custom UI is mounted and blocking. */ blockingInlineCustomUiActive: boolean; + /** True when the active inline custom UI is waiting behind an overlay that kept focus. */ + blockingInlineCustomUiFocusDeferred?: boolean; } export type HostCustomUiStateListener = (state: HostCustomUiState) => void; @@ -185,6 +187,9 @@ export interface ExtensionUIContext { /** Observe host-owned inline custom UI focus state changes. Returns an unsubscribe function. */ onHostCustomUiStateChange?(listener: HostCustomUiStateListener): () => void; + /** Move focus to a mounted host-owned inline custom UI, if one is pending. */ + focusHostInlineCustomUi?(): boolean; + /** Listen to raw terminal input (interactive mode only). Returns an unsubscribe function. */ onTerminalInput(handler: TerminalInputHandler): () => void; @@ -246,6 +251,8 @@ export interface ExtensionUIContext { ) => (Component & { dispose?(): void }) | Promise, options?: { overlay?: boolean; + /** Keep host inline custom UI pending in the background while this overlay is visible. */ + deferInlineCustomUiFocus?: boolean; /** AbortSignal to programmatically dismiss the custom UI. */ signal?: AbortSignal; /** Overlay positioning/sizing options. Can be static or a function for dynamic updates. */ diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f8b381fde..2f4686ccc 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -443,6 +443,8 @@ export class InteractiveMode { private extensionEditor: ExtensionEditorComponent | undefined = undefined; private extensionTerminalInputUnsubscribers = new Set<() => void>(); private blockingInlineCustomUiDepth = 0; + private deferredInlineCustomUiFocusDepth = 0; + private pendingInlineCustomUiFocus: Component | undefined = undefined; private hostCustomUiStateListeners = new Set(); // Extension widgets (components rendered above/below the editor) @@ -2520,9 +2522,12 @@ export class InteractiveMode { } private getHostCustomUiState(): HostCustomUiState { + const focusDeferred = + this.blockingInlineCustomUiDepth > 0 && this.pendingInlineCustomUiFocus !== undefined; return { blockingInlineCustomUiDepth: this.blockingInlineCustomUiDepth, blockingInlineCustomUiActive: this.blockingInlineCustomUiDepth > 0, + ...(focusDeferred ? { blockingInlineCustomUiFocusDeferred: true } : {}), }; } @@ -2552,6 +2557,36 @@ export class InteractiveMode { }; } + private beginInlineCustomUiFocusDeferral(): () => void { + let released = false; + this.deferredInlineCustomUiFocusDepth++; + return () => { + if (released) return; + released = true; + this.deferredInlineCustomUiFocusDepth = Math.max( + 0, + this.deferredInlineCustomUiFocusDepth - 1, + ); + if (this.deferredInlineCustomUiFocusDepth === 0) { + this.focusHostInlineCustomUi(); + } + }; + } + + private shouldDeferInlineCustomUiFocus(): boolean { + return this.deferredInlineCustomUiFocusDepth > 0; + } + + private focusHostInlineCustomUi(): boolean { + const component = this.pendingInlineCustomUiFocus; + if (component === undefined) return false; + this.pendingInlineCustomUiFocus = undefined; + this.ui.setFocus(component); + this.ui.requestRender(); + this.notifyHostCustomUiStateListeners(); + return true; + } + private onHostCustomUiStateChange( listener: HostCustomUiStateListener, ): () => void { @@ -2592,6 +2627,7 @@ export class InteractiveMode { getHostCustomUiState: () => this.getHostCustomUiState(), onHostCustomUiStateChange: (listener) => this.onHostCustomUiStateChange(listener), + focusHostInlineCustomUi: () => this.focusHostInlineCustomUi(), onTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler), setStatus: (key, text) => this.setExtensionStatus(key, text), @@ -2947,6 +2983,7 @@ export class InteractiveMode { | Promise, options?: { overlay?: boolean; + deferInlineCustomUiFocus?: boolean; signal?: AbortSignal; overlayOptions?: OverlayOptions | (() => OverlayOptions); onHandle?: (handle: OverlayHandle) => void; @@ -2955,11 +2992,11 @@ export class InteractiveMode { const savedText = this.editor.getText(); const isOverlay = options?.overlay ?? false; - const restoreEditor = () => { + const restoreEditor = (focusEditor: boolean) => { this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.editor.setText(savedText); - this.ui.setFocus(this.editor); + if (focusEditor) this.ui.setFocus(this.editor); this.ui.requestRender(); }; @@ -2968,6 +3005,7 @@ export class InteractiveMode { let closed = false; let mounted = false; let releaseHostInlineCustomUi: (() => void) | undefined; + let releaseOverlayInlineCustomUiFocusDeferral: (() => void) | undefined; const disposeComponent = () => { try { @@ -2978,6 +3016,10 @@ export class InteractiveMode { }; const releaseHostCustomUi = () => { + if (component !== undefined && this.pendingInlineCustomUiFocus === component) { + this.pendingInlineCustomUiFocus = undefined; + this.notifyHostCustomUiStateListeners(); + } releaseHostInlineCustomUi?.(); }; @@ -2987,8 +3029,16 @@ export class InteractiveMode { const closeMountedUi = () => { if (!mounted) return; - if (isOverlay) this.ui.hideOverlay(); - else restoreEditor(); + if (isOverlay) { + releaseOverlayInlineCustomUiFocusDeferral?.(); + releaseOverlayInlineCustomUiFocusDeferral = undefined; + this.ui.hideOverlay(); + } else { + restoreEditor( + !this.shouldDeferInlineCustomUiFocus() && + this.pendingInlineCustomUiFocus !== component, + ); + } }; const close = (result: T) => { @@ -3065,12 +3115,52 @@ export class InteractiveMode { }; const handle = this.ui.showOverlay(component, resolveOptions()); mounted = true; - // Expose handle to caller for visibility control - options?.onHandle?.(handle); + if (options?.deferInlineCustomUiFocus) { + let releaseDeferral: (() => void) | undefined = this.beginInlineCustomUiFocusDeferral(); + releaseOverlayInlineCustomUiFocusDeferral = () => { + releaseDeferral?.(); + releaseDeferral = undefined; + }; + const release = () => { + releaseOverlayInlineCustomUiFocusDeferral?.(); + releaseOverlayInlineCustomUiFocusDeferral = undefined; + }; + const wrappedHandle: OverlayHandle = { + hide: () => { + release(); + handle.hide(); + }, + setHidden: (hidden) => { + if (hidden) release(); + handle.setHidden(hidden); + if (!hidden && releaseDeferral === undefined) { + releaseDeferral = this.beginInlineCustomUiFocusDeferral(); + releaseOverlayInlineCustomUiFocusDeferral = () => { + releaseDeferral?.(); + releaseDeferral = undefined; + }; + } + }, + isHidden: () => handle.isHidden(), + focus: () => handle.focus(), + unfocus: (unfocusOptions) => handle.unfocus(unfocusOptions), + isFocused: () => handle.isFocused(), + }; + // Expose handle to caller for visibility control + options?.onHandle?.(wrappedHandle); + } else { + // Expose handle to caller for visibility control + options?.onHandle?.(handle); + } } else { this.editorContainer.clear(); this.editorContainer.addChild(component); - this.ui.setFocus(component); + if (this.shouldDeferInlineCustomUiFocus()) { + this.pendingInlineCustomUiFocus = component; + this.notifyHostCustomUiStateListeners(); + } else { + this.ui.setFocus(component); + } mounted = true; this.ui.requestRender(); } diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index cd8678478..16f5563ef 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -257,6 +257,8 @@ describe("InteractiveMode.showExtensionCustom host custom UI state", () => { requestRender: vi.fn(), }, blockingInlineCustomUiDepth: 0, + deferredInlineCustomUiFocusDepth: 0, + pendingInlineCustomUiFocus: undefined, hostCustomUiStateListeners: new Set(), }; Object.setPrototypeOf(fakeThis, (InteractiveMode as any).prototype); diff --git a/packages/workflows/src/extension/wiring.ts b/packages/workflows/src/extension/wiring.ts index bac47b4d1..5b7736c69 100644 --- a/packages/workflows/src/extension/wiring.ts +++ b/packages/workflows/src/extension/wiring.ts @@ -502,6 +502,7 @@ export interface PiOverlayHandle { export interface PiHostCustomUiState { blockingInlineCustomUiDepth: number; blockingInlineCustomUiActive: boolean; + blockingInlineCustomUiFocusDeferred?: boolean; } export type PiHostCustomUiStateListener = (state: PiHostCustomUiState) => void; @@ -513,6 +514,8 @@ export interface PiCustomOverlayOptions { * place of the editor until the factory's `done()` callback fires. */ overlay: boolean; + /** Keep host inline custom UI pending in the background while this overlay is visible. */ + deferInlineCustomUiFocus?: boolean; /** * Geometry / anchoring intended for pi-tui's `resolveOverlayLayout`. * NOT forwarded by current pi interactive `custom()` — see @@ -647,6 +650,8 @@ export interface PiUISurface { getHostCustomUiState?: () => PiHostCustomUiState; /** Observe host-owned inline custom UI focus state changes, if exposed by the host. */ onHostCustomUiStateChange?: (listener: PiHostCustomUiStateListener) => () => void; + /** Move focus to a mounted host-owned inline custom UI, if one is pending. */ + focusHostInlineCustomUi?: () => boolean; pasteToEditor?: (text: string) => void; setEditorText?: (text: string) => void; getEditorText?: () => string; diff --git a/packages/workflows/src/tui/overlay-adapter.ts b/packages/workflows/src/tui/overlay-adapter.ts index d268e4e0c..e7f0b04c0 100644 --- a/packages/workflows/src/tui/overlay-adapter.ts +++ b/packages/workflows/src/tui/overlay-adapter.ts @@ -46,6 +46,7 @@ export interface OverlayUISurface { custom?: PiCustomOverlayFunction; getHostCustomUiState?: () => PiHostCustomUiState; onHostCustomUiStateChange?: (listener: PiHostCustomUiStateListener) => () => void; + focusHostInlineCustomUi?: () => boolean; getEditorComponent?: () => PiEditorFactory | undefined; getChatRenderSettings?: () => OverlayChatRenderSettings | undefined; getFooterDataProvider?: () => ReadonlyFooterDataProvider; @@ -101,8 +102,8 @@ const FULLSCREEN_OVERLAY_OPTIONS: PiOverlayOptions = { const MOUSE_SCROLL_TRACKING_ON = "\x1b[?1000h\x1b[?1006h"; const MOUSE_SCROLL_TRACKING_OFF = "\x1b[?1006l\x1b[?1000l"; -const HOST_CUSTOM_UI_PAUSED_STATUS = - "Workflow graph paused while you answer this question. Return to the graph after responding."; +const MAIN_CHAT_INPUT_STATUS_KEY = `${WORKFLOW_STATUS_KEY}:main-chat-input`; +const MAIN_CHAT_INPUT_STATUS = "Main chat needs input — exit graph to answer."; function setMouseScrollTracking(enabled: boolean): void { if (!process.stdout.isTTY) return; @@ -147,9 +148,7 @@ export function buildGraphOverlayAdapter( let finishMounted: (() => void) | null = null; let observedUi: OverlayUISurface | undefined; let unsubscribeHostCustomUi: (() => void) | null = null; - let currentRequestRender: (() => void) | null = null; let hostInlineCustomUiActive = false; - let overlayYieldedToHostCustomUi = false; function readHostCustomUiActive(ui: OverlayUISurface | undefined = observedUi): boolean { const state = ui?.getHostCustomUiState?.(); @@ -157,34 +156,17 @@ export function buildGraphOverlayAdapter( return hostInlineCustomUiActive; } - function yieldToHostCustomUi(): void { - if (overlayYieldedToHostCustomUi) return; - if (!mounted || currentHandle === null) return; - if (currentHandle.isHidden()) return; - currentView?.setVisible(false); - setMouseScrollTracking(false); - currentHandle.setHidden(true); - currentHandle.unfocus(); - observedUi?.setStatus?.(WORKFLOW_STATUS_KEY, HOST_CUSTOM_UI_PAUSED_STATUS); - overlayYieldedToHostCustomUi = true; - } - - function restoreAfterHostCustomUi(): void { - if (!overlayYieldedToHostCustomUi) return; - if (readHostCustomUiActive()) return; - overlayYieldedToHostCustomUi = false; - observedUi?.setStatus?.(WORKFLOW_STATUS_KEY, undefined); - if (!mounted || currentHandle === null) return; - currentView?.setVisible(true); - setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true); - currentHandle.setHidden(false); - currentHandle.focus(); - currentRequestRender?.(); + function updateMainChatInputHint(active: boolean): void { + observedUi?.setStatus?.( + MAIN_CHAT_INPUT_STATUS_KEY, + active ? MAIN_CHAT_INPUT_STATUS : undefined, + ); } function clearHostCustomUiObservation(): void { unsubscribeHostCustomUi?.(); unsubscribeHostCustomUi = null; + observedUi?.setStatus?.(MAIN_CHAT_INPUT_STATUS_KEY, undefined); observedUi = undefined; hostInlineCustomUiActive = false; } @@ -198,25 +180,23 @@ export function buildGraphOverlayAdapter( if (typeof ui?.onHostCustomUiStateChange === "function") { unsubscribeHostCustomUi = ui.onHostCustomUiStateChange((state) => { hostInlineCustomUiActive = state.blockingInlineCustomUiActive; - if (hostInlineCustomUiActive) yieldToHostCustomUi(); - else restoreAfterHostCustomUi(); + updateMainChatInputHint(hostInlineCustomUiActive); }); } } - if (readHostCustomUiActive(ui)) yieldToHostCustomUi(); + updateMainChatInputHint(readHostCustomUiActive(ui)); } function close(): void { setMouseScrollTracking(false); - overlayYieldedToHostCustomUi = false; currentHandle?.hide(); finishMounted?.(); observedUi?.setStatus?.(WORKFLOW_STATUS_KEY, undefined); + observedUi?.setStatus?.(MAIN_CHAT_INPUT_STATUS_KEY, undefined); currentView?.dispose(); currentHandle = null; finishMounted = null; currentView = null; - currentRequestRender = null; mounted = false; clearHostCustomUiObservation(); } @@ -239,7 +219,7 @@ export function buildGraphOverlayAdapter( */ function hideMounted(): void { setMouseScrollTracking(false); - overlayYieldedToHostCustomUi = false; + observedUi?.setStatus?.(MAIN_CHAT_INPUT_STATUS_KEY, undefined); if (currentHandle) { currentView?.setVisible(false); currentHandle.setHidden(true); @@ -253,7 +233,6 @@ export function buildGraphOverlayAdapter( } function refocusVisibleOverlayForAwaitingInput(snapshot: StoreSnapshot): void { - if (readHostCustomUiActive()) return; if (currentHandle === null) return; if (currentHandle.isHidden()) return; if (currentHandle.isFocused()) return; @@ -293,12 +272,10 @@ export function buildGraphOverlayAdapter( ): void { const ui = surface?.ui ?? pi.ui; observeHostCustomUi(ui); - const hostBlocked = readHostCustomUiActive(ui); // Already mounted but hidden — flip visibility without remounting. if (mounted && currentHandle?.isHidden()) { currentView?.retarget(runId, stageId); - if (hostBlocked) return; currentView?.setVisible(true); setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true); currentHandle.setHidden(false); @@ -307,10 +284,6 @@ export function buildGraphOverlayAdapter( } if (mounted) { currentView?.retarget(runId, stageId); - if (hostBlocked) { - yieldToHostCustomUi(); - return; - } setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true); // Restore keyboard focus to the visible overlay after retargeting. // pi-tui dispatches key events only to the focused component, so a @@ -333,18 +306,16 @@ export function buildGraphOverlayAdapter( keybindings: PiKeybindings, done: (result: undefined) => void, ): PiCustomComponent => { - currentRequestRender = () => tui.requestRender?.(); const finish = (): void => { if (settled) return; settled = true; setMouseScrollTracking(false); - overlayYieldedToHostCustomUi = false; observedUi?.setStatus?.(WORKFLOW_STATUS_KEY, undefined); + observedUi?.setStatus?.(MAIN_CHAT_INPUT_STATUS_KEY, undefined); currentView?.dispose(); currentView = null; currentHandle = null; finishMounted = null; - currentRequestRender = null; mounted = false; clearHostCustomUiObservation(); done(undefined); @@ -386,7 +357,6 @@ export function buildGraphOverlayAdapter( // so the gate receives input even if focus drifted off the overlay // while the agent's turn was streaming (#1120). requestFocus: () => { - if (readHostCustomUiActive()) return; if (currentHandle?.isHidden() === true) return; // Idempotent: only grab focus if the overlay does not already own it. // A redundant focus() while already focused re-runs pi-tui's focus @@ -408,16 +378,17 @@ export function buildGraphOverlayAdapter( finishMounted = finish; mounted = true; setMouseScrollTracking(view.wantsMouseScrollTracking()); - if (readHostCustomUiActive(ui)) yieldToHostCustomUi(); + updateMainChatInputHint(readHostCustomUiActive(ui)); return makeComponent(view, tui); }; const options: PiCustomOverlayOptions = { overlay: true, + deferInlineCustomUiFocus: true, overlayOptions: FULLSCREEN_OVERLAY_OPTIONS, onHandle: (handle) => { currentHandle = handle; - if (readHostCustomUiActive(ui)) yieldToHostCustomUi(); + updateMainChatInputHint(readHostCustomUiActive(ui)); }, }; void custom(factory, options); @@ -429,8 +400,6 @@ export function buildGraphOverlayAdapter( // no scroll-pollution). if (mounted && currentHandle) { const nowHidden = !currentHandle.isHidden(); - if (!nowHidden && readHostCustomUiActive()) return; - if (nowHidden) overlayYieldedToHostCustomUi = false; currentView?.setVisible(!nowHidden); setMouseScrollTracking( nowHidden ? false : currentView?.wantsMouseScrollTracking() ?? true, diff --git a/test/integration/overlay-entrypoints.test.ts b/test/integration/overlay-entrypoints.test.ts index 6249f4aae..70a30cd07 100644 --- a/test/integration/overlay-entrypoints.test.ts +++ b/test/integration/overlay-entrypoints.test.ts @@ -205,12 +205,14 @@ function buildInteractiveHostCustomUi(): { customMounts: PiCustomOverlayFactory[]; overlayHandles: Array>; overlayShows: () => number; + focusTargets: unknown[]; customPromises: Promise[]; } { initTheme("dark"); const customMounts: PiCustomOverlayFactory[] = []; const customPromises: Promise[] = []; const overlayHandles: Array> = []; + const focusTargets: unknown[] = []; let overlayShowCount = 0; const host: any = { editor: { @@ -223,7 +225,9 @@ function buildInteractiveHostCustomUi(): { }, keybindings: {}, ui: { - setFocus: () => undefined, + setFocus: (target: unknown) => { + focusTargets.push(target); + }, requestRender: () => undefined, showOverlay: () => { overlayShowCount++; @@ -234,6 +238,8 @@ function buildInteractiveHostCustomUi(): { hideOverlay: () => undefined, }, blockingInlineCustomUiDepth: 0, + deferredInlineCustomUiFocusDepth: 0, + pendingInlineCustomUiFocus: undefined, hostCustomUiStateListeners: new Set(), }; Object.setPrototypeOf(host, (InteractiveMode as any).prototype); @@ -257,6 +263,7 @@ function buildInteractiveHostCustomUi(): { customMounts, overlayHandles, overlayShows: () => overlayShowCount, + focusTargets, customPromises, }; } @@ -636,6 +643,47 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { await Promise.allSettled(customPromises); }); + test("hiding the graph focuses the pending main-chat inline custom UI (#1353)", async () => { + const { ui, focusTargets, customPromises } = buildInteractiveHostCustomUi(); + const store = createStore(); + const adapter = buildGraphOverlayAdapter({ ui }, store); + + adapter.open("run-1"); + await Promise.resolve(); + + let finishInline!: (value: string) => void; + const inlineComponent: PiCustomComponent = { + render: () => ["QUESTION"], + invalidate: () => undefined, + }; + const inlinePromise = ui.custom!( + (_tui, _theme, _keybindings, done: (value: string) => void) => { + finishInline = done; + return inlineComponent; + }, + { overlay: false } as PiCustomOverlayOptions, + ) as Promise; + await Promise.resolve(); + + assert.equal( + focusTargets.includes(inlineComponent), + false, + "inline main-chat UI must not steal focus while the graph is visible", + ); + + adapter.toggle("run-1"); + + assert.equal( + focusTargets.at(-1), + inlineComponent, + "exiting/hiding the graph should focus the pending main-chat UI", + ); + finishInline("answered"); + await assert.doesNotReject(inlinePromise); + adapter.close(); + await Promise.allSettled(customPromises); + }); + // Regression for issue #1120: retargeting a visible, mounted overlay must // restore keyboard focus. pi-tui only dispatches key events to the focused // component, so without this the retargeted overlay (e.g. brought to a @@ -661,7 +709,7 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { assert.equal(focusCalls, 1, "visible retarget must restore keyboard focus (#1120)"); }); - test("host inline custom UI hides and restores a visible graph overlay without remounting (#1353)", () => { + test("host inline custom UI stays pending behind a focused graph overlay (#1353)", () => { let renderCalls = 0; const { ui, calls } = buildMockUi({ onRequestRender: () => { @@ -699,36 +747,30 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { }; hostCustomUi.setActive(true); - assert.equal(hidden, true); - assert.equal(focused, false); - assert.deepEqual(setHiddenCalls, [true]); - assert.equal(unfocusCalls, 1); + assert.equal(hidden, false, "host question must not hide the graph"); + assert.equal(focused, true, "graph overlay keeps keyboard focus"); + assert.deepEqual(setHiddenCalls, []); + assert.equal(unfocusCalls, 0); assert.ok( statusMessages.some( (status) => - status.key === "pi-workflows" && - status.value === - "Workflow graph paused while you answer this question. Return to the graph after responding.", + status.key === "pi-workflows:main-chat-input" && + status.value === "Main chat needs input — exit graph to answer.", ), - "yielding to a host question should explain how to return to the graph", + "focused graph should hint that main chat has a pending question", ); - assert.equal(calls.length, 1, "host yield must not remount the overlay"); + assert.equal(calls.length, 1, "host question must not remount the overlay"); hostCustomUi.setActive(false); assert.equal(hidden, false); assert.equal(focused, true); - assert.deepEqual(setHiddenCalls, [true, false]); - assert.equal(focusCalls, 1); - assert.deepEqual( - statusMessages.slice(-2), - [ - { key: "pi-workflows", value: undefined }, - { key: "pi-workflows", value: "pi-workflows/workflow" }, - ], - "host restore should explicitly clear its paused status before the pane restores its own status", - ); - assert.equal(renderCalls, 1, "host restore must explicitly request a render"); - assert.equal(calls.length, 1, "host restore must not remount the overlay"); + assert.deepEqual(setHiddenCalls, []); + assert.equal(focusCalls, 0); + assert.deepEqual(statusMessages.slice(-1), [ + { key: "pi-workflows:main-chat-input", value: undefined }, + ]); + assert.equal(renderCalls, 0, "host question state should not force graph remount/render"); + assert.equal(calls.length, 1, "host question completion must not remount the overlay"); }); test("close unsubscribes from host custom UI state changes (#1353)", () => { @@ -796,7 +838,7 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { assert.equal(calls.length, 1); }); - test("store-update refocus does not call focus while host inline custom UI is active (#1353)", () => { + test("store-update refocus keeps the graph interactive while host inline custom UI is active (#1353)", () => { const { ui, calls } = buildMockUi(); let hostActive = false; ui.getHostCustomUiState = () => ({ @@ -832,10 +874,10 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { createdAt: Date.now(), }); - assert.equal(focusCalls, 0); + assert.equal(focusCalls, 1, "graph focus should win over a pending main-chat question"); }); - test("stage-chat focus hold does not call focus while host inline custom UI is active (#1353)", async () => { + test("stage-chat focus hold still focuses while host inline custom UI is active (#1353)", async () => { const { ui, calls } = buildMockUi(); let hostActive = false; ui.getHostCustomUiState = () => ({ @@ -862,7 +904,7 @@ describe("buildGraphOverlayAdapter — open with pi.ui.custom", () => { await delay(180); adapter.close(); - assert.equal(focusCalls, 0); + assert.ok(focusCalls >= 1, "workflow-local HIL must continue to focus inside the attached pane"); }); test("visible graph overlay refocuses when detached ctx.ui.editor and confirm prompts appear", async () => { From f2fa52e460c1ca8060db4a66f35567cd8bebaa37 Mon Sep 17 00:00:00 2001 From: Norin Lavaee Date: Sat, 13 Jun 2026 04:31:50 -0700 Subject: [PATCH 5/5] docs: finalize issue 1353 overlay focus spec --- packages/coding-agent/CHANGELOG.md | 4 + packages/workflows/CHANGELOG.md | 2 +- ...3-fix-issue-1353-workflow-overlay-focus.md | 72 +++ ...thub-com-bastani-inc-atomic-issues-1353.md | 611 ------------------ 4 files changed, 77 insertions(+), 612 deletions(-) create mode 100644 specs/2026-06-13-fix-issue-1353-workflow-overlay-focus.md delete mode 100644 specs/2026-06-13-fix-issue-https-github-com-bastani-inc-atomic-issues-1353.md diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b09d91b70..eb663b6e6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed extension custom UI focus deferral so full-screen overlays can keep keyboard focus while a parent/main-chat inline custom UI is pending, then focus that pending UI when the overlay is hidden; already-aborted custom UI calls no longer invoke factories or emit host custom-UI state changes ([#1353](https://github.com/bastani-inc/atomic/issues/1353)). + ## [0.8.28] - 2026-06-11 ### Added diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 52badb01d..c9381987b 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed -- Fixed workflow graph overlay host-question handoff cleanup and repaint behavior: closing the overlay now unsubscribes host custom-UI listeners, restoring after a host question explicitly clears its paused status and requests a render, all overlay teardown paths reset yielded-host-question state, and the status copy explains that the graph is paused while the user answers the blocking question. +- Fixed the workflow graph overlay remaining interactive when the parent/main-chat agent opens `ask_user_question`: the graph keeps focus, the parent question stays pending behind it with a clear “Main chat needs input — exit graph to answer.” status hint, hiding/exiting the graph focuses the pending question, and host custom-UI state changes no longer hide, restore, remount, or repaint the overlay ([#1353](https://github.com/bastani-inc/atomic/issues/1353)). ## [0.8.28] - 2026-06-11 diff --git a/specs/2026-06-13-fix-issue-1353-workflow-overlay-focus.md b/specs/2026-06-13-fix-issue-1353-workflow-overlay-focus.md new file mode 100644 index 000000000..795388f7a --- /dev/null +++ b/specs/2026-06-13-fix-issue-1353-workflow-overlay-focus.md @@ -0,0 +1,72 @@ +# Workflow graph overlay focus when main chat asks a question + +| Document Metadata | Details | +| ---------------------- | ------- | +| Status | Final | +| Issue | [bastani-inc/atomic#1353](https://github.com/bastani-inc/atomic/issues/1353) | +| Created / Updated | 2026-06-13 / 2026-06-13 | + +## Summary + +Issue #1353 exposed a focus conflict between the full-screen workflow graph overlay and parent/main-chat `ask_user_question` prompts. The selected UX is **graph-overlay-first**: while the graph overlay is visible, keyboard focus stays on the graph so navigation, stage attachment, Ctrl+D hide, and graph recovery shortcuts remain usable. A parent/main-chat question is allowed to mount, but its focus is deferred and the graph shows a status hint telling the user to exit/hide the graph to answer it. + +This is better than hiding or yielding the graph automatically because it avoids making the overlay appear frozen, preserves the user's current graph context, and keeps the user in control of when to leave graph mode. + +## Goals + +- Keep a visible workflow graph overlay interactive when the parent/main-chat agent opens `ask_user_question`. +- Mount the parent question without stealing focus from the graph overlay. +- Surface a clear hint (`Main chat needs input — exit graph to answer.`) while the question is pending behind the graph. +- When the user hides/exits the graph, focus the pending main-chat question so it can be answered immediately. +- Avoid remounting the graph overlay or committing duplicate overlay frames into chat scrollback. +- Preserve stage-local workflow HIL behavior: in-stage `ask_user_question`, readiness gates, and `ctx.ui.*` prompts still focus inside the attached workflow pane. +- Preserve custom UI lifecycle correctness: synchronous factory invocation, cleanup on resolve/reject/abort/throw, and no host-state notifications for already-aborted custom UI calls. + +## Non-goals + +- Redesigning `ask_user_question` schemas, answer envelopes, or response semantics. +- Converting every `ask_user_question` prompt into an overlay. +- Adding nested workflow graph overlays or general-purpose third-party overlay stacking. +- Changing workflow execution, stage scheduling, HIL persistence, or `/workflow send` coercion. + +## Selected approach + +### 1. Host owns inline custom UI state + +`InteractiveMode.showExtensionCustom()` tracks host-owned inline custom UI depth and exposes that state through the extension UI context: + +- `getHostCustomUiState()` +- `onHostCustomUiStateChange(listener)` +- `focusHostInlineCustomUi()` + +Inline custom UI requests acquire host state only after the pre-abort check. If the abort signal is already aborted, the factory is not called and observers are not notified. + +### 2. Overlay defers host inline focus + +The workflow graph overlay opens with `deferInlineCustomUiFocus: true`. While that deferral is active, main-chat inline custom UI can mount, but `InteractiveMode` stores it as pending focus instead of calling `setFocus(component)`. + +When the overlay is hidden via Ctrl+D/toggle/setHidden, the deferral is released and `focusHostInlineCustomUi()` focuses the pending main-chat question. + +### 3. Graph remains interactive while the question is pending + +`WorkflowGraphOverlayAdapter` observes host custom UI state only to display/clear the main-chat input hint. It does **not** auto-hide, unfocus, remount, or suppress graph focus while a host inline custom UI is active. + +Store-update and stage-chat focus paths continue to focus the visible graph when workflow-local prompts require it, so workflow HIL remains usable even if a parent question is pending behind the overlay. + +## Validation + +Automated coverage should assert: + +- Same-turn graph `open()` calls through the interactive host custom path do not remount. +- Pre-aborted host custom UI does not invoke the factory, emit host-state notifications, hide the graph, unfocus it, or refocus it. +- A parent/main-chat inline custom UI does not steal focus while the graph is visible. +- Hiding the graph focuses the pending main-chat inline custom UI. +- Host custom UI state changes do not hide, restore, remount, or repaint the graph overlay. +- User-hidden graph overlays are not restored by host custom UI state changes. +- Graph/store-update and stage-chat focus paths still keep workflow-local HIL interactive. + +Targeted validation command: + +```sh +bun test test/integration/overlay-entrypoints.test.ts packages/coding-agent/test/interactive-mode-status.test.ts +``` diff --git a/specs/2026-06-13-fix-issue-https-github-com-bastani-inc-atomic-issues-1353.md b/specs/2026-06-13-fix-issue-https-github-com-bastani-inc-atomic-issues-1353.md deleted file mode 100644 index a1cbab2f8..000000000 --- a/specs/2026-06-13-fix-issue-https-github-com-bastani-inc-atomic-issues-1353.md +++ /dev/null @@ -1,611 +0,0 @@ -# Atomic Workflow Overlay Focus Arbitration Technical Design Document / RFC - -| Document Metadata | Details | -| ---------------------- | ------------------------------------------- | -| Author(s) | Norin Lavaee | -| Status | Draft (WIP) | -| Team / Owner | Atomic CLI / Workflows UI | -| Created / Last Updated | 2026-06-12 / 2026-06-12 (Iteration 5 of 10) | - -## 1. Executive Summary - -GitHub issue [bastani-inc/atomic#1353](https://github.com/bastani-inc/atomic/issues/1353) reports that the full-screen workflow graph overlay can freeze when the parent main-chat agent opens `ask_user_question`. The root cause is focus contention: the workflow overlay reclaims focus through `WorkflowGraphOverlayAdapter.requestFocus()` and `StageChatView`’s focus-hold timer while the parent question mounts as an inline `ctx.ui.custom()` replacement in `InteractiveMode.showExtensionCustom()`. - -This RFC proposes a backward-compatible focus arbitration seam between the Atomic interactive host and the workflows overlay. When a host-owned inline custom UI is active, the workflow overlay yields with `setHidden(true)` + `unfocus()` and later restores with `setHidden(false)` + `focus()`. - -Iteration 5 incorporates review round 4: pre-aborted `ctx.ui.custom(..., { signal })` calls must not acquire the host inline focus token. The selected design checks `signal.aborted` before `beginHostInlineCustomUi()`, then invokes the factory synchronously inside `try/catch` and wraps only the returned value in `Promise.resolve(...)`. - -## 2. Context and Motivation - -### 2.1 Current State - -The current working tree contains most of the focus arbitration architecture: - -- **Host custom UI state:** `packages/coding-agent/src/core/extensions/types.ts:153-186` defines optional `HostCustomUiState`, `getHostCustomUiState()`, and `onHostCustomUiStateChange()` APIs. -- **Interactive host state:** `packages/coding-agent/src/modes/interactive/interactive-mode.ts:2540-2552` tracks `blockingInlineCustomUiDepth` and releases state idempotently. -- **Parent custom UI mount path:** `InteractiveMode.showExtensionCustom()` mounts inline custom UI in `packages/coding-agent/src/modes/interactive/interactive-mode.ts:2966-3068`. -- **Current remaining regression:** `showExtensionCustom()` currently initializes `releaseHostInlineCustomUi` with `beginHostInlineCustomUi()` at `packages/coding-agent/src/modes/interactive/interactive-mode.ts:2970-2972`, before checking `options.signal.aborted` at `packages/coding-agent/src/modes/interactive/interactive-mode.ts:3020-3023`. -- **Workflow overlay observer path:** `packages/workflows/src/tui/overlay-adapter.ts:183-188` observes host custom UI active/inactive notifications and calls yield/restore. -- **False churn risk:** A pre-aborted parent custom UI currently emits an active/inactive pair for a UI that will never mount, causing `yieldToHostCustomUi()` at `packages/workflows/src/tui/overlay-adapter.ts:155-163` and `restoreAfterHostCustomUi()` at `packages/workflows/src/tui/overlay-adapter.ts:166-174`. -- **Workflow overlay mount path:** `packages/workflows/src/tui/overlay-adapter.ts:122` builds `WorkflowGraphOverlayAdapter`, which relies on synchronous overlay factory side effects for same-turn no-remount. -- **Stage-local prompt path:** Workflow-stage `ctx.ui.custom()` remains brokered into the attached stage chat through `StageUiBroker` and `StageChatView._showCustomUi()`. -- **Repository hygiene:** Current `git status --short` no longer shows the root generated `*-report.md` artifacts flagged in review round 1. - -**Focus doors:** - -- `showExtensionCustom()` is the host-owned inline custom UI door. -- `beginHostInlineCustomUi()` / its release closure are the host focus ownership token door. -- `WorkflowGraphOverlayAdapter` is the workflow overlay yield/restore door. -- `StageUiBroker` and `StageChatView` remain the stage-local HIL door and must not be treated as parent host-blocking UI. - -### 2.2 The Problem - -- **User Impact:** With the workflow graph overlay open, a parent-session `ask_user_question` can make the TUI appear frozen. Keyboard input no longer reaches the graph, and the structured question cannot be reached or answered. -- **Product Impact:** Workflows become unreliable during clarification flows, especially when a long-running workflow is open while the parent agent asks a planning or scope question. -- **Technical Debt:** Focus ownership must be arbitrated at a single host-owned boundary instead of scattered across `InteractiveMode.showExtensionCustom()`, `WorkflowGraphOverlayAdapter`, `WorkflowAttachPane`, and `StageChatView`. -- **Review Round 4 Regression:** Pre-aborted custom UI calls acquire and release host focus state despite never mounting a component, causing false overlay hide/restore churn. - -### 2.3 Review Findings Addressed - -| Review Round | Finding | Required Resolution | -| ------------ | ------- | ------------------- | -| Round 1 | `[P2] Remove generated review artifacts from the tree` | Resolved; keep root generated reports absent before landing. | -| Round 1 | `[P2] Release host custom UI state on sync factory throws` | Keep cleanup guarantee. | -| Round 2 | Reviewer A findings | No findings. | -| Round 2 | Reviewer B findings | Reviewer infrastructure failure only; superseded by valid later reviews. | -| Round 3 | `[P2] Preserve synchronous custom UI factory invocation` | Use immediate `try/catch` plus `Promise.resolve(factoryResult)`. | -| Round 3 | `[P2] Keep custom UI factories synchronous` | Do not defer factory invocation into a microtask. | -| Round 4 | `[P2] Don’t acquire host focus for pre-aborted custom UI` | Check `options.signal.aborted` before `beginHostInlineCustomUi()` and assert no host state notifications for pre-aborted calls. | - -### Compatibility Posture - -Breaking changes are disallowed. `@bastani/atomic` is a published package with documented extension UI APIs, and workflow users depend on current `/workflow`, `ctx.ui.custom()`, `ask_user_question`, and stage-local HIL behavior. - -## 3. Goals and Non-Goals - -### 3.1 Functional Goals - -- [ ] When the workflow overlay is visible and the parent main-chat agent opens `ask_user_question`, the parent question must be visible and answerable. -- [ ] The workflow overlay must not become input-dead, permanently hidden, or remounted as scrollback noise. -- [ ] The overlay must yield non-destructively with `OverlayHandle.setHidden(true)` / `unfocus()` and restore with `setHidden(false)` / `focus()` only when appropriate. -- [ ] Overlay focus reassertion paths must no-op while a host-owned blocking inline custom UI is active. -- [ ] Host inline custom UI state must always be released on resolve, reject, abort, async factory rejection, and synchronous factory throw. -- [ ] Pre-aborted custom UI calls must not acquire host inline custom UI state or emit active/inactive host state notifications. -- [ ] Custom UI factories must be invoked synchronously in the same call stack as `ctx.ui.custom()` unless the signal is already aborted. -- [ ] `WorkflowGraphOverlayAdapter.open()` called twice in the same turn must not remount duplicate graph overlays. -- [ ] Immediate abort after `ctx.ui.custom()` returns must not allow a previously uninvoked factory to run later. -- [ ] Stage-local workflow prompts must continue to mount inside the attached workflow stage chat and retain existing #1120 focus fixes. -- [ ] Existing `/workflow connect`, F2 overlay open, graph navigation, Ctrl+D hide/detach, and `workflow send` behavior must remain unchanged. -- [ ] Generated root report artifacts must remain out of the repository tree before landing. -- [ ] Validation must use Bun commands only. - -### 3.2 Non-Goals (Out of Scope) - -- [ ] Do not redesign `ask_user_question` question schemas, answer envelopes, or tool semantics. -- [ ] Do not convert every `ask_user_question` dialog into an overlay. -- [ ] Do not add nested workflow graph overlays; workflow `ctx.ui.custom({ overlay: true })` remains unsupported in the graph viewer. -- [ ] Do not change workflow execution, stage scheduling, HIL persistence, or `/workflow send` answer coercion. -- [ ] Do not introduce a public breaking change to `ExtensionUIContext.custom()`, `OverlayHandle`, or workflow APIs. -- [ ] Do not solve general multi-overlay stacking for arbitrary third-party extensions beyond this host-inline-custom-UI conflict. -- [ ] Do not keep internal orchestration reports in the repository root. -- [ ] Do not publish, release, or submit a PR in this stage. - -## 4. Proposed Solution (High-Level Design) - -### 4.1 System Architecture Diagram - -```mermaid -%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#f8f9fa','primaryTextColor':'#2c3e50','primaryBorderColor':'#4a5568','lineColor':'#4a90e2','secondaryColor':'#ffffff','tertiaryColor':'#e9ecef','clusterBkg':'#ffffff','clusterBorder':'#cbd5e0'}}}%% -flowchart TB - classDef person fill:#5a67d8,stroke:#4c51bf,stroke-width:3px,color:#fff,font-weight:600 - classDef host fill:#4a90e2,stroke:#357abd,stroke-width:2.5px,color:#fff,font-weight:600 - classDef workflow fill:#667eea,stroke:#5a67d8,stroke-width:2.5px,color:#fff,font-weight:600 - classDef state fill:#48bb78,stroke:#38a169,stroke-width:2.5px,color:#fff,font-weight:600 - classDef external fill:#718096,stroke:#4a5568,stroke-width:2.5px,color:#fff,font-weight:600,stroke-dasharray:6 3 - - User(("◉
User
keyboard input")):::person - - subgraph AtomicHost["◆ Atomic interactive host — focus airlock"] - direction TB - TUI["pi-tui TUI
single focused component"]:::host - FocusArbiter{{"Host custom UI focus state
the one focus ownership airlock"}}:::host - InteractiveMode["InteractiveMode
showExtensionCustom()"]:::host - PreAbortGate["Pre-abort gate
no token · no factory · no notification"]:::host - FactoryGuard["Synchronous guarded factory
try/catch · no microtask deferral"]:::host - AskTool["ask_user_question
parent ctx.ui.custom inline"]:::host - end - - subgraph Workflows["◆ @bastani/workflows extension"] - direction TB - OverlayAdapter["WorkflowGraphOverlayAdapter
open · toggle · requestFocus"]:::workflow - OverlayHandle["PiOverlayHandle
setHidden · focus · unfocus"]:::state - AttachPane["WorkflowAttachPane
graph ↔ stage chat"]:::workflow - StageChat["StageChatView
stage-local custom UI focus hold"]:::workflow - Broker["StageUiBroker
in-stage ask_user_question"]:::workflow - end - - PiTui{{"@earendil-works/pi-tui
overlay rendering/focus order"}}:::external - - User -->|"raw terminal input"| TUI - TUI --> FocusArbiter - InteractiveMode -->|"mount_main_chat_custom_ui"| PreAbortGate - PreAbortGate -->|"signal already aborted"| FocusArbiter - PreAbortGate -->|"signal live"| FactoryGuard - FactoryGuard -->|"sync component/promise result"| AskTool - FactoryGuard -->|"sync throw"| FocusArbiter - AskTool -->|"begin host inline custom UI"| FocusArbiter - FocusArbiter -->|"active=true"| OverlayAdapter - OverlayAdapter -->|"yield_workflow_overlay_to_host_custom_ui"| OverlayHandle - OverlayHandle -->|"setHidden(true), unfocus()"| PiTui - AskTool -->|"question visible + focused"| TUI - AskTool -->|"resolve/abort/reject"| FocusArbiter - FocusArbiter -->|"active=false"| OverlayAdapter - OverlayAdapter -->|"restore_workflow_overlay_after_host_custom_ui"| OverlayHandle - OverlayHandle -->|"setHidden(false), focus()"| PiTui - - OverlayAdapter --> AttachPane - AttachPane --> StageChat - StageChat -->|"stage-local prompt only"| Broker - StageChat -->|"request_stage_custom_ui_focus
(not host-blocking)"| OverlayAdapter - - style AtomicHost fill:#fff,stroke:#cbd5e0,stroke-width:2px,stroke-dasharray:8 4 - style Workflows fill:#fff,stroke:#cbd5e0,stroke-width:2px,stroke-dasharray:8 4 -``` - -### 4.2 Architectural Pattern - -Use a **single focus-owner arbitration pattern** plus **synchronous factory preservation**: - -- The Atomic host owns the global truth of “a blocking inline custom UI is active.” -- Pre-aborted custom UI requests exit before acquiring host focus ownership. -- Workflow overlays observe host state and yield/restore themselves through their existing `OverlayHandle`. -- Stage-local workflow custom UI remains local to `StageUiBroker` and `StageChatView`. -- Parent inline custom UI factories run synchronously, with synchronous throws caught explicitly. - -### 4.3 Key Components - -| Component | Responsibility | Technology Stack | Justification | -| --------- | -------------- | ---------------- | ------------- | -| `InteractiveMode.showExtensionCustom()` | Mounts extension custom UI and preserves lifecycle ordering | TypeScript, `@earendil-works/pi-tui` | Must remain the host-side focus and factory lifecycle chokepoint. | -| `beginHostInlineCustomUi()` | Creates the host focus ownership token | TypeScript closure | Must only run for live non-overlay custom UI requests. | -| `ExtensionUIContext` | Optional host custom UI state observer API | TypeScript interfaces | Additive/backward-compatible seam for first-party overlays. | -| `WorkflowGraphOverlayAdapter` | Owns workflow overlay handle and focus restoration | TypeScript, workflows TUI | Relies on accurate host state and synchronous overlay factory side effects. | -| `WorkflowAttachPane` | Swaps graph/stage-chat interiors and records visibility | TypeScript component | Existing `setVisible()` integrates with yielding. | -| `StageChatView` | Keeps stage-local prompts focusable in the overlay | TypeScript component | Must preserve existing #1120 mid-turn stage prompt behavior. | -| `ask_user_question` | Parent/session structured question tool | TypeScript tool | Reuses host `ctx.ui.custom()`; no tool schema or answer format changes. | -| Repository hygiene gate | Keeps generated orchestration reports out of commits | Git status / artifact policy | Prevents internal review artifacts from being committed. | - -### 4.4 The Door Set at a Glance (Stranger-Across-Time View) - -`open_workflow_overlay`, `hide_workflow_overlay`, `close_workflow_overlay`, `request_workflow_overlay_focus`, `mount_main_chat_custom_ui`, `refuse_pre_aborted_custom_ui`, `construct_custom_ui_component_synchronously`, `begin_host_inline_custom_ui`, `release_host_inline_custom_ui`, `yield_workflow_overlay_to_host_custom_ui`, `resolve_main_chat_question`, `restore_workflow_overlay_after_host_custom_ui`, `mount_stage_custom_ui`, `answer_stage_custom_ui` - -No door guards an irreversible runtime effect; this change controls UI focus and visibility only. - -## 5. Detailed Design - -### 5.1 The Doors (Entrypoint Contracts) - -```ts -mount_main_chat_custom_ui( - factory: CustomUiFactory, - options?: CustomUiOptions, -): Promise -// Guarantee: mounts a host-owned custom UI and releases host focus ownership on every exit. -// Failures: Aborted | FactoryThrewSynchronously | FactoryRejected | ComponentDisposed | UiUnavailable -// Refusals: overlay custom UI does not become a blocking inline custom UI. -``` - -```ts -refuse_pre_aborted_custom_ui( - signal?: AbortSignal, -): Result -// Guarantee: rejects an already-aborted custom UI request before host focus state changes. -// Failures: Aborted -// Refusals: cannot invoke factory, acquire host token, or notify host state listeners. -``` - -```ts -construct_custom_ui_component_synchronously( - factory: CustomUiFactory, - done: (result: T) => void, -): CustomUiComponent | Promise -// Guarantee: invokes the factory before ctx.ui.custom() returns. -// Failures: FactoryThrewSynchronously -// Refusals: factory invocation cannot be deferred into a microtask. -``` - -```ts -begin_host_inline_custom_ui(): ReleaseHostInlineCustomUi -// Guarantee: marks a live parent inline custom UI as the current focus owner. -// Failures: none; nested calls increment depth. -// Refusals: cannot be called for pre-aborted requests. -``` - -```ts -release_host_inline_custom_ui(release: ReleaseHostInlineCustomUi): void -// Guarantee: releases exactly one host inline custom UI ownership claim. -// Failures: none; duplicate release is a no-op. -// Refusals: depth cannot become negative. -``` - -```ts -on_host_custom_ui_state_change( - listener: (state: HostCustomUiState) => void, -): Unsubscribe -// Guarantee: notifies observers when host inline custom UI active/inactive state changes. -// Failures: ListenerThrows is swallowed without breaking UI cleanup. -// Refusals: observer receives only state, not prompt data. -``` - -```ts -yield_workflow_overlay_to_host_custom_ui(): void -// Guarantee: hides a visible workflow overlay without resolving or remounting it. -// Failures: OverlayNotMounted | OverlayAlreadyHidden | HandleUnavailable -// Refusals: does not cancel workflow runs, brokered prompts, or stage chat state. -``` - -```ts -restore_workflow_overlay_after_host_custom_ui(): void -// Guarantee: restores only the workflow overlay that this host custom UI yield hid. -// Failures: OverlayClosed | HostCustomUiStillActive | UserHiddenOverlay -// Refusals: does not reopen overlays the user explicitly hid or closed. -``` - -```ts -request_workflow_overlay_focus(): void -// Guarantee: focuses the visible workflow overlay only when host focus is not blocked. -// Failures: OverlayNotMounted | OverlayHidden | HostInlineCustomUiActive -// Refusals: cannot steal focus from a parent inline custom UI. -``` - -```ts -mount_stage_custom_ui(request: StageCustomUiRequest): Promise -// Guarantee: mounts a stage-owned custom UI inside the attached workflow stage chat. -// Failures: MissingTuiHost | OverlayModeUnsupported | BrokerRejected | Aborted -// Refusals: stage-local custom UI cannot create a nested overlay. -``` - -**Per-door audit:** - -| Door | (1) Joint | (2) One sentence, no "and" | (3) Honest name | (5) Every exit | (6) Refusals real | (7) Trust transition | (8) One chokepoint | -| ---- | --------- | -------------------------- | --------------- | -------------- | ----------------- | -------------------- | ------------------ | -| `mount_main_chat_custom_ui` | ✅ host modal mount | ✅ mounts a host-owned custom UI | ✅ | abort/reject/resolve/factory throw | ✅ overlay mode excluded from blocking state | ✅ host focus airlock | ✅ parent inline custom UI door | -| `refuse_pre_aborted_custom_ui` | ✅ abort gate | ✅ rejects before host state changes | ✅ | aborted / proceed | ✅ no token, factory, notification | ✅ host focus airlock | ✅ pre-abort chokepoint | -| `construct_custom_ui_component_synchronously` | ✅ factory construction | ✅ invokes factory before return | ✅ | sync throw / returned value | ✅ no microtask deferral | n/a | ✅ factory timing door | -| `begin_host_inline_custom_ui` | ✅ focus ownership claim | ✅ marks live inline UI as focus owner | ✅ | release / nested depth | ✅ pre-aborted requests excluded | ✅ focus ownership airlock | ✅ active-state source | -| `release_host_inline_custom_ui` | ✅ focus ownership release | ✅ releases one active claim | ✅ | duplicate release no-op | ✅ depth clamped at zero | ✅ focus ownership airlock | ✅ cleanup chokepoint | -| `yield_workflow_overlay_to_host_custom_ui` | ✅ focus handoff | ✅ hides workflow overlay without resolving it | ✅ | mounted/hidden/no-handle | ✅ cannot cancel workflow | n/a | ✅ overlay yield chokepoint | -| `restore_workflow_overlay_after_host_custom_ui` | ✅ focus restoration | ✅ restores only auto-yielded overlay | ✅ | closed/still-active/user-hidden | ✅ no reopen of user-hidden overlay | n/a | ✅ overlay restore chokepoint | -| `request_workflow_overlay_focus` | ✅ focus request | ✅ focuses overlay only when allowed | ✅ | hidden/focused/host-blocked | ✅ host block prevents focus steal | n/a | ✅ all overlay re-focus paths use it | -| `mount_stage_custom_ui` | ✅ stage-local HIL mount | ✅ mounts stage UI inside attached chat | ✅ | missing host/unsupported overlay/abort | ✅ nested overlay rejected | n/a | ✅ stage broker path | - -### 5.2 API Interfaces — The Same Doors on the Wire - -This feature has no HTTP/gRPC wire surface. The “wire” is the TUI and extension API surface. - -```ts -// Parent main-chat structured question. -ask_user_question(params) - -> ctx.ui.custom(factory, { signal }) - -> refuse_pre_aborted_custom_ui - -> mount_main_chat_custom_ui -``` - -```ts -// Workflow overlay entrypoints. -F2 -/workflow connect -workflow({ action: "connect" }) - -> GraphOverlayPort.open(runId, ctx) - -> open_workflow_overlay -``` - -```ts -type HostCustomUiState = { - blockingInlineCustomUiDepth: number; - blockingInlineCustomUiActive: boolean; -}; - -type HostCustomUiStateListener = (state: HostCustomUiState) => void; - -interface ExtensionUIContext { - getHostCustomUiState?(): HostCustomUiState; - onHostCustomUiStateChange?( - listener: HostCustomUiStateListener, - ): () => void; -} -``` - -Required `showExtensionCustom()` lifecycle ordering: - -```ts -let releaseHostInlineCustomUi: (() => void) | undefined; - -const releaseHostCustomUi = () => { - releaseHostInlineCustomUi?.(); -}; - -if (options?.signal?.aborted) { - rejectAndClose(options.signal.reason ?? new Error("Extension custom UI aborted")); - return; -} - -options?.signal?.addEventListener("abort", abortCustomUi, { once: true }); - -if (!isOverlay) { - releaseHostInlineCustomUi = this.beginHostInlineCustomUi(); -} - -let factoryResult: - | (Component & { dispose?(): void }) - | Promise; - -try { - factoryResult = factory(this.ui, theme, this.keybindings, close); -} catch (error) { - rejectAndClose(error); - return; -} - -Promise.resolve(factoryResult) - .then((component) => { - if (closed) { - component.dispose?.(); - return; - } - - if (!isOverlay) { - editorContainer.clear(); - editorContainer.addChild(component); - ui.setFocus(component); - mounted = true; - ui.requestRender(); - return; - } - - const handle = ui.showOverlay(component, resolveOptions()); - mounted = true; - options?.onHandle?.(handle); - }) - .catch((error) => { - rejectAndClose(error); - }); -``` - -Explicitly forbidden patterns: - -```ts -// Do not use this: it defers factory side effects past ctx.ui.custom() return. -Promise.resolve().then(() => factory(this.ui, theme, this.keybindings, close)); - -// Do not do this: it emits false host active/inactive for pre-aborted UI. -const release = this.beginHostInlineCustomUi(); -if (options?.signal?.aborted) abortCustomUi(); -``` - -Existing hosts that do not implement the optional observer methods continue to compile and run. Workflows must treat absence as `blockingInlineCustomUiActive === false`. - -### 5.3 Data Model / Schema - -No persistent database schema is required. The change adds ephemeral in-memory UI state. - -| State | Owner | Type | Constraints | Description | -| ----- | ----- | ---- | ----------- | ----------- | -| `blockingInlineCustomUiDepth` | `InteractiveMode` | `number` | integer, `>= 0` | Count of active non-overlay host custom UI mounts. | -| `hostCustomUiStateListeners` | `InteractiveMode` | `Set` | listeners removed on unsubscribe | Broadcasts active/inactive changes to extension overlays. | -| `releaseHostInlineCustomUi` | `showExtensionCustom()` | `(() => void) \| undefined` | assigned only after pre-abort check; idempotent release | Releases the active host custom UI claim. | -| `closed` | `showExtensionCustom()` | `boolean` | monotonic false → true | Ensures resolve/reject/abort/sync-throw cleanup runs once. | -| `mounted` | `showExtensionCustom()` | `boolean` | true only after UI is mounted | Avoids restoring editor for a factory that never mounted. | -| `factoryResult` | `showExtensionCustom()` | `Component \| Promise` | assigned synchronously or routes throw to cleanup | Preserves factory side effects before `ctx.ui.custom()` returns. | -| `overlayYieldedToHostCustomUi` | `WorkflowGraphOverlayAdapter` | `boolean` | true only when this adapter hid the overlay | Prevents restoring overlays hidden by the user. | -| `currentHandle` | `WorkflowGraphOverlayAdapter` | `PiOverlayHandle \| null` | null after close | Existing overlay control handle for `setHidden`, `focus`, `unfocus`, `hide`. | -| `currentView.visible` | `WorkflowAttachPane` | `boolean` | synced through `setVisible()` | Keeps stage attached state/status tags consistent with overlay visibility. | - -### 5.4 Algorithms and State Management - -**Host inline custom UI lifecycle** - -1. `showExtensionCustom()` determines `isOverlay = options?.overlay ?? false`. -2. If the abort signal is already aborted, reject through the normal cleanup path before: - - acquiring `beginHostInlineCustomUi()`; - - registering host active state; - - notifying host custom UI listeners; - - invoking the factory. -3. Register the abort listener only after the pre-abort check. -4. For non-overlay custom UI, use `beginHostInlineCustomUi()` to mark the host as owning focus. -5. Invoke the custom UI factory synchronously inside `try/catch`. -6. If the factory throws synchronously, call `rejectAndClose(error)` and return. -7. Wrap the returned component or promise with `Promise.resolve(factoryResult)`. -8. Mount the component and call `ui.setFocus(component)` as today. -9. On resolve, reject, abort, async factory rejection, or synchronous factory throw: - - remove abort listener; - - restore the editor only if `mounted === true`; - - dispose the component if present; - - release host inline custom UI state if acquired; - - resolve/reject the promise exactly once. - -**Workflow overlay yield** - -1. On observer `active=true`, `WorkflowGraphOverlayAdapter` checks: - - mounted; - - `currentHandle !== null`; - - not already hidden; - - not already yielded. -2. If visible, call: - - `currentView?.setVisible(false)`; - - `setMouseScrollTracking(false)`; - - `currentHandle.setHidden(true)`; - - `currentHandle.unfocus()`; - - set `overlayYieldedToHostCustomUi = true`. - -**Workflow overlay restore** - -1. On observer `active=false`, only restore if `overlayYieldedToHostCustomUi === true`. -2. If still mounted and handle exists: - - `currentView?.setVisible(true)`; - - `setMouseScrollTracking(currentView?.wantsMouseScrollTracking() ?? true)`; - - `currentHandle.setHidden(false)`; - - `currentHandle.focus()`; - - request render. -3. Clear the yielded flag. -4. If the overlay was closed or explicitly hidden by user action, skip restore. - -**Focus request guard** - -Every workflow auto-focus path must consult the host block predicate before calling `focus()`: - -- `refocusVisibleOverlayForAwaitingInput()` in `packages/workflows/src/tui/overlay-adapter.ts`. -- `requestFocus` in `packages/workflows/src/tui/overlay-adapter.ts`. -- mounted-hidden reopen/toggle paths where a host inline custom UI is active. - -**Repository hygiene** - -1. Root generated reports must remain absent: - - `analysis-report.md` - - `bun-preflight-report.md` - - `implementation-report.md` - - `locator-report.md` - - `preflight-report.md` - - `validation-report.md` -2. Future generated reports should be written to `/tmp`, `.atomic/workflows/runs/`, or another ignored artifact directory. -3. `git status --short` must not show untracked root orchestration reports. - -**State machine** - -```mermaid -stateDiagram-v2 - [*] --> RequestReceived - RequestReceived --> RefusedPreAborted: signal already aborted - RequestReceived --> HostInlineActive: live non-overlay custom UI - HostInlineActive --> OverlayYieldedToHostCustomUi: observer active=true - OverlayYieldedToHostCustomUi --> HostInlineSettled: resolve/reject/abort - HostInlineSettled --> OverlayVisibleFocused: release token && observer active=false - RefusedPreAborted --> [*]: no token, no factory, no overlay churn -``` - -## 6. Alternatives Considered - -| Option | Pros | Cons | Reason for Rejection | -| ------ | ---- | ---- | -------------------- | -| **A: Guard overlay `requestFocus()` only** | Smallest code change | Full-screen overlay can still cover the parent inline question | Rejected because it fixes focus stealing but not visibility. | -| **B: Render parent `ask_user_question` above the workflow overlay** | Keeps workflow overlay visible | Changes global `ask_user_question` behavior and requires z-order guarantees | Rejected as broader and riskier. | -| **C: Disable or queue parent `ask_user_question` while workflow overlay is open** | Avoids simultaneous surfaces | Blocks legitimate parent clarification flows | Rejected because the question should remain answerable. | -| **D: Host observer + non-destructive overlay yield/restore (Selected)** | Uses existing `setHidden`/`unfocus`, preserves overlay state | Adds an optional focus-state observer seam | Selected because it solves focus and visibility with bounded scope. | -| **E: Defer factory through `Promise.resolve().then(...)`** | Routes sync throws into promise rejection | Breaks same-turn overlay no-remount and creates abort-before-microtask side effects | Rejected by review round 3. | -| **F: Immediate factory `try/catch` + `Promise.resolve(factoryResult)` (Selected)** | Preserves synchronous factory contract and catches sync throws | Slightly more verbose implementation | Selected because it satisfies both round 1 and round 3 findings. | -| **G: Acquire host focus token before checking pre-abort** | Simple cleanup path | Emits false active/inactive state and causes overlay yield/restore churn for cancelled UI | Rejected by review round 4. | -| **H: Ignore generated report files until PR time** | No code work | High risk of accidentally committing internal artifacts | Rejected by review round 1. | - -## 7. Cross-Cutting Concerns - -### 7.1 Security and Privacy - -- The focus airlock exposes only boolean/depth state, never question text, option labels, answers, or component internals. -- `ask_user_question` result envelopes and workflow HIL answer handling remain unchanged. -- No new network calls, files, tokens, or persistence are introduced. -- Listener failures must not prevent cleanup. -- Existing non-interactive/headless policies remain intact. - -### 7.2 Reliability - -- Host inline custom UI token release must be idempotent. -- Pre-aborted requests must not acquire or release a token. -- Synchronous custom UI factory throws, async rejections, aborts, and normal resolutions must share one cleanup path. -- Custom UI factory invocation must stay synchronous to preserve overlay no-remount guards. -- User-hidden overlays must not be restored by host custom UI deactivation. -- Workflow overlay state must not be remounted or committed to scrollback during yield/restore. - -### 7.3 Accessibility and UX - -- Parent questions should be visually reachable immediately when opened. -- Cancelled pre-aborted questions must not produce visible overlay flicker or unexpected graph focus. -- Restored workflow overlays should return to the same graph/stage-chat state without scrollback duplication. -- Keyboard focus after question close should land on the workflow overlay if it was visible before the question; otherwise it should remain on the editor/default host focus. -- Stage-local workflow questions must continue to show inside the attached stage chat. - -### 7.4 Repository Hygiene - -- Generated reports from agent orchestration are not product artifacts and must not live at repo root when the implementation lands. -- If such reports are useful during development, they should be written under `/tmp` or an ignored run/artifact directory. -- Validation must include a `git status --short` check for unexpected untracked generated reports. - -## Backwards Compatibility - -Breaking changes are disallowed. - -Compatibility-sensitive surfaces that must be preserved: - -- `ExtensionUIContext.custom(factory, options?)` signature in `packages/coding-agent/src/core/extensions/types.ts`. -- The synchronous custom UI factory invocation timing of `ctx.ui.custom()`. -- Pre-aborted `ctx.ui.custom()` calls must reject without mounting, focusing, or emitting host custom UI state. -- `OverlayHandle` methods and semantics documented in `packages/coding-agent/docs/tui.md`. -- `ask_user_question` tool parameters, validation, abort behavior, loader visibility behavior, and response envelope. -- Workflow `ctx.ui.custom()` broker behavior, including rejection of nested `overlay: true`. -- `/workflow` commands, F2 shortcut behavior, graph overlay toggle/hide semantics, and `workflow send`. -- Existing tests around #1120, #1137, #1141, #1148, and #1261. - -The host custom UI observer remains optional and additive. Older/minimal UI contexts that only implement `custom` must continue to work. Workflows must feature-detect observer methods and default to current behavior if unavailable. - -## 8. Test Plan - -- **Unit Tests:** - - Verify synchronous non-overlay custom UI factory throws release host state. - - Verify asynchronous non-overlay custom UI factory rejection releases host state. - - Verify the custom UI factory runs synchronously before `showExtensionCustom()` / `ctx.ui.custom()` returns. - - Verify a pre-aborted signal does not invoke the factory. - - Add/strengthen the pre-aborted signal test to assert no host custom UI state listener events are emitted. - - Verify immediate abort after `ctx.ui.custom()` returns does not cause an uninvoked factory to run later. - - Keep existing `ask_user_question` abort signal and loader restore tests passing. - -- **Integration Tests:** - - Verify visible graph overlay yields/restores around host inline custom UI. - - Verify a user-hidden overlay is not restored by host inactive. - - Verify store-update refocus is suppressed while host inline UI is active. - - Verify stage-chat focus hold is suppressed while host inline UI is active. - - Add a pre-aborted host custom UI / visible overlay regression: assert no `setHidden(true)`, `setHidden(false)`, `unfocus()`, or `focus()` calls occur. - - Verify same-turn no-remount: call `adapter.open("run-1")` and `adapter.open("run-2")` in the same turn through the real/synchronous host custom path and assert only one `ctx.ui.custom` mount occurs. - - Existing tests around visible retarget focus (#1120), toggle `setHidden`, Ctrl+D hide, and full-screen overlay mount must continue to pass. - -- **Repository Hygiene Tests / Checks:** - - Confirm root generated reports are absent. - - Run `git status --short` and confirm no untracked root report files remain. - - Run `git diff --check origin/main`. - -- **Validation Commands:** - 1. `bun test test/integration/overlay-entrypoints.test.ts` - 2. `bun test test/unit/stage-chat-view.test.ts` - 3. `bun test packages/coding-agent/test/interactive-mode-status.test.ts` - 4. `bun test packages/coding-agent/test/ask-user-question-tool.test.ts` - 5. `bun run typecheck` - 6. `git diff --check origin/main` - 7. `git status --short` - -- **Manual Verification:** - 1. Start an interactive Atomic session. - 2. Start a background workflow and open the graph overlay with F2 or `/workflow connect `. - 3. Trigger a parent main-chat `ask_user_question`. - 4. Expected: workflow overlay yields or is hidden, the question is visible and answerable, and after answering the overlay returns focused and navigable. - 5. Repeat with a deliberately broken extension custom UI factory that throws synchronously. - 6. Expected: no stuck `blockingInlineCustomUiActive` state; workflow overlay focus behavior recovers. - 7. Trigger a pre-aborted parent question while the workflow overlay is visible. - 8. Expected: no overlay hide/show flicker and no unexpected focus change. - 9. Trigger two overlay opens in the same command turn. - 10. Expected: no duplicate overlay remount and no scrollback pollution. - -- **Fuzz / Property Tests:** - - Randomize host custom UI active/inactive events with overlay open/hide/close calls and assert: - - overlay handle calls never remount; - - hidden depth never becomes negative; - - `focus()` is never called while host active=true; - - user-hidden overlays are not restored by host inactive=false transitions; - - pre-aborted requests emit zero host state events; - - sync/async factory failures always leave host custom UI depth at zero; - - no factory side effect occurs after cancellation unless the factory already ran synchronously before cancellation. - -## 9. Open Questions / Unresolved Issues - -- [ ] Should `getHostCustomUiState()` / `onHostCustomUiStateChange()` be documented as a supported public extension API or described as an advanced host capability for first-party overlays? `[OWNER: Atomic CLI maintainers]` -- [ ] If multiple extension overlays are visible, should all capturing overlays yield to a parent inline custom UI, or only the workflow graph overlay? `[OWNER: TUI/platform team]` -- [ ] What should happen if the user presses F2 or `/workflow connect` while a parent `ask_user_question` is active? `[OWNER: workflows team]` -- [ ] Should restore focus return to the workflow overlay if the workflow run ended while the parent question was open? `[OWNER: workflows team]` -- [ ] Should `.gitignore` gain a dedicated ignored artifact directory for future agent-generated reports, or should orchestration always write such reports outside the repo? `[OWNER: repo maintainers]`