diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 40781092..a30bbf52 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2371,7 +2371,7 @@ const result = await ctx.ui.custom( ); ``` -For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control visibility programmatically: +For advanced positioning (anchors, margins, percentages, responsive visibility), pass `overlayOptions`. Use `onHandle` to control focus or visibility programmatically: ```typescript const result = await ctx.ui.custom( @@ -2379,12 +2379,19 @@ const result = await ctx.ui.custom( { overlay: true, overlayOptions: { anchor: "top-right", width: "50%", margin: 2 }, - onHandle: (handle) => { /* handle.setHidden(true/false) */ } + onHandle: (handle) => { + handle.focus(); // focus this overlay and bring it to the visual front + // handle.unfocus({ target: editorComponent }); // release input to a specific component + // handle.setHidden(true/false); // toggle visibility + // handle.hide(); // permanently remove + } } ); ``` -See [tui.md](tui.md) for the full `OverlayOptions` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples. +A focused visible overlay can reclaim input after temporary non-overlay custom UI closes. If you intentionally want another component to keep input while the overlay stays visible, call `handle.unfocus({ target })`. Passing `{ target: null }` releases the overlay without focusing another component. + +See [tui.md](tui.md) for the full `OverlayOptions` and `OverlayHandle` API and [overlay-qa-tests.ts](../examples/extensions/overlay-qa-tests.ts) for examples. ### Custom Editor diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index afdaf721..c3b31204 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -145,8 +145,11 @@ const result = await ctx.ui.custom( // Responsive: hide on narrow terminals visible: (termWidth, termHeight) => termWidth >= 80, }, - // Get handle for programmatic visibility control + // Get handle for programmatic focus and visibility control onHandle: (handle) => { + // handle.focus() - focus this overlay and bring it to the visual front + // handle.unfocus() - release input to normal fallback + // handle.unfocus({ target }) - release input to a specific component or null // handle.setHidden(true/false) - toggle visibility // handle.hide() - permanently remove }, @@ -154,6 +157,12 @@ const result = await ctx.ui.custom( ); ``` +### Overlay Focus + +A focused visible overlay keeps input ownership across temporary non-overlay UI. If an overlay opens another `ctx.ui.custom()` component without `{ overlay: true }`, that replacement UI receives input while it is active; when it closes, the focused overlay can reclaim input. + +Use `handle.unfocus()` when a visible overlay should stop owning input and let TUI fall back to another visible capturing overlay or the previous focus target. Use `handle.unfocus({ target })` when a specific component should receive input while the overlay stays visible. Passing `{ target: null }` intentionally leaves no focused component until focus is set again. + ### Overlay Lifecycle Overlay components are disposed when closed. Don't reuse references - create fresh instances: diff --git a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts index 306c139b..fd4486d8 100644 --- a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts +++ b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts @@ -15,13 +15,13 @@ * /overlay-sidepanel - Responsive sidepanel (hides when terminal < 100 cols) * /overlay-toggle - Toggle visibility demo (demonstrates OverlayHandle.setHidden) * /overlay-passive - Non-capturing overlay demo (passive info panel alongside active overlay) - * /overlay-focus - Focus cycling and rendering order with non-capturing overlays + * /overlay-focus - Focus cycling, input routing, dismissal, and rendering order with overlays * /overlay-streaming - Multiple input panels with simulated streaming (Tab to cycle focus) */ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent"; import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui"; -import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { Input, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; // Global handle for toggle demo (in real code, use a more elegant pattern) @@ -272,9 +272,9 @@ export default function (pi: ExtensionAPI) { }, }); - // Focus cycling demo - demonstrates focus(), unfocus(), isFocused() and rendering order + // Focus cycling demo - demonstrates focus(), input routing, per-panel dismissal, and rendering order pi.registerCommand("overlay-focus", { - description: "Test focus cycling and rendering order with non-capturing overlays", + description: "Test focus cycling, input routing, dismissal, and rendering order with overlays", handler: async (_args: string, ctx: ExtensionCommandContext) => { ctx.ui.setEditorText(""); await ctx.ui.custom((tui, theme, _kb, done) => new FocusDemoController(tui, theme, done), { @@ -1031,57 +1031,66 @@ class TimerPanel extends BaseOverlay { // === Focus cycling demo === +type FocusPanelColor = "error" | "success" | "accent"; +type FocusPanelConfig = { label: string; color: FocusPanelColor; options: OverlayOptions }; +type FocusPanelEntry = { panel: FocusPanel; handle: OverlayHandle }; + +const FOCUS_PANEL_CONFIGS = [ + { label: "Alpha", color: "error", options: { row: 2, col: 4, width: 34 } }, + { label: "Beta", color: "success", options: { row: 5, col: 28, width: 34 } }, + { label: "Gamma", color: "accent", options: { row: 8, col: 52, width: 34 } }, +] satisfies FocusPanelConfig[]; + class FocusDemoController extends BaseOverlay { - private tui: TUI; - private panels: FocusPanel[] = []; - private handles: OverlayHandle[] = []; - private focusIndex = -1; - private done: () => void; + private readonly tui: TUI; + private entries: FocusPanelEntry[] = []; + private readonly done: () => void; + private closed = false; constructor(tui: TUI, theme: Theme, done: () => void) { super(theme); this.tui = tui; this.done = done; - const colors = ["error", "success", "accent"] as const; - const labels = ["Alpha", "Beta", "Gamma"]; - for (let i = 0; i < 3; i++) { - const panel = new FocusPanel( - theme, - labels[i]!, - colors[i]!, - () => this.cycleFocus(), - () => this.close(), - ); - const handle = this.tui.showOverlay(panel, { - nonCapturing: true, - row: 2, - col: 5 + i * 6, - width: 28, - }); - panel.handle = handle; - this.panels.push(panel); - this.handles.push(handle); + for (const config of FOCUS_PANEL_CONFIGS) { + const panel = new FocusPanel({ theme, config, controller: this }); + const handle = this.tui.showOverlay(panel, { nonCapturing: true, ...config.options }); + this.entries.push({ panel, handle }); } + + this.focusFirstOpenPanel(); } - private cycleFocus(): void { - if (this.focusIndex >= 0 && this.focusIndex < this.handles.length) { - this.handles[this.focusIndex]!.unfocus(); - } - this.focusIndex++; - if (this.focusIndex >= this.handles.length) { - this.focusIndex = -1; - } else { - this.handles[this.focusIndex]!.focus(); - } - this.tui.requestRender(); + focusNext(current: FocusPanel, direction: 1 | -1 = 1): void { + const openEntries = this.openEntries(); + const currentOpenPosition = openEntries.findIndex((entry) => entry.panel === current); + if (currentOpenPosition === -1) throw new Error(`Panel ${current.label} is not open`); + const nextOpenPosition = (currentOpenPosition + direction + openEntries.length) % openEntries.length; + this.focusEntryAt(openEntries, nextOpenPosition); } - private close(): void { - for (const handle of this.handles) handle.hide(); - this.handles = []; - this.panels = []; + dismiss(panel: FocusPanel): void { + const openEntries = this.openEntries(); + const currentOpenPosition = openEntries.findIndex((candidate) => candidate.panel === panel); + if (currentOpenPosition === -1) return; + const entry = openEntries[currentOpenPosition]; + if (!entry) throw new Error(`Invalid focus panel index ${currentOpenPosition}`); + const remainingEntries = openEntries.filter((candidate) => candidate.panel !== panel); + + entry.panel.closed = true; + entry.handle.hide(); + if (remainingEntries.length === 0) { + this.close(); + return; + } + + this.focusEntryAt(remainingEntries, currentOpenPosition % remainingEntries.length); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.hidePanels(); this.done(); } @@ -1089,86 +1098,148 @@ class FocusDemoController extends BaseOverlay { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); } else if (matchesKey(data, "tab")) { - this.cycleFocus(); + this.focusFirstOpenPanel(); } } render(width: number): string[] { const th = this.theme; - const focused = this.focusIndex === -1 ? "Controller" : (this.panels[this.focusIndex]?.label ?? "?"); + const focused = this.entries.find((entry) => entry.handle.isFocused())?.panel.label ?? "Controller"; return this.box( [ "", ` Current focus: ${th.fg("accent", focused)}`, "", " Three overlapping panels above are", - ` all ${th.fg("accent", "nonCapturing")}. Press Tab to`, - " cycle focus() between them.", + ` ${th.fg("accent", "nonCapturing")} overlays controlled with`, + " raw OverlayHandle.focus()/hide().", "", - " Focused panel renders on top", - " (focus-based rendering order).", + " Type in the focused panel's input.", + " Focused panel renders on top.", "", - th.fg("dim", " Tab = cycle focus | Esc = close"), + th.fg("dim", " Tab/Shift+Tab = cycle panels"), + th.fg("dim", " Esc/Ctrl+D = dismiss panel"), + th.fg("dim", " Ctrl+C = close all"), "", ], width, - "Focus Demo", + "Focus + Input Demo", ); } override dispose(): void { - for (const handle of this.handles) handle.hide(); + if (this.closed) return; + this.closed = true; + this.hidePanels(); + } + + private focusFirstOpenPanel(): void { + const firstOpen = this.openEntries()[0]; + if (firstOpen) { + firstOpen.handle.focus(); + this.tui.requestRender(); + } + } + + private focusEntryAt(entries: FocusPanelEntry[], index: number): void { + const entry = entries[index]; + if (!entry) throw new Error(`Invalid focus panel index ${index}`); + entry.handle.focus(); + this.tui.requestRender(); + } + + private hidePanels(): void { + for (const entry of this.entries) { + if (!entry.panel.closed) { + entry.panel.closed = true; + entry.handle.hide(); + } + } + this.entries = []; + } + + private openEntries(): FocusPanelEntry[] { + return this.entries.filter((entry) => !entry.panel.closed); } } class FocusPanel extends BaseOverlay { - handle: OverlayHandle | null = null; + focused = false; + closed = false; readonly label: string; - private color: "error" | "success" | "accent"; - private onTab: () => void; - private onClose: () => void; + private readonly color: FocusPanelColor; + private readonly controller: FocusDemoController; + private readonly input = new Input(); + private inputs: string[] = []; - constructor( - theme: Theme, - label: string, - color: "error" | "success" | "accent", - onTab: () => void, - onClose: () => void, - ) { + constructor({ + theme, + config, + controller, + }: { + theme: Theme; + config: FocusPanelConfig; + controller: FocusDemoController; + }) { super(theme); - this.label = label; - this.color = color; - this.onTab = onTab; - this.onClose = onClose; + this.label = config.label; + this.color = config.color; + this.controller = controller; } handleInput(data: string): void { if (matchesKey(data, "tab")) { - this.onTab(); - } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { - this.onClose(); + this.controller.focusNext(this); + } else if (matchesKey(data, "shift+tab")) { + this.controller.focusNext(this, -1); + } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+d")) { + this.controller.dismiss(this); + } else if (matchesKey(data, "ctrl+c")) { + this.controller.close(); + } else if (matchesKey(data, "return")) { + this.inputs.push("Enter"); + } else if (matchesKey(data, "up")) { + this.inputs.push("↑"); + } else if (matchesKey(data, "down")) { + this.inputs.push("↓"); + } else if (matchesKey(data, "left")) { + this.input.handleInput(data); + this.inputs.push("←"); + } else if (matchesKey(data, "right")) { + this.input.handleInput(data); + this.inputs.push("→"); + } else if (matchesKey(data, "backspace")) { + this.input.handleInput(data); + this.inputs.push("Backspace"); + } else { + this.input.handleInput(data); + this.inputs.push(JSON.stringify(data)); } } render(width: number): string[] { const th = this.theme; - const focused = this.handle?.isFocused() ?? false; const innerW = Math.max(1, width - 2); - const border = (c: string) => th.fg(this.color, c); + const border = (c: string) => th.fg(this.focused ? this.color : "dim", c); const padLine = (s: string) => truncateToWidth(s, innerW, "...", true); + const recent = this.inputs.length === 0 ? "(none)" : this.inputs.slice(-6).join(" "); const lines: string[] = []; + this.input.focused = this.focused; + const [inputLine = ""] = this.input.render(Math.max(1, innerW - 8)); lines.push(border(`╭${"─".repeat(innerW)}╮`)); - lines.push(border("│") + padLine(` ${th.fg("accent", this.label)}`) + border("│")); - lines.push(border("│") + padLine("") + border("│")); - if (focused) { - lines.push(border("│") + padLine(th.fg("success", " ● FOCUSED")) + border("│")); - lines.push(border("│") + padLine(th.fg("dim", " (receiving input)")) + border("│")); - } else { - lines.push(border("│") + padLine(th.fg("dim", " ○ unfocused")) + border("│")); - lines.push(border("│") + padLine(th.fg("dim", " (passive)")) + border("│")); - } + lines.push( + border("│") + + padLine( + ` ${th.fg(this.color, this.label)} ${this.focused ? th.fg("success", "FOCUSED") : th.fg("dim", "visible")}`, + ) + + border("│"), + ); lines.push(border("│") + padLine("") + border("│")); + lines.push(border("│") + padLine(` Input: ${inputLine}`) + border("│")); + lines.push(border("│") + padLine(` Keys: ${recent}`) + border("│")); + lines.push(border("│") + padLine(th.fg("dim", " Tab/Shift+Tab focus")) + border("│")); + lines.push(border("│") + padLine(th.fg("dim", " Esc/Ctrl+D dismiss")) + border("│")); lines.push(border(`╰${"─".repeat(innerW)}╯`)); return lines; diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index d5a2c159..5f4a8e21 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -1,7 +1,9 @@ import { homedir } from "node:os"; import * as path from "node:path"; -import { type AutocompleteProvider, CombinedAutocompleteProvider, Container } from "@earendil-works/pi-tui"; +import { type AutocompleteProvider, CombinedAutocompleteProvider } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; +import { type Component, Container, type Focusable, TUI } from "../../tui/src/tui.ts"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; @@ -17,6 +19,41 @@ function renderAll(container: Container, width = 120): string { return container.children.flatMap((child) => child.render(width)).join("\n"); } +class TestFocusableComponent implements Component, Focusable { + focused = false; + inputs: string[] = []; + private readonly label: string; + private text = ""; + + constructor(label: string) { + this.label = label; + } + + handleInput(data: string): void { + this.inputs.push(data); + } + + getText(): string { + return this.text; + } + + setText(text: string): void { + this.text = text; + } + + render(): string[] { + return [this.label]; + } + + invalidate(): void {} +} + +async function flushTui(tui: TUI, terminal: VirtualTerminal): Promise { + tui.requestRender(true); + await Promise.resolve(); + await terminal.waitForRender(); +} + function normalizeRenderedOutput(container: Container, width = 220): string { return renderAll(container, width) .replace(/\u001b\[[0-9;]*m/g, "") @@ -148,6 +185,78 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { }); }); +describe("InteractiveMode.showExtensionCustom", () => { + beforeAll(() => { + initTheme("dark"); + }); + + test("overlay custom UI reclaims input after non-overlay custom UI closes", async () => { + const terminal = new VirtualTerminal(80, 24); + const ui = new TUI(terminal); + const editorContainer = new Container(); + const editor = new TestFocusableComponent("EDITOR"); + const palette = new TestFocusableComponent("PALETTE"); + const overlay = new TestFocusableComponent("OVERLAY"); + const replacement = new TestFocusableComponent("REPLACEMENT"); + let closeOverlay: (value: string) => void = () => { + throw new Error("closeOverlay was not initialized"); + }; + let closeReplacement: (value: string) => void = () => { + throw new Error("closeReplacement was not initialized"); + }; + const fakeThis = { + editor, + editorContainer, + keybindings: {}, + ui, + }; + const showExtensionCustom = ( + factory: (tui: TUI, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component, + options?: { overlay?: boolean }, + ): Promise => + (InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, factory, options) as Promise; + + editorContainer.addChild(editor); + ui.addChild(editorContainer); + ui.addChild(palette); + ui.setFocus(palette); + ui.start(); + try { + const overlayPromise = showExtensionCustom( + (_tui, _theme, _keybindings, done) => { + closeOverlay = done; + return overlay; + }, + { overlay: true }, + ); + await flushTui(ui, terminal); + expect(overlay.focused).toBe(true); + + const replacementPromise = showExtensionCustom((_tui, _theme, _keybindings, done) => { + closeReplacement = done; + return replacement; + }); + await flushTui(ui, terminal); + expect(replacement.focused).toBe(true); + + closeReplacement("done"); + await replacementPromise; + await flushTui(ui, terminal); + terminal.sendInput("x"); + await flushTui(ui, terminal); + + expect(overlay.inputs).toEqual(["x"]); + expect(editor.inputs).toEqual([]); + expect(overlay.focused).toBe(true); + + closeOverlay("closed"); + await overlayPromise; + } finally { + ui.stop(); + } + }); +}); + describe("InteractiveMode.createExtensionUIContext addAutocompleteProvider", () => { test("stores wrapper factories and rebuilds autocomplete immediately", () => { const wrapper: AutocompleteProviderFactory = (current) => current; diff --git a/packages/tui/README.md b/packages/tui/README.md index 1da50a3a..d0aae400 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -116,9 +116,21 @@ handle.setHidden(true); // Temporarily hide (can show again) handle.setHidden(false); // Show again after hiding handle.isHidden(); // Check if temporarily hidden handle.focus(); // Focus and bring to visual front -handle.unfocus(); // Release focus to previous target +handle.unfocus(); // Release focus to normal fallback +handle.unfocus({ target: baseComponent }); // Release this overlay to a specific component +handle.unfocus({ target: null }); // Release this overlay and leave focus empty handle.isFocused(); // Check if overlay has focus +handle.unfocus(); +// Overlay loses focus; TUI falls back to another visible capturing overlay or the previous focus target. + +handle.unfocus({ target: null }); +// Overlay loses focus; no component receives input until focus is set again. + +// A focused visible overlay reclaims keyboard input after temporary replacement UI +// releases focus. If you want a specific component to receive input while overlays remain +// visible, call handle.unfocus({ target: component }). + // Hide topmost overlay tui.hideOverlay(); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index a645c8a6..fe0fed00 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -99,6 +99,7 @@ export { type OverlayHandle, type OverlayMargin, type OverlayOptions, + type OverlayUnfocusOptions, type SizeValue, TUI, } from "./tui.ts"; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 2874ef02..f746ee68 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -176,6 +176,12 @@ export interface OverlayOptions { nonCapturing?: boolean; } +/** Options for {@link OverlayHandle.unfocus}. */ +export interface OverlayUnfocusOptions { + /** Explicit target to focus after releasing this overlay. */ + target: Component | null; +} + /** * Handle returned by showOverlay for controlling the overlay */ @@ -188,12 +194,32 @@ export interface OverlayHandle { isHidden(): boolean; /** Focus this overlay and bring it to the visual front */ focus(): void; - /** Release focus to the previous target */ - unfocus(): void; + /** Release focus to the next visible capturing overlay or previous target, or to an explicit target when provided */ + unfocus(options?: OverlayUnfocusOptions): void; /** Check if this overlay currently has focus */ isFocused(): boolean; } +type OverlayStackEntry = { + component: Component; + options?: OverlayOptions; + preFocus: Component | null; + hidden: boolean; + focusOrder: number; +}; + +type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null }; +type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry }; +type BlockedOverlayFocusRestoreState = { + status: "blocked"; + overlay: OverlayStackEntry; + blockedBy: Component; + resume: OverlayBlockedFocusResume; +}; +type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState; +type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState; +type OverlayFocusRestorePolicy = "clear" | "preserve"; + /** * Container - a component that contains other components */ @@ -262,13 +288,8 @@ export class TUI extends Container { // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; - private overlayStack: { - component: Component; - options?: OverlayOptions; - preFocus: Component | null; - hidden: boolean; - focusOrder: number; - }[] = []; + private overlayStack: OverlayStackEntry[] = []; + private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" }; constructor(terminal: Terminal, showHardwareCursor?: boolean) { super(); @@ -309,17 +330,126 @@ export class TUI extends Container { } setFocus(component: Component | null): void { - // Clear focused flag on old component + this.setFocusInternal({ component, overlayFocusRestore: "clear" }); + } + + private setFocusInternal({ + component, + overlayFocusRestore, + }: { + component: Component | null; + overlayFocusRestore: OverlayFocusRestorePolicy; + }): void { + const previousFocus = this.focusedComponent; + let nextFocus = component; + const previousFocusedOverlay = previousFocus + ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry)) + : undefined; + const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false; + const restoreState = this.getVisibleOverlayFocusRestore(); + if (nextFocus && !nextFocusIsOverlay) { + if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { + if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) { + nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); + } else { + this.overlayFocusRestore = { + status: "blocked", + overlay: restoreState.overlay, + blockedBy: nextFocus, + resume: restoreState.resume, + }; + } + } else if ( + previousFocusedOverlay && + restoreState.status !== "inactive" && + restoreState.overlay === previousFocusedOverlay && + !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus) + ) { + this.overlayFocusRestore = { + status: "blocked", + overlay: previousFocusedOverlay, + blockedBy: nextFocus, + resume: { status: "restore-overlay" }, + }; + } + } else if (nextFocus === null) { + if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { + nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); + } else if (overlayFocusRestore === "clear") { + this.clearOverlayFocusRestore(); + } + } + if (isFocusable(this.focusedComponent)) { this.focusedComponent.focused = false; } - this.focusedComponent = component; + this.focusedComponent = nextFocus; - // Set focused flag on new component - if (isFocusable(component)) { - component.focused = true; + if (isFocusable(nextFocus)) { + nextFocus.focused = true; } + + const focusedOverlay = nextFocus + ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry)) + : undefined; + if (focusedOverlay) { + this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay }; + } + } + + private clearOverlayFocusRestore(): void { + this.overlayFocusRestore = { status: "inactive" }; + } + + private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void { + if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) { + this.clearOverlayFocusRestore(); + } + } + + private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null { + if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component; + this.clearOverlayFocusRestore(); + return restoreState.resume.target; + } + + private getVisibleOverlayFocusRestore(): OverlayFocusRestoreState { + const restoreState = this.overlayFocusRestore; + if (restoreState.status === "inactive") return restoreState; + if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) { + return { status: "inactive" }; + } + return restoreState; + } + + private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean { + const visited = new Set(); + let current = entry.preFocus; + while (current && !visited.has(current)) { + visited.add(current); + if (current === component) return true; + current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null; + } + return false; + } + + private retargetOverlayPreFocus(removed: OverlayStackEntry): void { + for (const overlay of this.overlayStack) { + if (overlay !== removed && overlay.preFocus === removed.component) { + overlay.preFocus = removed.preFocus; + } + } + } + + private isComponentMounted(component: Component): boolean { + return this.children.some((child) => this.containsComponent(child, component)); + } + + private containsComponent(root: Component, target: Component): boolean { + if (root === target) return true; + if (!(root instanceof Container)) return false; + return root.children.some((child) => this.containsComponent(child, target)); } /** @@ -327,9 +457,9 @@ export class TUI extends Container { * Returns a handle to control the overlay's visibility. */ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { - const entry = { + const entry: OverlayStackEntry = { component, - options, + ...(options === undefined ? {} : { options }), preFocus: this.focusedComponent, hidden: false, focusOrder: ++this.focusOrderCounter, @@ -347,6 +477,8 @@ export class TUI extends Container { hide: () => { const index = this.overlayStack.indexOf(entry); if (index !== -1) { + this.clearOverlayFocusRestoreFor(entry); + this.retargetOverlayPreFocus(entry); this.overlayStack.splice(index, 1); // Restore focus if this overlay had focus if (this.focusedComponent === component) { @@ -362,6 +494,7 @@ export class TUI extends Container { entry.hidden = hidden; // Update focus when hiding/showing if (hidden) { + this.clearOverlayFocusRestoreFor(entry); // If this overlay had focus, move focus to next visible or preFocus if (this.focusedComponent === component) { const topVisible = this.getTopmostVisibleOverlay(); @@ -379,16 +512,39 @@ export class TUI extends Container { isHidden: () => entry.hidden, focus: () => { if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return; - if (this.focusedComponent !== component) { - this.setFocus(component); - } entry.focusOrder = ++this.focusOrderCounter; + this.setFocus(component); this.requestRender(); }, - unfocus: () => { - if (this.focusedComponent !== component) return; - const topVisible = this.getTopmostVisibleOverlay(); - this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus); + unfocus: (unfocusOptions) => { + const isFocused = this.focusedComponent === component; + const restoreState = this.overlayFocusRestore; + const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry; + if (!isFocused && !hasPendingRestore) return; + if ( + restoreState.status === "blocked" && + restoreState.overlay === entry && + this.focusedComponent === restoreState.blockedBy + ) { + if (unfocusOptions) { + this.overlayFocusRestore = { + status: "blocked", + overlay: entry, + blockedBy: restoreState.blockedBy, + resume: { status: "focus-target", target: unfocusOptions.target }, + }; + } else { + this.clearOverlayFocusRestore(); + } + this.requestRender(); + return; + } + this.clearOverlayFocusRestoreFor(entry); + if (isFocused || unfocusOptions) { + const topVisible = this.getTopmostVisibleOverlay(); + const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus; + this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget); + } this.requestRender(); }, isFocused: () => this.focusedComponent === component, @@ -397,8 +553,11 @@ export class TUI extends Container { /** Hide the topmost overlay and restore previous focus. */ hideOverlay(): void { - const overlay = this.overlayStack.pop(); + const overlay = this.overlayStack[this.overlayStack.length - 1]; if (!overlay) return; + this.clearOverlayFocusRestoreFor(overlay); + this.retargetOverlayPreFocus(overlay); + this.overlayStack.pop(); if (this.focusedComponent === overlay.component) { // Find topmost visible overlay, or fall back to preFocus const topVisible = this.getTopmostVisibleOverlay(); @@ -414,7 +573,7 @@ export class TUI extends Container { } /** Check if an overlay entry is currently visible */ - private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean { + private isOverlayVisible(entry: OverlayStackEntry): boolean { if (entry.hidden) return false; if (entry.options?.visible) { return entry.options.visible(this.terminal.columns, this.terminal.rows); @@ -422,15 +581,16 @@ export class TUI extends Container { return true; } - /** Find the topmost visible capturing overlay, if any */ - private getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined { - for (let i = this.overlayStack.length - 1; i >= 0; i--) { - if (this.overlayStack[i].options?.nonCapturing) continue; - if (this.isOverlayVisible(this.overlayStack[i])) { - return this.overlayStack[i]; + /** Find the visual-frontmost visible capturing overlay, if any */ + private getTopmostVisibleOverlay(): OverlayStackEntry | undefined { + let topmost: OverlayStackEntry | undefined; + for (const overlay of this.overlayStack) { + if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue; + if (!topmost || overlay.focusOrder > topmost.focusOrder) { + topmost = overlay; } } - return undefined; + return topmost; } override invalidate(): void { @@ -579,8 +739,22 @@ export class TUI extends Container { if (topVisible) { this.setFocus(topVisible.component); } else { - // No visible overlays, restore to preFocus - this.setFocus(focusedOverlay.preFocus); + this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" }); + } + } + + const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent); + if (!focusIsOverlay) { + const restoreState = this.getVisibleOverlayFocusRestore(); + if (restoreState.status === "eligible") { + this.setFocus(restoreState.overlay.component); + } else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) { + if (restoreState.resume.status === "restore-overlay") { + this.setFocus(restoreState.overlay.component); + } else { + this.clearOverlayFocusRestore(); + this.setFocus(restoreState.resume.target); + } } } diff --git a/packages/tui/test/overlay-non-capturing.test.ts b/packages/tui/test/overlay-non-capturing.test.ts index cb91e5e4..64ce6203 100644 --- a/packages/tui/test/overlay-non-capturing.test.ts +++ b/packages/tui/test/overlay-non-capturing.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Component, Focusable } from "../src/tui.ts"; -import { TUI } from "../src/tui.ts"; +import { Container, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticOverlay implements Component { @@ -222,6 +222,35 @@ describe("TUI overlay non-capturing", () => { } }); + it("removed focused child overlay does not become parent overlay fallback", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const child = new FocusableOverlay(["CHILD"]); + const parent = new FocusableOverlay(["PARENT"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const childHandle = tui.showOverlay(child, { nonCapturing: true }); + childHandle.focus(); + const parentHandle = tui.showOverlay(parent); + assert.strictEqual(parent.focused, true); + + childHandle.hide(); + parentHandle.hide(); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(child.inputs, []); + assert.deepStrictEqual(parent.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + it("microtask-deferred sub-overlay pattern (showExtensionCustom simulation) restores focus", async () => { const terminal = new VirtualTerminal(80, 24); const tui = new TUI(terminal); @@ -234,16 +263,18 @@ describe("TUI overlay non-capturing", () => { try { // Simulate showExtensionCustom: factory creates timer synchronously, // then .then() pushes controller as a microtask - let timerHandle: ReturnType; - let doneFn: () => void; + let timerHandle: ReturnType | null = null; + let doneFn: () => void = () => { + throw new Error("doneFn was not initialized"); + }; const overlayPromise = new Promise((resolve) => { doneFn = () => { + if (!timerHandle) throw new Error("timerHandle was not initialized"); timerHandle.hide(); tui.hideOverlay(); resolve(); }; - // Factory runs synchronously: creates timer sub-overlay timerHandle = tui.showOverlay(timer, { nonCapturing: true }); // .then() runs as microtask — same as showExtensionCustom Promise.resolve(controller).then((c) => { @@ -251,15 +282,14 @@ describe("TUI overlay non-capturing", () => { }); }); - // Wait for .then() microtask and renders to settle - await new Promise((r) => setTimeout(r, 50)); + await Promise.resolve(); await renderAndFlush(tui, terminal); assert.strictEqual(controller.focused, true); assert.strictEqual(editor.focused, false); // Simulate Esc: cleanup + close (from inside handleInput) - doneFn!(); + doneFn(); // Now await the promise (simulating showExtensionCustom resolving) await overlayPromise; await renderAndFlush(tui, terminal); @@ -305,6 +335,473 @@ describe("TUI overlay non-capturing", () => { } }); + it("active base focus replacement receives close input before overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + } + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + tui.setFocus(editor); + } + }; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + assert.strictEqual(overlay.focused, true); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.strictEqual(overlay.focused, true); + + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + } finally { + tui.stop(); + } + }); + + it("active replacement still receives input when it is another overlay preFocus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const passive = new FocusableOverlay(["PASSIVE"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + } + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + tui.setFocus(editor); + } + }; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.setFocus(replacement); + tui.showOverlay(passive, { nonCapturing: true }); + tui.setFocus(editor); + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + + terminal.sendInput("1"); + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["1", "\r"]); + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("blocked replacement can move focus internally before overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const base = new Container(); + const editor = new FocusableOverlay(["EDITOR"]); + const firstReplacement = new FocusableOverlay(["FIRST"]); + const secondReplacement = new FocusableOverlay(["SECOND"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(firstReplacement); + }; + firstReplacement.handleInput = (data: string) => { + firstReplacement.inputs.push(data); + if (data === "n") tui.setFocus(secondReplacement); + }; + secondReplacement.handleInput = (data: string) => { + secondReplacement.inputs.push(data); + if (data === "\r") { + base.clear(); + base.addChild(editor); + tui.setFocus(editor); + } + }; + base.addChild(editor); + base.addChild(firstReplacement); + base.addChild(secondReplacement); + tui.addChild(base); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("n"); + await renderAndFlush(tui, terminal); + terminal.sendInput("2"); + terminal.sendInput("\r"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.deepStrictEqual(firstReplacement.inputs, ["n"]); + assert.deepStrictEqual(secondReplacement.inputs, ["2", "\r"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("removed replacement restores overlay even when overlay preFocus differs from next focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const base = new Container(); + const editor = new FocusableOverlay(["EDITOR"]); + const palette = new FocusableOverlay(["PALETTE"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(replacement); + }; + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") { + base.clear(); + base.addChild(editor); + tui.setFocus(editor); + } + }; + base.addChild(editor); + base.addChild(palette); + base.addChild(replacement); + tui.addChild(base); + tui.setFocus(palette); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(editor.inputs, []); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("unfocus target releases a blocked overlay while replacement remains focused", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const fallback = new FocusableOverlay(["FALLBACK"]); + const target = new FocusableOverlay(["TARGET"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") tui.setFocus(fallback); + }; + tui.addChild(new EmptyContent()); + tui.start(); + try { + const overlayHandle = tui.showOverlay(overlay); + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") { + tui.setFocus(replacement); + overlayHandle.unfocus({ target }); + } + }; + + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + assert.strictEqual(replacement.focused, true); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(overlay.inputs, ["b"]); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(fallback.inputs, []); + assert.deepStrictEqual(target.inputs, ["x"]); + } finally { + tui.stop(); + } + }); + + it("handleInput restores focus to a visible focused overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay); + assert.strictEqual(overlay.focused, true); + tui.setFocus(replacement); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["x"]); + assert.deepStrictEqual(editor.inputs, []); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("handleInput restores focus to explicitly focused raw sub-overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const controller = new FocusableOverlay(["CONTROLLER"]); + const subOverlay = new FocusableOverlay(["SUB"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(controller); + const subHandle = tui.showOverlay(subOverlay, { nonCapturing: true }); + subHandle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(subOverlay.inputs, ["x"]); + assert.deepStrictEqual(controller.inputs, []); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + + it("passive non-capturing overlay does not regain input after base focus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const passive = new FocusableOverlay(["PASSIVE"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(passive, { nonCapturing: true }); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(passive.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("explicitly focused non-capturing overlay regains input after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["NC"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["x"]); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + + it("unfocus() prevents visible overlay from regaining input", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(overlay); + handle.unfocus(); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("setFocus(null) explicitly clears visible overlay restore", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay); + tui.setFocus(null); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("blocked replacement setFocus(null) resumes the visible overlay", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const replacement = new FocusableOverlay(["REPLACEMENT"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + replacement.handleInput = (data: string) => { + replacement.inputs.push(data); + if (data === "\r") tui.setFocus(null); + }; + overlay.handleInput = (data: string) => { + overlay.inputs.push(data); + if (data === "b") tui.setFocus(replacement); + }; + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + terminal.sendInput("\r"); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(replacement.inputs, ["\r"]); + assert.deepStrictEqual(overlay.inputs, ["b", "x"]); + assert.strictEqual(overlay.focused, true); + } finally { + tui.stop(); + } + }); + + it("temporarily invisible focused overlay falls back without losing restore eligibility", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + let visible = true; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(overlay, { visible: () => visible }); + tui.setFocus(editor); + visible = false; + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + visible = true; + terminal.sendInput("y"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, ["y"]); + } finally { + tui.stop(); + } + }); + + it("temporarily invisible focused overlay with null preFocus restores when visible again", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + let visible = true; + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(overlay, { visible: () => visible }); + visible = false; + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + visible = true; + terminal.sendInput("y"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, ["y"]); + } finally { + tui.stop(); + } + }); + + it("cyclic overlay preFocus ancestry does not hang focus changes", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.setFocus(overlay); + tui.start(); + try { + const handle = tui.showOverlay(overlay, { nonCapturing: true }); + handle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(overlay.inputs, []); + } finally { + tui.stop(); + } + }); + + it("handleInput restores the focus-order top overlay after base focus steal", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const lower = new FocusableOverlay(["LOWER"]); + const upper = new FocusableOverlay(["UPPER"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const lowerHandle = tui.showOverlay(lower); + tui.showOverlay(upper); + lowerHandle.focus(); + tui.setFocus(editor); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(lower.inputs, ["x"]); + assert.deepStrictEqual(upper.inputs, []); + assert.deepStrictEqual(editor.inputs, []); + } finally { + tui.stop(); + } + }); + it("hideOverlay() does not reassign focus when topmost overlay is non-capturing", async () => { const terminal = new VirtualTerminal(80, 24); const tui = new TUI(terminal); @@ -481,6 +978,95 @@ describe("TUI overlay non-capturing", () => { tui.stop(); } }); + + it("explicit unfocus target supports cycling between three overlays and editor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const a = new FocusableOverlay(["A"]); + const b = new FocusableOverlay(["B"]); + const c = new FocusableOverlay(["C"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(a); + const bHandle = tui.showOverlay(b); + const cHandle = tui.showOverlay(c); + + aHandle.focus(); + terminal.sendInput("a"); + await renderAndFlush(tui, terminal); + bHandle.focus(); + terminal.sendInput("b"); + await renderAndFlush(tui, terminal); + cHandle.focus(); + terminal.sendInput("c"); + await renderAndFlush(tui, terminal); + cHandle.unfocus({ target: editor }); + terminal.sendInput("e"); + await renderAndFlush(tui, terminal); + aHandle.focus(); + terminal.sendInput("A"); + await renderAndFlush(tui, terminal); + aHandle.unfocus({ target: editor }); + terminal.sendInput("E"); + await renderAndFlush(tui, terminal); + + assert.deepStrictEqual(a.inputs, ["a", "A"]); + assert.deepStrictEqual(b.inputs, ["b"]); + assert.deepStrictEqual(c.inputs, ["c"]); + assert.deepStrictEqual(editor.inputs, ["e", "E"]); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("explicit null unfocus target clears focus without restoring overlays", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new FocusableOverlay(["OVERLAY"]); + tui.addChild(new EmptyContent()); + tui.start(); + try { + const handle = tui.showOverlay(overlay); + handle.unfocus({ target: null }); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + + it("hiding focused overlay falls back to next visual-frontmost overlay", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const a = new FocusableOverlay(["A"]); + const b = new FocusableOverlay(["B"]); + const c = new FocusableOverlay(["C"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(a); + const bHandle = tui.showOverlay(b); + tui.showOverlay(c); + aHandle.focus(); + bHandle.focus(); + bHandle.setHidden(true); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(a.inputs, ["x"]); + assert.deepStrictEqual(c.inputs, []); + assert.strictEqual(a.focused, true); + } finally { + tui.stop(); + } + }); }); describe("rendering order", () => {