docs(coding-agent): expand overlay focus demo

Update the overlay QA focus demo to exercise three visible overlays with text input, focus cycling, per-panel dismissal, and visual focus ordering.
This commit is contained in:
Nico Bailon
2026-05-30 11:34:14 -07:00
parent 735ccbd00f
commit 82f29ea442

View File

@@ -15,13 +15,13 @@
* /overlay-sidepanel - Responsive sidepanel (hides when terminal < 100 cols) * /overlay-sidepanel - Responsive sidepanel (hides when terminal < 100 cols)
* /overlay-toggle - Toggle visibility demo (demonstrates OverlayHandle.setHidden) * /overlay-toggle - Toggle visibility demo (demonstrates OverlayHandle.setHidden)
* /overlay-passive - Non-capturing overlay demo (passive info panel alongside active overlay) * /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) * /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 { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui"; import type { Component, OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@earendil-works/pi-tui";
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { CURSOR_MARKER, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { spawn } from "child_process"; import { spawn } from "child_process";
// Global handle for toggle demo (in real code, use a more elegant pattern) // 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", { 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) => { handler: async (_args: string, ctx: ExtensionCommandContext) => {
ctx.ui.setEditorText(""); ctx.ui.setEditorText("");
await ctx.ui.custom<void>((tui, theme, _kb, done) => new FocusDemoController(tui, theme, done), { await ctx.ui.custom<void>((tui, theme, _kb, done) => new FocusDemoController(tui, theme, done), {
@@ -1035,7 +1035,6 @@ class FocusDemoController extends BaseOverlay {
private tui: TUI; private tui: TUI;
private panels: FocusPanel[] = []; private panels: FocusPanel[] = [];
private handles: OverlayHandle[] = []; private handles: OverlayHandle[] = [];
private focusIndex = -1;
private done: () => void; private done: () => void;
constructor(tui: TUI, theme: Theme, done: () => void) { constructor(tui: TUI, theme: Theme, done: () => void) {
@@ -1044,42 +1043,63 @@ class FocusDemoController extends BaseOverlay {
this.done = done; this.done = done;
const colors = ["error", "success", "accent"] as const; const colors = ["error", "success", "accent"] as const;
const labels = ["Alpha", "Beta", "Gamma"]; 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++) { for (let i = 0; i < 3; i++) {
const panel = new FocusPanel( const panel = new FocusPanel(theme, labels[i]!, colors[i]!, this);
theme,
labels[i]!,
colors[i]!,
() => this.cycleFocus(),
() => this.close(),
);
const handle = this.tui.showOverlay(panel, { const handle = this.tui.showOverlay(panel, {
nonCapturing: true, nonCapturing: true,
row: 2, ...positions[i]!,
col: 5 + i * 6, width: 34,
width: 28,
}); });
panel.handle = handle; panel.handle = handle;
this.panels.push(panel); this.panels.push(panel);
this.handles.push(handle); this.handles.push(handle);
} }
this.focusFirstOpenPanel();
} }
private cycleFocus(): void { focusNext(current: FocusPanel, direction: 1 | -1 = 1): void {
if (this.focusIndex >= 0 && this.focusIndex < this.handles.length) { const open = this.openPanelIndexes();
this.handles[this.focusIndex]!.unfocus(); if (open.length === 0) {
} this.close();
this.focusIndex++; return;
if (this.focusIndex >= this.handles.length) {
this.focusIndex = -1;
} else {
this.handles[this.focusIndex]!.focus();
} }
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(); this.tui.requestRender();
} }
private close(): void { dismiss(panel: FocusPanel): void {
for (const handle of this.handles) handle.hide(); const index = this.panels.indexOf(panel);
if (index === -1 || panel.closed) return;
panel.closed = true;
this.handles[index]?.hide();
if (this.openPanelIndexes().length === 0) {
this.close();
return;
}
this.focusNext(panel);
}
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.handles = [];
this.panels = []; this.panels = [];
this.done(); this.done();
@@ -1089,64 +1109,102 @@ class FocusDemoController extends BaseOverlay {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.close(); this.close();
} else if (matchesKey(data, "tab")) { } else if (matchesKey(data, "tab")) {
this.cycleFocus(); this.focusFirstOpenPanel();
} }
} }
render(width: number): string[] { render(width: number): string[] {
const th = this.theme; const th = this.theme;
const focused = this.focusIndex === -1 ? "Controller" : (this.panels[this.focusIndex]?.label ?? "?"); const focused = this.panels.find((panel) => panel.handle?.isFocused())?.label ?? "Controller";
return this.box( return this.box(
[ [
"", "",
` Current focus: ${th.fg("accent", focused)}`, ` Current focus: ${th.fg("accent", focused)}`,
"", "",
" Three overlapping panels above are", " Three overlapping panels above are",
` all ${th.fg("accent", "nonCapturing")}. Press Tab to`, ` ${th.fg("accent", "nonCapturing")} overlays controlled with`,
" cycle focus() between them.", " raw OverlayHandle.focus()/hide().",
"", "",
" Focused panel renders on top", " Type in the focused panel's input.",
" (focus-based rendering order).", " 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, width,
"Focus Demo", "Focus + Input Demo",
); );
} }
override dispose(): void { override dispose(): void {
for (const handle of this.handles) handle.hide(); for (const handle of this.handles) handle.hide();
} }
private focusFirstOpenPanel(): void {
const firstOpen = this.openPanelIndexes()[0];
if (firstOpen !== undefined) {
this.handles[firstOpen]!.focus();
this.tui.requestRender();
}
}
private openPanelIndexes(): number[] {
return this.panels.flatMap((panel, index) => (panel.closed ? [] : [index]));
}
} }
class FocusPanel extends BaseOverlay { class FocusPanel extends BaseOverlay {
handle: OverlayHandle | null = null; handle: OverlayHandle | null = null;
closed = false;
readonly label: string; readonly label: string;
private color: "error" | "success" | "accent"; private color: "error" | "success" | "accent";
private onTab: () => void; private controller: FocusDemoController;
private onClose: () => void; private text = "";
private cursor = 0;
private inputs: string[] = [];
constructor( constructor(theme: Theme, label: string, color: "error" | "success" | "accent", controller: FocusDemoController) {
theme: Theme,
label: string,
color: "error" | "success" | "accent",
onTab: () => void,
onClose: () => void,
) {
super(theme); super(theme);
this.label = label; this.label = label;
this.color = color; this.color = color;
this.onTab = onTab; this.controller = controller;
this.onClose = onClose;
} }
handleInput(data: string): void { handleInput(data: string): void {
if (matchesKey(data, "tab")) { if (matchesKey(data, "tab")) {
this.onTab(); this.controller.focusNext(this);
} else if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { } else if (matchesKey(data, "shift+tab")) {
this.onClose(); 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, "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 {
this.inputs.push(JSON.stringify(data));
} }
} }
@@ -1154,25 +1212,37 @@ class FocusPanel extends BaseOverlay {
const th = this.theme; const th = this.theme;
const focused = this.handle?.isFocused() ?? false; const focused = this.handle?.isFocused() ?? false;
const innerW = Math.max(1, width - 2); const innerW = Math.max(1, width - 2);
const border = (c: string) => th.fg(this.color, c); const border = (c: string) => th.fg(focused ? this.color : "dim", c);
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true); const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
const recent = this.inputs.length === 0 ? "(none)" : this.inputs.slice(-6).join(" ");
const lines: string[] = []; const lines: string[] = [];
lines.push(border(`${"─".repeat(innerW)}`)); lines.push(border(`${"─".repeat(innerW)}`));
lines.push(border("│") + padLine(` ${th.fg("accent", this.label)}`) + border("│")); lines.push(
lines.push(border("│") + padLine("") + border("│")); border("│") +
if (focused) { padLine(
lines.push(border("│") + padLine(th.fg("success", "FOCUSED")) + border("│")); ` ${th.fg(this.color, this.label)} ${focused ? th.fg("success", "FOCUSED") : th.fg("dim", "visible")}`,
lines.push(border("│") + padLine(th.fg("dim", " (receiving input)")) + border("│")); ) +
} else { border("│"),
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("│") + padLine("") + border("│"));
lines.push(border("│") + padLine(` Input: ${this.renderInput(focused)}`) + 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)}`)); lines.push(border(`${"─".repeat(innerW)}`));
return lines; 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) === // === Streaming input panel test (/overlay-streaming) ===