fix(tui): harden overlay focus restoration

This commit is contained in:
Nico Bailon
2026-05-30 12:48:05 -07:00
parent 82f29ea442
commit 91a2f86600
8 changed files with 542 additions and 176 deletions

View File

@@ -2371,7 +2371,7 @@ const result = await ctx.ui.custom<string | null>(
);
```
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<string | null>(
@@ -2379,12 +2379,19 @@ const result = await ctx.ui.custom<string | null>(
{
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

View File

@@ -145,8 +145,11 @@ const result = await ctx.ui.custom<string | null>(
// 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<string | null>(
);
```
### 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:

View File

@@ -21,7 +21,7 @@
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui";
import { CURSOR_MARKER, 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)
@@ -1031,77 +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 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"];
const positions = [
{ row: 2, col: 4 },
{ row: 5, col: 28 },
{ row: 8, col: 52 },
];
for (let i = 0; i < 3; i++) {
const panel = new FocusPanel(theme, labels[i]!, colors[i]!, this);
const handle = this.tui.showOverlay(panel, {
nonCapturing: true,
...positions[i]!,
width: 34,
});
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();
}
focusNext(current: FocusPanel, direction: 1 | -1 = 1): void {
const open = this.openPanelIndexes();
if (open.length === 0) {
this.close();
return;
}
const currentIndex = this.panels.indexOf(current);
const currentOpenPosition = open.indexOf(currentIndex);
const nextOpenPosition =
currentOpenPosition === -1 ? 0 : (currentOpenPosition + direction + open.length) % open.length;
this.handles[open[nextOpenPosition]!]!.focus();
this.tui.requestRender();
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);
}
dismiss(panel: FocusPanel): void {
const index = this.panels.indexOf(panel);
if (index === -1 || panel.closed) return;
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);
panel.closed = true;
this.handles[index]?.hide();
if (this.openPanelIndexes().length === 0) {
entry.panel.closed = true;
entry.handle.hide();
if (remainingEntries.length === 0) {
this.close();
return;
}
this.focusNext(panel);
this.focusEntryAt(remainingEntries, currentOpenPosition % remainingEntries.length);
}
close(): void {
for (let i = 0; i < this.handles.length; i++) {
if (!this.panels[i]?.closed) {
this.panels[i]!.closed = true;
this.handles[i]!.hide();
}
}
this.handles = [];
this.panels = [];
if (this.closed) return;
this.closed = true;
this.hidePanels();
this.done();
}
@@ -1115,7 +1104,7 @@ class FocusDemoController extends BaseOverlay {
render(width: number): string[] {
const th = this.theme;
const focused = this.panels.find((panel) => panel.handle?.isFocused())?.label ?? "Controller";
const focused = this.entries.find((entry) => entry.handle.isFocused())?.panel.label ?? "Controller";
return this.box(
[
"",
@@ -1139,36 +1128,62 @@ class FocusDemoController extends BaseOverlay {
}
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.openPanelIndexes()[0];
if (firstOpen !== undefined) {
this.handles[firstOpen]!.focus();
const firstOpen = this.openEntries()[0];
if (firstOpen) {
firstOpen.handle.focus();
this.tui.requestRender();
}
}
private openPanelIndexes(): number[] {
return this.panels.flatMap((panel, index) => (panel.closed ? [] : [index]));
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 controller: FocusDemoController;
private text = "";
private cursor = 0;
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", controller: FocusDemoController) {
constructor({
theme,
config,
controller,
}: {
theme: Theme;
config: FocusPanelConfig;
controller: FocusDemoController;
}) {
super(theme);
this.label = label;
this.color = color;
this.label = config.label;
this.color = config.color;
this.controller = controller;
}
@@ -1181,52 +1196,47 @@ class FocusPanel extends BaseOverlay {
this.controller.dismiss(this);
} else if (matchesKey(data, "ctrl+c")) {
this.controller.close();
} else if (matchesKey(data, "backspace")) {
if (this.cursor > 0) {
this.text = this.text.slice(0, this.cursor - 1) + this.text.slice(this.cursor);
this.cursor--;
}
this.inputs.push("Backspace");
} else if (matchesKey(data, "left")) {
this.cursor = Math.max(0, this.cursor - 1);
this.inputs.push("←");
} else if (matchesKey(data, "right")) {
this.cursor = Math.min(this.text.length, this.cursor + 1);
this.inputs.push("→");
} 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 (data.length === 1 && data.charCodeAt(0) >= 32) {
this.text = this.text.slice(0, this.cursor) + data + this.text.slice(this.cursor);
this.cursor++;
this.inputs.push(JSON.stringify(data));
} 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(focused ? this.color : "dim", 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(this.color, this.label)} ${focused ? th.fg("success", "FOCUSED") : th.fg("dim", "visible")}`,
` ${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: ${this.renderInput(focused)}`) + 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("│"));
@@ -1234,15 +1244,6 @@ class FocusPanel extends BaseOverlay {
return lines;
}
private renderInput(focused: boolean): string {
if (!focused) return this.text || this.theme.fg("dim", "(empty)");
const before = this.text.slice(0, this.cursor);
const cursorChar = this.cursor < this.text.length ? this.text[this.cursor] : " ";
const after = this.text.slice(this.cursor + 1);
return `${before}${CURSOR_MARKER}\x1b[7m${cursorChar}\x1b[27m${after}`;
}
}
// === Streaming input panel test (/overlay-streaming) ===

View File

@@ -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<void> {
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 = <T>(
factory: (tui: TUI, theme: unknown, keybindings: unknown, done: (result: T) => void) => Component,
options?: { overlay?: boolean },
): Promise<T> =>
(InteractiveMode as any).prototype.showExtensionCustom.call(fakeThis, factory, options) as Promise<T>;
editorContainer.addChild(editor);
ui.addChild(editorContainer);
ui.addChild(palette);
ui.setFocus(palette);
ui.start();
try {
const overlayPromise = showExtensionCustom<string>(
(_tui, _theme, _keybindings, done) => {
closeOverlay = done;
return overlay;
},
{ overlay: true },
);
await flushTui(ui, terminal);
expect(overlay.focused).toBe(true);
const replacementPromise = showExtensionCustom<string>((_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;