diff --git a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts index 3bc24704..8ad4a32b 100644 --- a/packages/coding-agent/examples/extensions/overlay-qa-tests.ts +++ b/packages/coding-agent/examples/extensions/overlay-qa-tests.ts @@ -14,10 +14,13 @@ * /overlay-maxheight - Test maxHeight truncation * /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-streaming - Multiple input panels with simulated streaming (Tab to cycle focus) */ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent"; -import type { OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@mariozechner/pi-tui"; +import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@mariozechner/pi-tui"; import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { spawn } from "child_process"; @@ -256,6 +259,42 @@ export default function (pi: ExtensionAPI) { globalToggleHandle = null; }, }); + + // Non-capturing overlay demo - passive info panel that doesn't steal focus + pi.registerCommand("overlay-passive", { + description: "Test non-capturing overlay (passive info panel alongside active overlay)", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + ctx.ui.setEditorText(""); + await ctx.ui.custom((tui, theme, _kb, done) => new PassiveDemoController(tui, theme, done), { + overlay: true, + overlayOptions: { anchor: "center", width: 48 }, + }); + }, + }); + + // Focus cycling demo - demonstrates focus(), unfocus(), isFocused() and rendering order + pi.registerCommand("overlay-focus", { + description: "Test focus cycling and rendering order with non-capturing overlays", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + ctx.ui.setEditorText(""); + await ctx.ui.custom((tui, theme, _kb, done) => new FocusDemoController(tui, theme, done), { + overlay: true, + overlayOptions: { anchor: "bottom-center", width: 55, margin: { bottom: 1 } }, + }); + }, + }); + + // Test multiple input panels with simulated streaming + pi.registerCommand("overlay-streaming", { + description: "Multiple input panels with simulated streaming (Tab to cycle focus)", + handler: async (_args: string, ctx: ExtensionCommandContext) => { + ctx.ui.setEditorText(""); + await ctx.ui.custom((tui, theme, _kb, done) => new StreamingInputController(tui, theme, done), { + overlay: true, + overlayOptions: { anchor: "bottom-center", width: 60, margin: { bottom: 1 } }, + }); + }, + }); } function sleep(ms: number): Promise { @@ -879,3 +918,431 @@ class ToggleDemoComponent extends BaseOverlay { ); } } + +// === Non-capturing passive overlay demo === + +class PassiveDemoController extends BaseOverlay { + focused = false; + private typed = ""; + private timerComponent: TimerPanel; + private timerHandle: OverlayHandle | null = null; + private interval: ReturnType | null = null; + private inputCount = 0; + private lastInputDebug = ""; + + constructor( + private tui: TUI, + theme: Theme, + private done: () => void, + ) { + super(theme); + this.timerComponent = new TimerPanel(theme); + this.timerHandle = this.tui.showOverlay(this.timerComponent, { + nonCapturing: true, + anchor: "top-right", + width: 22, + margin: { top: 1, right: 2 }, + }); + this.interval = setInterval(() => { + this.timerComponent.tick(); + this.tui.requestRender(); + }, 1000); + } + + handleInput(data: string): void { + this.inputCount++; + this.lastInputDebug = `len=${data.length} c0=${data.charCodeAt(0)}`; + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.cleanup(); + this.done(); + } else if (matchesKey(data, "backspace")) { + this.typed = this.typed.slice(0, -1); + } else if (data.length === 1 && data.charCodeAt(0) >= 32) { + this.typed += data; + } + } + + render(width: number): string[] { + const th = this.theme; + const display = this.typed.length > 0 ? this.typed : th.fg("dim", "(type here)"); + return this.box( + [ + "", + ` ${th.fg("dim", `focused=${this.focused} inputs=${this.inputCount}`)}`, + ` ${th.fg("dim", `last: ${this.lastInputDebug || "none"}`)}`, + "", + ` > ${display}`, + "", + th.fg("dim", " Type to prove input goes here."), + th.fg("dim", " Press Esc to close both."), + "", + ], + width, + "Non-Capturing Demo", + ); + } + + private cleanup(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + this.timerHandle?.hide(); + this.timerHandle = null; + } + + override dispose(): void { + this.cleanup(); + } +} + +class TimerPanel extends BaseOverlay { + private seconds = 0; + + tick(): void { + this.seconds++; + } + + render(width: number): string[] { + const th = this.theme; + const mins = Math.floor(this.seconds / 60); + const secs = this.seconds % 60; + const time = `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + return this.box([` ${th.fg("accent", time)}`, th.fg("dim", " nonCapturing: true")], width, "Timer"); + } +} + +// === Focus cycling demo === + +class FocusDemoController extends BaseOverlay { + private panels: FocusPanel[] = []; + private handles: OverlayHandle[] = []; + private focusIndex = -1; + + constructor( + private tui: TUI, + theme: Theme, + private done: () => void, + ) { + super(theme); + 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); + } + } + + 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(); + } + + private close(): void { + for (const handle of this.handles) handle.hide(); + this.handles = []; + this.panels = []; + this.done(); + } + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.close(); + } else if (matchesKey(data, "tab")) { + this.cycleFocus(); + } + } + + render(width: number): string[] { + const th = this.theme; + const focused = this.focusIndex === -1 ? "Controller" : (this.panels[this.focusIndex]?.label ?? "?"); + 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.", + "", + " Focused panel renders on top", + " (focus-based rendering order).", + "", + th.fg("dim", " Tab = cycle focus | Esc = close"), + "", + ], + width, + "Focus Demo", + ); + } + + override dispose(): void { + for (const handle of this.handles) handle.hide(); + } +} + +class FocusPanel extends BaseOverlay { + handle: OverlayHandle | null = null; + readonly label: string; + + constructor( + theme: Theme, + label: string, + private color: "error" | "success" | "accent", + private onTab: () => void, + private onClose: () => void, + ) { + super(theme); + this.label = label; + } + + handleInput(data: string): void { + if (matchesKey(data, "tab")) { + this.onTab(); + } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.onClose(); + } + } + + 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 padLine = (s: string) => truncateToWidth(s, innerW, "...", true); + const lines: string[] = []; + + 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("") + border("│")); + lines.push(border(`╰${"─".repeat(innerW)}╯`)); + + return lines; + } +} + +// === Streaming input panel test (/overlay-streaming) === + +class StreamingInputController extends BaseOverlay { + private panels: StreamingInputPanel[] = []; + private handles: OverlayHandle[] = []; + private focusIndex = -1; // -1 = controller focused, 0-2 = panel focused + private streamLines: string[] = []; + private streamInterval: ReturnType | null = null; + private lineCount = 0; + + constructor( + private tui: TUI, + theme: Theme, + private done: () => void, + ) { + super(theme); + + // Create 3 input panels as non-capturing overlays + const colors = ["error", "success", "accent"] as const; + const labels = ["Panel A", "Panel B", "Panel C"]; + + for (let i = 0; i < 3; i++) { + const panel = new StreamingInputPanel( + theme, + labels[i]!, + colors[i]!, + () => this.cycleFocus(), + () => this.close(), + ); + const handle = this.tui.showOverlay(panel, { + nonCapturing: true, + row: 1 + i * 9, + col: 2, + width: 35, + }); + panel.handle = handle; + this.panels.push(panel); + this.handles.push(handle); + } + + // Start with controller focused (focusIndex = -1) + + // Start simulated streaming + this.streamInterval = setInterval(() => { + this.lineCount++; + const timestamp = new Date().toLocaleTimeString(); + this.streamLines.push(`[${timestamp}] Streaming line ${this.lineCount}...`); + if (this.streamLines.length > 8) { + this.streamLines.shift(); + } + this.tui.requestRender(); + }, 500); + } + + private cycleFocus(): void { + // Unfocus current panel if any + if (this.focusIndex >= 0 && this.focusIndex < this.handles.length) { + this.handles[this.focusIndex]!.unfocus(); + } + + // Cycle: -1 (controller) → 0 → 1 → 2 → -1 ... + this.focusIndex++; + if (this.focusIndex >= this.handles.length) { + this.focusIndex = -1; // Back to controller + } + + // Focus new panel if any + if (this.focusIndex >= 0) { + this.handles[this.focusIndex]!.focus(); + } + + this.tui.requestRender(); + } + + private close(): void { + if (this.streamInterval) { + clearInterval(this.streamInterval); + this.streamInterval = null; + } + for (const handle of this.handles) handle.hide(); + this.handles = []; + this.panels = []; + this.done(); + } + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.close(); + } else if (matchesKey(data, "tab")) { + this.cycleFocus(); + } + } + + render(width: number): string[] { + const th = this.theme; + const focusedLabel = + this.focusIndex === -1 + ? th.fg("success", "Controller (this panel)") + : (this.panels[this.focusIndex]?.label ?? "?"); + + const lines = [ + "", + ` Current focus: ${th.fg("accent", focusedLabel)}`, + "", + " Simulated streaming output:", + th.fg("dim", " ─".repeat((width - 2) / 2)), + ]; + + for (const line of this.streamLines) { + lines.push(` ${th.fg("dim", line)}`); + } + + while (lines.length < 12) { + lines.push(""); + } + + lines.push(th.fg("dim", " ─".repeat((width - 2) / 2))); + lines.push(""); + lines.push(` Three ${th.fg("accent", "nonCapturing")} input panels on the left.`); + lines.push(" Tab cycles: Controller → Panel A → B → C → Controller"); + lines.push(" Type in each panel to test input routing."); + lines.push(""); + lines.push(th.fg("dim", " Tab = cycle focus | Esc = close all")); + lines.push(""); + + return this.box(lines, width, "Streaming + Input Test"); + } + + override dispose(): void { + this.close(); + } +} + +class StreamingInputPanel implements Component { + handle: OverlayHandle | null = null; + private typed = ""; + readonly label: string; + + constructor( + private theme: Theme, + label: string, + private color: "error" | "success" | "accent", + private onTab: () => void, + private onClose: () => void, + ) { + this.label = label; + } + + handleInput(data: string): void { + if (matchesKey(data, "tab")) { + this.onTab(); + } else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.onClose(); + } else if (matchesKey(data, "backspace")) { + this.typed = this.typed.slice(0, -1); + } else if (data.length === 1 && data.charCodeAt(0) >= 32) { + this.typed += 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 padLine = (s: string) => { + const w = visibleWidth(s); + return s + " ".repeat(Math.max(0, innerW - w)); + }; + + const inputDisplay = this.typed.length > 0 ? this.typed : th.fg("dim", "(type here)"); + const truncatedInput = truncateToWidth(` > ${inputDisplay}`, innerW, "...", true); + + const lines: string[] = []; + 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("") + border("│")); + } + lines.push(border("│") + padLine(truncatedInput) + border("│")); + lines.push(border("│") + padLine("") + border("│")); + lines.push(border("│") + padLine(th.fg("dim", " Tab | Esc")) + border("│")); + lines.push(border(`╰${"─".repeat(innerW)}╯`)); + + return lines; + } + + invalidate(): void {} +} diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a6215a7b..6a04e5fa 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### Added + +- Added non-capturing overlays via `OverlayOptions.nonCapturing` and new `OverlayHandle` methods: `focus()`, `unfocus()`, and `isFocused()` for programmatic overlay focus control ([#1355](https://github.com/badlogic/pi-mono/issues/1355)) + +### Changed + +- Overlay compositing order now uses focus order so focused overlays render on top while preserving stack semantics for show/hide behavior ([#1355](https://github.com/badlogic/pi-mono/issues/1355)) + +### Fixed + +- Fixed automatic focus restoration to skip non-capturing overlays and fixed `hideOverlay()` to only reassign focus when the popped overlay had focus ([#1355](https://github.com/badlogic/pi-mono/issues/1355)) + ## [0.56.3] - 2026-03-06 ### Added diff --git a/packages/tui/README.md b/packages/tui/README.md index 96fff9c0..75d9668b 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -93,6 +93,9 @@ const handle = tui.showOverlay(component, { // Responsive visibility visible: (termWidth, termHeight) => termWidth >= 100 // Hide on narrow terminals + + // Focus behavior + nonCapturing: true // Don't auto-focus when shown }); // OverlayHandle methods @@ -100,6 +103,9 @@ handle.hide(); // Permanently remove the overlay 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.isFocused(); // Check if overlay has focus // Hide topmost overlay tui.hideOverlay(); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 36ec45f1..7f4bb999 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -145,6 +145,8 @@ export interface OverlayOptions { * Called each render cycle with current terminal dimensions. */ visible?: (termWidth: number, termHeight: number) => boolean; + /** If true, don't capture keyboard focus when shown */ + nonCapturing?: boolean; } /** @@ -157,6 +159,12 @@ export interface OverlayHandle { setHidden(hidden: boolean): void; /** Check if overlay is temporarily hidden */ isHidden(): boolean; + /** Focus this overlay and bring it to the visual front */ + focus(): void; + /** Release focus to the previous target */ + unfocus(): void; + /** Check if this overlay currently has focus */ + isFocused(): boolean; } /** @@ -221,11 +229,13 @@ export class TUI extends Container { private stopped = false; // 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; }[] = []; constructor(terminal: Terminal, showHardwareCursor?: boolean) { @@ -285,10 +295,16 @@ export class TUI extends Container { * Returns a handle to control the overlay's visibility. */ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { - const entry = { component, options, preFocus: this.focusedComponent, hidden: false }; + const entry = { + component, + options, + preFocus: this.focusedComponent, + hidden: false, + focusOrder: ++this.focusOrderCounter, + }; this.overlayStack.push(entry); // Only focus if overlay is actually visible - if (this.isOverlayVisible(entry)) { + if (!options?.nonCapturing && this.isOverlayVisible(entry)) { this.setFocus(component); } this.terminal.hideCursor(); @@ -321,13 +337,29 @@ export class TUI extends Container { } } else { // Restore focus to this overlay when showing (if it's actually visible) - if (this.isOverlayVisible(entry)) { + if (!options?.nonCapturing && this.isOverlayVisible(entry)) { + entry.focusOrder = ++this.focusOrderCounter; this.setFocus(component); } } this.requestRender(); }, 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.requestRender(); + }, + unfocus: () => { + if (this.focusedComponent !== component) return; + const topVisible = this.getTopmostVisibleOverlay(); + this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus); + this.requestRender(); + }, + isFocused: () => this.focusedComponent === component, }; } @@ -335,9 +367,11 @@ export class TUI extends Container { hideOverlay(): void { const overlay = this.overlayStack.pop(); if (!overlay) return; - // Find topmost visible overlay, or fall back to preFocus - const topVisible = this.getTopmostVisibleOverlay(); - this.setFocus(topVisible?.component ?? overlay.preFocus); + if (this.focusedComponent === overlay.component) { + // Find topmost visible overlay, or fall back to preFocus + const topVisible = this.getTopmostVisibleOverlay(); + this.setFocus(topVisible?.component ?? overlay.preFocus); + } if (this.overlayStack.length === 0) this.terminal.hideCursor(); this.requestRender(); } @@ -356,9 +390,10 @@ export class TUI extends Container { return true; } - /** Find the topmost visible overlay, if any */ + /** 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]; } @@ -678,7 +713,7 @@ export class TUI extends Container { } } - /** Composite all overlays into content lines (in stack order, later = on top). */ + /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */ private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { if (this.overlayStack.length === 0) return lines; const result = [...lines]; @@ -687,10 +722,9 @@ export class TUI extends Container { const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = []; let minLinesNeeded = result.length; - for (const entry of this.overlayStack) { - // Skip invisible overlays (hidden or visible() returns false) - if (!this.isOverlayVisible(entry)) continue; - + const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e)); + visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder); + for (const entry of visibleEntries) { const { component, options } = entry; // Get layout with height=0 first to determine width and maxHeight @@ -723,9 +757,6 @@ export class TUI extends Container { const viewportStart = Math.max(0, workingHeight - termHeight); - // Track which lines were modified for final verification - const modifiedLines = new Set(); - // Composite each overlay for (const { overlayLines, row, col, w } of rendered) { for (let i = 0; i < overlayLines.length; i++) { @@ -736,22 +767,10 @@ export class TUI extends Container { const truncatedOverlayLine = visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i]; result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth); - modifiedLines.add(idx); } } } - // Final verification: ensure no composited line exceeds terminal width - // This is a belt-and-suspenders safeguard - compositeLineAt should already - // guarantee this, but we verify here to prevent crashes from any edge cases - // Only check lines that were actually modified (optimization) - for (const idx of modifiedLines) { - const lineWidth = visibleWidth(result[idx]); - if (lineWidth > termWidth) { - result[idx] = sliceByColumn(result[idx], 0, termWidth, true); - } - } - return result; } diff --git a/packages/tui/test/overlay-non-capturing.test.ts b/packages/tui/test/overlay-non-capturing.test.ts new file mode 100644 index 00000000..628f3971 --- /dev/null +++ b/packages/tui/test/overlay-non-capturing.test.ts @@ -0,0 +1,609 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import type { Component, Focusable } from "../src/tui.js"; +import { TUI } from "../src/tui.js"; +import { VirtualTerminal } from "./virtual-terminal.js"; + +class StaticOverlay implements Component { + constructor(private lines: string[]) {} + + render(): string[] { + return this.lines; + } + + invalidate(): void {} +} + +class EmptyContent implements Component { + render(): string[] { + return []; + } + invalidate(): void {} +} + +class FocusableOverlay implements Component, Focusable { + focused = false; + inputs: string[] = []; + + constructor(private lines: string[]) {} + + handleInput(data: string): void { + this.inputs.push(data); + } + + render(): string[] { + return this.lines; + } + + invalidate(): void {} +} + +async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise { + tui.requestRender(true); + await new Promise((resolve) => process.nextTick(resolve)); + await terminal.flush(); +} + +describe("TUI overlay non-capturing", () => { + describe("focus management", () => { + it("non-capturing overlay preserves focus on creation", 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 { + tui.showOverlay(overlay, { nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("focus() transfers focus to the overlay", 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, { nonCapturing: true }); + handle.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, false); + assert.strictEqual(overlay.focused, true); + assert.strictEqual(handle.isFocused(), true); + } finally { + tui.stop(); + } + }); + + it("unfocus() restores previous focus", 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, { nonCapturing: true }); + handle.focus(); + handle.unfocus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(overlay.focused, false); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + + it("setHidden(false) on non-capturing overlay does not auto-focus", 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, { nonCapturing: true }); + handle.setHidden(true); + handle.setHidden(false); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("hide() when overlay is not focused does not change focus", 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, { nonCapturing: true }); + handle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("hide() when focused restores focus correctly", 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, { nonCapturing: true }); + handle.focus(); + handle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("capturing overlay removed with non-capturing below restores focus to editor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const nonCapturing = new FocusableOverlay(["NC"]); + const capturing = new FocusableOverlay(["CAP"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(nonCapturing, { nonCapturing: true }); + const handle = tui.showOverlay(capturing); + assert.strictEqual(capturing.focused, true); + handle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(nonCapturing.focused, false); + } finally { + tui.stop(); + } + }); + + it("sub-overlay cleanup then hideOverlay restores focus and input to editor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const timer = new FocusableOverlay(["TIMER"]); + const controller = new FocusableOverlay(["CTRL"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const timerHandle = tui.showOverlay(timer, { nonCapturing: true }); + tui.showOverlay(controller); + assert.strictEqual(controller.focused, true); + assert.strictEqual(editor.focused, false); + timerHandle.hide(); + tui.hideOverlay(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(controller.focused, false); + assert.strictEqual(timer.focused, false); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"]); + assert.deepStrictEqual(controller.inputs, []); + assert.deepStrictEqual(timer.inputs, []); + } 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); + const editor = new FocusableOverlay(["EDITOR"]); + const timer = new FocusableOverlay(["TIMER"]); + const controller = new FocusableOverlay(["CTRL"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + // Simulate showExtensionCustom: factory creates timer synchronously, + // then .then() pushes controller as a microtask + let timerHandle: ReturnType; + let doneFn: () => void; + + const overlayPromise = new Promise((resolve) => { + doneFn = () => { + 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) => { + tui.showOverlay(c); + }); + }); + + // Wait for .then() microtask and renders to settle + await new Promise((r) => setTimeout(r, 50)); + await renderAndFlush(tui, terminal); + + assert.strictEqual(controller.focused, true); + assert.strictEqual(editor.focused, false); + + // Simulate Esc: cleanup + close (from inside handleInput) + doneFn!(); + // Now await the promise (simulating showExtensionCustom resolving) + await overlayPromise; + await renderAndFlush(tui, terminal); + + assert.strictEqual(editor.focused, true, "editor should regain focus"); + assert.strictEqual(controller.focused, false); + assert.strictEqual(timer.focused, false); + + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(editor.inputs, ["x"], "editor should receive input after close"); + assert.deepStrictEqual(controller.inputs, []); + } finally { + tui.stop(); + } + }); + + it("handleInput redirection skips non-capturing overlays when focused overlay becomes invisible", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const fallbackCapturing = new FocusableOverlay(["FALLBACK"]); + const nonCapturing = new FocusableOverlay(["NC"]); + const primary = new FocusableOverlay(["PRIMARY"]); + let isVisible = true; + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(fallbackCapturing); + tui.showOverlay(nonCapturing, { nonCapturing: true }); + tui.showOverlay(primary, { visible: () => isVisible }); + assert.strictEqual(primary.focused, true); + isVisible = false; + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(primary.inputs, []); + assert.deepStrictEqual(nonCapturing.inputs, []); + assert.deepStrictEqual(fallbackCapturing.inputs, ["x"]); + assert.strictEqual(fallbackCapturing.focused, true); + } 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); + const editor = new FocusableOverlay(["EDITOR"]); + const capturing = new FocusableOverlay(["CAP"]); + const nonCapturing = new FocusableOverlay(["NC"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + tui.showOverlay(capturing); + tui.showOverlay(nonCapturing, { nonCapturing: true }); + assert.strictEqual(capturing.focused, true); + tui.hideOverlay(); + await renderAndFlush(tui, terminal); + assert.strictEqual(capturing.focused, true); + } finally { + tui.stop(); + } + }); + + it("multiple capturing and non-capturing overlays restore focus through removals", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const c1 = new FocusableOverlay(["C1"]); + const n1 = new FocusableOverlay(["N1"]); + const c2 = new FocusableOverlay(["C2"]); + const n2 = new FocusableOverlay(["N2"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const c1Handle = tui.showOverlay(c1); + tui.showOverlay(n1, { nonCapturing: true }); + const c2Handle = tui.showOverlay(c2); + tui.showOverlay(n2, { nonCapturing: true }); + assert.strictEqual(c2.focused, true); + c2Handle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(c1.focused, true); + c1Handle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + } finally { + tui.stop(); + } + }); + + it("capturing overlay unfocus() on topmost capturing overlay falls back to preFocus", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + const capturing = new FocusableOverlay(["CAP"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const handle = tui.showOverlay(capturing); + assert.strictEqual(capturing.focused, true); + handle.unfocus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(capturing.focused, false); + } finally { + tui.stop(); + } + }); + }); + + describe("no-op guards", () => { + it("focus() on hidden overlay is a no-op", 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, { nonCapturing: true }); + handle.setHidden(true); + handle.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + + it("focus() after hide() is a no-op", 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, { nonCapturing: true }); + handle.hide(); + handle.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + + it("unfocus() when overlay does not have focus is a no-op", 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, { nonCapturing: true }); + handle.unfocus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(overlay.focused, false); + } finally { + tui.stop(); + } + }); + + it("unfocus() with null preFocus clears focus and does not route input back to overlay", 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); + assert.strictEqual(overlay.focused, true); + handle.unfocus(); + assert.strictEqual(overlay.focused, false); + terminal.sendInput("x"); + await renderAndFlush(tui, terminal); + assert.deepStrictEqual(overlay.inputs, []); + assert.strictEqual(handle.isFocused(), false); + } finally { + tui.stop(); + } + }); + }); + + describe("focus cycle prevention", () => { + it("toggle focus between non-capturing overlays then unfocus returns to 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"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(a, { nonCapturing: true }); + const bHandle = tui.showOverlay(b, { nonCapturing: true }); + aHandle.focus(); + bHandle.focus(); + aHandle.focus(); + aHandle.unfocus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(editor.focused, true); + assert.strictEqual(a.focused, false); + assert.strictEqual(b.focused, false); + } finally { + tui.stop(); + } + }); + }); + + describe("rendering order", () => { + it("focus() on already-focused overlay bumps visual order", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const aHandle = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + aHandle.focus(); + tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C"); + aHandle.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A"); + assert.strictEqual(aHandle.isFocused(), true); + } finally { + tui.stop(); + } + }); + + it("default rendering order for overlapping overlays follows creation order", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + } finally { + tui.stop(); + } + }); + + it("focus() on lower overlay renders it on top", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + tui.addChild(new EmptyContent()); + tui.start(); + try { + const lower = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + lower.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A"); + } finally { + tui.stop(); + } + }); + + it("focusing middle overlay places it on top while preserving others relative order", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + const middle = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + const top = tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C"); + middle.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + middle.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C"); + top.hide(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A"); + } finally { + tui.stop(); + } + }); + + it("capturing overlay hidden and shown again renders on top after unhide", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + tui.addChild(new EmptyContent()); + tui.start(); + try { + tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + const capturing = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1 }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + capturing.setHidden(true); + tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C"); + capturing.setHidden(false); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + } finally { + tui.stop(); + } + }); + + it("unfocus() does not change visual order until another overlay is focused", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TUI(terminal); + const editor = new FocusableOverlay(["EDITOR"]); + tui.addChild(new EmptyContent()); + tui.setFocus(editor); + tui.start(); + try { + const a = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + const b = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true }); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + a.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A"); + a.unfocus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A"); + b.focus(); + await renderAndFlush(tui, terminal); + assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B"); + } finally { + tui.stop(); + } + }); + }); +});