Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
42 kB · 1348 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349/** * Overlay QA Tests - comprehensive overlay positioning and edge case tests * * Usage: pi --extension ./examples/extensions/overlay-qa-tests.ts * * Commands: * /overlay-animation - Real-time animation demo (~30 FPS, proves DOOM-like rendering works) * /overlay-anchors - Cycle through all 9 anchor positions * /overlay-margins - Test margin and offset options * /overlay-stack - Test stacked overlays * /overlay-overflow - Test width overflow with streaming process output * /overlay-edge - Test overlay positioned at terminal edge * /overlay-percent - Test percentage-based positioning * /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 "@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 { spawn } from "child_process";
// Global handle for toggle demo (in real code, use a more elegant pattern)let globalToggleHandle: OverlayHandle | null = null;
export default function (pi: ExtensionAPI) { // Animation demo - proves overlays can handle real-time updates (like pi-doom would need) pi.registerCommand("overlay-animation", { description: "Test real-time animation in overlay (~30 FPS)", handler: async (_args: string, ctx: ExtensionCommandContext) => { await ctx.ui.custom<void>((tui, theme, _kb, done) => new AnimationDemoComponent(tui, theme, done), { overlay: true, overlayOptions: { anchor: "center", width: 50, maxHeight: 20 }, }); }, });
// Test all 9 anchor positions pi.registerCommand("overlay-anchors", { description: "Cycle through all anchor positions", handler: async (_args: string, ctx: ExtensionCommandContext) => { const anchors: OverlayAnchor[] = [ "top-left", "top-center", "top-right", "left-center", "center", "right-center", "bottom-left", "bottom-center", "bottom-right", ];
let index = 0; while (true) { const result = await ctx.ui.custom<"next" | "confirm" | "cancel">( (_tui, theme, _kb, done) => new AnchorTestComponent(theme, anchors[index]!, done), { overlay: true, overlayOptions: { anchor: anchors[index], width: 40 }, }, );
if (result === "next") { index = (index + 1) % anchors.length; continue; } if (result === "confirm") { ctx.ui.notify(`Selected: ${anchors[index]}`, "info"); } break; } }, });
// Test margins and offsets pi.registerCommand("overlay-margins", { description: "Test margin and offset options", handler: async (_args: string, ctx: ExtensionCommandContext) => { const configs: { name: string; options: OverlayOptions }[] = [ { name: "No margin (top-left)", options: { anchor: "top-left", width: 35 } }, { name: "Margin: 3 all sides", options: { anchor: "top-left", width: 35, margin: 3 } }, { name: "Margin: top=5, left=10", options: { anchor: "top-left", width: 35, margin: { top: 5, left: 10 } }, }, { name: "Center + offset (10, -3)", options: { anchor: "center", width: 35, offsetX: 10, offsetY: -3 } }, { name: "Bottom-right, margin: 2", options: { anchor: "bottom-right", width: 35, margin: 2 } }, ];
let index = 0; while (true) { const result = await ctx.ui.custom<"next" | "close">( (_tui, theme, _kb, done) => new MarginTestComponent(theme, configs[index]!, done), { overlay: true, overlayOptions: configs[index]!.options, }, );
if (result === "next") { index = (index + 1) % configs.length; continue; } break; } }, });
// Test stacked overlays pi.registerCommand("overlay-stack", { description: "Test stacked overlays", handler: async (_args: string, ctx: ExtensionCommandContext) => { // Three large overlays that overlap in the center area // Each offset slightly so you can see the stacking
ctx.ui.notify("Showing overlay 1 (back)...", "info"); const p1 = ctx.ui.custom<string>( (_tui, theme, _kb, done) => new StackOverlayComponent(theme, 1, "back (red border)", done), { overlay: true, overlayOptions: { anchor: "center", width: 50, offsetX: -8, offsetY: -4, maxHeight: 15 }, }, );
await sleep(400);
ctx.ui.notify("Showing overlay 2 (middle)...", "info"); const p2 = ctx.ui.custom<string>( (_tui, theme, _kb, done) => new StackOverlayComponent(theme, 2, "middle (green border)", done), { overlay: true, overlayOptions: { anchor: "center", width: 50, offsetX: 0, offsetY: 0, maxHeight: 15 }, }, );
await sleep(400);
ctx.ui.notify("Showing overlay 3 (front)...", "info"); const p3 = ctx.ui.custom<string>( (_tui, theme, _kb, done) => new StackOverlayComponent(theme, 3, "front (blue border)", done), { overlay: true, overlayOptions: { anchor: "center", width: 50, offsetX: 8, offsetY: 4, maxHeight: 15 }, }, );
// Wait for all to close const results = await Promise.all([p1, p2, p3]); ctx.ui.notify(`Closed in order: ${results.join(", ")}`, "info"); }, });
// Test width overflow scenarios (original crash case) - streams real process output pi.registerCommand("overlay-overflow", { description: "Test width overflow with streaming process output", handler: async (_args: string, ctx: ExtensionCommandContext) => { await ctx.ui.custom<void>((tui, theme, _kb, done) => new StreamingOverflowComponent(tui, theme, done), { overlay: true, overlayOptions: { anchor: "center", width: 90, maxHeight: 20 }, }); }, });
// Test overlay at terminal edge pi.registerCommand("overlay-edge", { description: "Test overlay positioned at terminal edge", handler: async (_args: string, ctx: ExtensionCommandContext) => { await ctx.ui.custom<void>((_tui, theme, _kb, done) => new EdgeTestComponent(theme, done), { overlay: true, overlayOptions: { anchor: "right-center", width: 40, margin: { right: 0 } }, }); }, });
// Test percentage-based positioning pi.registerCommand("overlay-percent", { description: "Test percentage-based positioning", handler: async (_args: string, ctx: ExtensionCommandContext) => { const configs = [ { name: "rowPercent: 0 (top)", row: 0, col: 50 }, { name: "rowPercent: 50 (middle)", row: 50, col: 50 }, { name: "rowPercent: 100 (bottom)", row: 100, col: 50 }, { name: "colPercent: 0 (left)", row: 50, col: 0 }, { name: "colPercent: 100 (right)", row: 50, col: 100 }, ];
let index = 0; while (true) { const config = configs[index]!; const result = await ctx.ui.custom<"next" | "close">( (_tui, theme, _kb, done) => new PercentTestComponent(theme, config, done), { overlay: true, overlayOptions: { width: 30, row: `${config.row}%`, col: `${config.col}%`, }, }, );
if (result === "next") { index = (index + 1) % configs.length; continue; } break; } }, });
// Test maxHeight pi.registerCommand("overlay-maxheight", { description: "Test maxHeight truncation", handler: async (_args: string, ctx: ExtensionCommandContext) => { await ctx.ui.custom<void>((_tui, theme, _kb, done) => new MaxHeightTestComponent(theme, done), { overlay: true, overlayOptions: { anchor: "center", width: 50, maxHeight: 10 }, }); }, });
// Test responsive sidepanel - only shows when terminal is wide enough pi.registerCommand("overlay-sidepanel", { description: "Test responsive sidepanel (hides when terminal < 100 cols)", handler: async (_args: string, ctx: ExtensionCommandContext) => { await ctx.ui.custom<void>((tui, theme, _kb, done) => new SidepanelComponent(tui, theme, done), { overlay: true, overlayOptions: { anchor: "right-center", width: "25%", minWidth: 30, margin: { right: 1 }, // Only show when terminal is wide enough visible: (termWidth) => termWidth >= 100, }, }); }, });
// Test toggle overlay - demonstrates OverlayHandle.setHidden() via onHandle callback pi.registerCommand("overlay-toggle", { description: "Test overlay toggle (press 't' to toggle visibility)", handler: async (_args: string, ctx: ExtensionCommandContext) => { await ctx.ui.custom<void>((tui, theme, _kb, done) => new ToggleDemoComponent(tui, theme, done), { overlay: true, overlayOptions: { anchor: "center", width: 50 }, // onHandle callback provides access to the OverlayHandle for visibility control onHandle: (handle) => { // Store handle globally so component can access it // (In real code, you'd use a more elegant pattern like a store or event emitter) globalToggleHandle = handle; }, }); 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<void>((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<void>((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<void>((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<void> { return new Promise((resolve) => setTimeout(resolve, ms));}
// Base overlay component with common renderingabstract class BaseOverlay { constructor(protected theme: Theme) {}
protected box(lines: string[], width: number, title?: string): string[] { const th = this.theme; const innerW = Math.max(1, width - 2); const result: string[] = [];
const titleStr = title ? truncateToWidth(` ${title} `, innerW) : ""; const titleW = visibleWidth(titleStr); const topLine = "─".repeat(Math.floor((innerW - titleW) / 2)); const topLine2 = "─".repeat(Math.max(0, innerW - titleW - topLine.length)); result.push(th.fg("border", `╭${topLine}`) + th.fg("accent", titleStr) + th.fg("border", `${topLine2}╮`));
for (const line of lines) { result.push(th.fg("border", "│") + truncateToWidth(line, innerW, "...", true) + th.fg("border", "│")); }
result.push(th.fg("border", `╰${"─".repeat(innerW)}╯`)); return result; }
invalidate(): void {} dispose(): void {}}
// Anchor position testclass AnchorTestComponent extends BaseOverlay { constructor( theme: Theme, private anchor: OverlayAnchor, private done: (result: "next" | "confirm" | "cancel") => void, ) { super(theme); }
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.done("cancel"); } else if (matchesKey(data, "return")) { this.done("confirm"); } else if (matchesKey(data, "space") || matchesKey(data, "right")) { this.done("next"); } }
render(width: number): string[] { const th = this.theme; return this.box( [ "", ` Current: ${th.fg("accent", this.anchor)}`, "", ` ${th.fg("dim", "Space/→ = next anchor")}`, ` ${th.fg("dim", "Enter = confirm")}`, ` ${th.fg("dim", "Esc = cancel")}`, "", ], width, "Anchor Test", ); }}
// Margin/offset testclass MarginTestComponent extends BaseOverlay { constructor( theme: Theme, private config: { name: string; options: OverlayOptions }, private done: (result: "next" | "close") => void, ) { super(theme); }
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.done("close"); } else if (matchesKey(data, "space") || matchesKey(data, "right")) { this.done("next"); } }
render(width: number): string[] { const th = this.theme; return this.box( [ "", ` ${th.fg("accent", this.config.name)}`, "", ` ${th.fg("dim", "Space/→ = next config")}`, ` ${th.fg("dim", "Esc = close")}`, "", ], width, "Margin Test", ); }}
// Stacked overlay testclass StackOverlayComponent extends BaseOverlay { constructor( theme: Theme, private num: number, private position: string, private done: (result: string) => void, ) { super(theme); }
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "return")) { this.done(`Overlay ${this.num}`); } }
render(width: number): string[] { const th = this.theme; // Use different colors for each overlay to show stacking const colors = ["error", "success", "accent"] as const; const color = colors[(this.num - 1) % colors.length]!; const innerW = Math.max(1, width - 2); const border = (char: string) => th.fg(color, char); const padLine = (s: string) => truncateToWidth(s, innerW, "...", true); const lines: string[] = [];
lines.push(border(`╭${"─".repeat(innerW)}╮`)); lines.push(border("│") + padLine(` Overlay ${th.fg("accent", `#${this.num}`)}`) + border("│")); lines.push(border("│") + padLine(` Layer: ${th.fg(color, this.position)}`) + border("│")); lines.push(border("│") + padLine("") + border("│")); // Add extra lines to make it taller for (let i = 0; i < 5; i++) { lines.push(border("│") + padLine(` ${"░".repeat(innerW - 2)} `) + border("│")); } lines.push(border("│") + padLine("") + border("│")); lines.push(border("│") + padLine(th.fg("dim", " Press Enter/Esc to close")) + border("│")); lines.push(border(`╰${"─".repeat(innerW)}╯`));
return lines; }}
// Streaming overflow test - spawns real process with colored output (original crash scenario)class StreamingOverflowComponent extends BaseOverlay { private lines: string[] = []; private proc: ReturnType<typeof spawn> | null = null; private scrollOffset = 0; private maxVisibleLines = 15; private finished = false; private disposed = false;
constructor( private tui: TUI, theme: Theme, private done: () => void, ) { super(theme); this.startProcess(); }
private startProcess(): void { // Run a command that produces many lines with ANSI colors // Using find with -ls produces file listings, or use ls --color this.proc = spawn("bash", [ "-c", ` echo "Starting streaming overflow test (30+ seconds)..." echo "This simulates subagent output with colors, hyperlinks, and long paths" echo "" for i in $(seq 1 100); do # Simulate long file paths with OSC 8 hyperlinks (clickable) - tests width overflow DIR="/Users/nicobailon/Documents/development/pi-mono/packages/coding-agent/src/modes/interactive" FILE="\${DIR}/components/very-long-component-name-that-exceeds-width-\${i}.ts" echo -e "\\033]8;;file://\${FILE}\\007▶ read: \${FILE}\\033]8;;\\007"
# Add some colored status messages with long text if [ $((i % 5)) -eq 0 ]; then echo -e " \\033[32m✓ Successfully processed \${i} files in /Users/nicobailon/Documents/development/pi-mono\\033[0m" fi if [ $((i % 7)) -eq 0 ]; then echo -e " \\033[33m⚠ Warning: potential issue detected at line \${i} in very-long-component-name-that-exceeds-width.ts\\033[0m" fi if [ $((i % 11)) -eq 0 ]; then echo -e " \\033[31m✗ Error: file not found /some/really/long/path/that/definitely/exceeds/the/overlay/width/limit/file-\${i}.ts\\033[0m" fi sleep 0.3 done echo "" echo -e "\\033[32m✓ Complete - 100 files processed in 30 seconds\\033[0m" echo "Press Esc to close" `, ]);
this.proc.stdout?.on("data", (data: Buffer) => { if (this.disposed) return; // Guard against callbacks after dispose const text = data.toString(); const newLines = text.split("\n"); for (const line of newLines) { if (line) this.lines.push(line); } // Auto-scroll to bottom this.scrollOffset = Math.max(0, this.lines.length - this.maxVisibleLines); this.tui.requestRender(); });
this.proc.stderr?.on("data", (data: Buffer) => { if (this.disposed) return; // Guard against callbacks after dispose this.lines.push(this.theme.fg("error", data.toString().trim())); this.tui.requestRender(); });
this.proc.on("close", () => { if (this.disposed) return; // Guard against callbacks after dispose this.finished = true; this.tui.requestRender(); }); }
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.proc?.kill(); this.done(); } else if (matchesKey(data, "up")) { this.scrollOffset = Math.max(0, this.scrollOffset - 1); this.tui.requestRender(); // Trigger re-render after scroll } else if (matchesKey(data, "down")) { this.scrollOffset = Math.min(Math.max(0, this.lines.length - this.maxVisibleLines), this.scrollOffset + 1); this.tui.requestRender(); // Trigger re-render after scroll } }
render(width: number): string[] { const th = this.theme; const innerW = Math.max(1, width - 2); const padLine = (s: string) => truncateToWidth(s, innerW, "...", true); const border = (c: string) => th.fg("border", c);
const result: string[] = []; const title = truncateToWidth(` Streaming Output (${this.lines.length} lines) `, innerW); const titlePad = Math.max(0, innerW - visibleWidth(title)); result.push(border("╭") + th.fg("accent", title) + border(`${"─".repeat(titlePad)}╮`));
// Scroll indicators const canScrollUp = this.scrollOffset > 0; const canScrollDown = this.scrollOffset < this.lines.length - this.maxVisibleLines; const scrollInfo = `↑${this.scrollOffset} | ↓${Math.max(0, this.lines.length - this.maxVisibleLines - this.scrollOffset)}`;
result.push( border("│") + padLine(canScrollUp || canScrollDown ? th.fg("dim", ` ${scrollInfo}`) : "") + border("│"), );
// Visible lines - truncate long lines to fit within border const visibleLines = this.lines.slice(this.scrollOffset, this.scrollOffset + this.maxVisibleLines); for (const line of visibleLines) { result.push(border("│") + padLine(` ${line}`) + border("│")); }
// Pad to maxVisibleLines for (let i = visibleLines.length; i < this.maxVisibleLines; i++) { result.push(border("│") + padLine("") + border("│")); }
const status = this.finished ? th.fg("success", "✓ Done") : th.fg("warning", "● Running"); result.push(border("│") + padLine(` ${status} ${th.fg("dim", "| ↑↓ scroll | Esc close")}`) + border("│")); result.push(border(`╰${"─".repeat(innerW)}╯`));
return result; }
dispose(): void { this.disposed = true; this.proc?.kill(); }}
// Edge position testclass EdgeTestComponent extends BaseOverlay { constructor( theme: Theme, private done: () => void, ) { super(theme); }
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.done(); } }
render(width: number): string[] { const th = this.theme; return this.box( [ "", " This overlay is at the", " right edge of terminal.", "", ` ${th.fg("dim", "Verify right border")}`, ` ${th.fg("dim", "aligns with edge.")}`, "", ` ${th.fg("dim", "Press Esc to close")}`, "", ], width, "Edge Test", ); }}
// Percentage positioning testclass PercentTestComponent extends BaseOverlay { constructor( theme: Theme, private config: { name: string; row: number; col: number }, private done: (result: "next" | "close") => void, ) { super(theme); }
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.done("close"); } else if (matchesKey(data, "space") || matchesKey(data, "right")) { this.done("next"); } }
render(width: number): string[] { const th = this.theme; return this.box( [ "", ` ${th.fg("accent", this.config.name)}`, "", ` ${th.fg("dim", "Space/→ = next")}`, ` ${th.fg("dim", "Esc = close")}`, "", ], width, "Percent Test", ); }}
// MaxHeight test - renders 20 lines, truncated to 10 by maxHeightclass MaxHeightTestComponent extends BaseOverlay { constructor( theme: Theme, private done: () => void, ) { super(theme); }
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.done(); } }
render(width: number): string[] { const th = this.theme; // Intentionally render 21 lines - maxHeight: 10 will truncate to first 10 // You should see header + lines 1-6, with bottom border cut off const contentLines: string[] = [ th.fg("warning", " ⚠ Rendering 21 lines, maxHeight: 10"), th.fg("dim", " Lines 11-21 truncated (no bottom border)"), "", ];
for (let i = 1; i <= 14; i++) { contentLines.push(` Line ${i} of 14`); }
contentLines.push("", th.fg("dim", " Press Esc to close"));
return this.box(contentLines, width, "MaxHeight Test"); }}
// Responsive sidepanel - demonstrates percentage width and visibility callbackclass SidepanelComponent extends BaseOverlay { private items = ["Dashboard", "Messages", "Settings", "Help", "About"]; private selectedIndex = 0;
constructor( private tui: TUI, theme: Theme, private done: () => void, ) { super(theme); }
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.done(); } else if (matchesKey(data, "up")) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.tui.requestRender(); } else if (matchesKey(data, "down")) { this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); this.tui.requestRender(); } else if (matchesKey(data, "return")) { // Could trigger an action here this.tui.requestRender(); } }
render(width: number): string[] { const th = this.theme; const innerW = Math.max(1, width - 2); const padLine = (s: string) => truncateToWidth(s, innerW, "...", true); const border = (c: string) => th.fg("border", c); const lines: string[] = [];
// Header lines.push(border(`╭${"─".repeat(innerW)}╮`)); lines.push(border("│") + padLine(th.fg("accent", " Responsive Sidepanel")) + border("│")); lines.push(border("├") + border("─".repeat(innerW)) + border("┤"));
// Menu items for (let i = 0; i < this.items.length; i++) { const item = this.items[i]!; const isSelected = i === this.selectedIndex; const prefix = isSelected ? th.fg("accent", "→ ") : " "; const text = isSelected ? th.fg("accent", item) : item; lines.push(border("│") + padLine(`${prefix}${text}`) + border("│")); }
// Footer with responsive behavior info lines.push(border("├") + border("─".repeat(innerW)) + border("┤")); lines.push(border("│") + padLine(th.fg("warning", " ⚠ Resize terminal < 100 cols")) + border("│")); lines.push(border("│") + padLine(th.fg("warning", " to see panel auto-hide")) + border("│")); lines.push(border("│") + padLine(th.fg("dim", " Uses visible: (w) => w >= 100")) + border("│")); lines.push(border("│") + padLine(th.fg("dim", " ↑↓ navigate | Esc close")) + border("│")); lines.push(border(`╰${"─".repeat(innerW)}╯`));
return lines; }}
// Animation demo - proves overlays can handle real-time updates like pi-doomclass AnimationDemoComponent extends BaseOverlay { private frame = 0; private interval: ReturnType<typeof setInterval> | null = null; private fps = 0; private lastFpsUpdate = Date.now(); private framesSinceLastFps = 0;
constructor( private tui: TUI, theme: Theme, private done: () => void, ) { super(theme); this.startAnimation(); }
private startAnimation(): void { // Run at ~30 FPS (same as DOOM target) this.interval = setInterval(() => { this.frame++; this.framesSinceLastFps++;
// Update FPS counter every second const now = Date.now(); if (now - this.lastFpsUpdate >= 1000) { this.fps = this.framesSinceLastFps; this.framesSinceLastFps = 0; this.lastFpsUpdate = now; }
this.tui.requestRender(); }, 1000 / 30); }
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.dispose(); this.done(); } }
render(width: number): string[] { const th = this.theme; const innerW = Math.max(1, width - 2); const padLine = (s: string) => truncateToWidth(s, innerW, "...", true); const border = (c: string) => th.fg("border", c);
const lines: string[] = []; lines.push(border(`╭${"─".repeat(innerW)}╮`)); lines.push(border("│") + padLine(th.fg("accent", " Animation Demo (~30 FPS)")) + border("│")); lines.push(border("│") + padLine(``) + border("│")); lines.push(border("│") + padLine(` Frame: ${th.fg("accent", String(this.frame))}`) + border("│")); lines.push(border("│") + padLine(` FPS: ${th.fg("success", String(this.fps))}`) + border("│")); lines.push(border("│") + padLine(``) + border("│"));
// Animated content - bouncing bar const barWidth = Math.max(12, innerW - 4); // Ensure enough space for bar const pos = Math.max(0, Math.floor(((Math.sin(this.frame / 10) + 1) * (barWidth - 10)) / 2)); const bar = " ".repeat(pos) + th.fg("accent", "██████████") + " ".repeat(Math.max(0, barWidth - 10 - pos)); lines.push(border("│") + padLine(` ${bar}`) + border("│"));
// Spinning character const spinChars = ["◐", "◓", "◑", "◒"]; const spin = spinChars[this.frame % spinChars.length]; lines.push(border("│") + padLine(` Spinner: ${th.fg("warning", spin!)}`) + border("│"));
// Color cycling const hue = (this.frame * 3) % 360; const rgb = hslToRgb(hue / 360, 0.8, 0.5); const colorBlock = `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m${" ".repeat(10)}\x1b[0m`; lines.push(border("│") + padLine(` Color: ${colorBlock}`) + border("│"));
lines.push(border("│") + padLine(``) + border("│")); lines.push(border("│") + padLine(th.fg("dim", " This proves overlays can handle")) + border("│")); lines.push(border("│") + padLine(th.fg("dim", " real-time game-like rendering.")) + border("│")); lines.push(border("│") + padLine(th.fg("dim", " (pi-doom uses same approach)")) + border("│")); lines.push(border("│") + padLine(``) + border("│")); lines.push(border("│") + padLine(th.fg("dim", " Press Esc to close")) + border("│")); lines.push(border(`╰${"─".repeat(innerW)}╯`));
return lines; }
dispose(): void { if (this.interval) { clearInterval(this.interval); this.interval = null; } }}
// HSL to RGB helper for color cycling animationfunction hslToRgb(h: number, s: number, l: number): [number, number, number] { let r: number, g: number, b: number; if (s === 0) { r = g = b = l; } else { const hue2rgb = (p: number, q: number, t: number) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; r = hue2rgb(p, q, h + 1 / 3); g = hue2rgb(p, q, h); b = hue2rgb(p, q, h - 1 / 3); } return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];}
// Toggle demo - demonstrates OverlayHandle.setHidden() via onHandle callbackclass ToggleDemoComponent extends BaseOverlay { private toggleCount = 0; private isToggling = false;
constructor( private tui: TUI, theme: Theme, private done: () => void, ) { super(theme); }
handleInput(data: string): void { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.done(); } else if (matchesKey(data, "t") && globalToggleHandle && !this.isToggling) { // Demonstrate toggle by hiding for 1 second then showing again // (In real usage, a global keybinding would control visibility) this.isToggling = true; this.toggleCount++; globalToggleHandle.setHidden(true);
// Auto-restore after 1 second to demonstrate the API setTimeout(() => { if (globalToggleHandle) { globalToggleHandle.setHidden(false); this.isToggling = false; this.tui.requestRender(); } }, 1000); } }
render(width: number): string[] { const th = this.theme; return this.box( [ "", th.fg("accent", " Toggle Demo"), "", " This overlay demonstrates the", " onHandle callback API.", "", ` Toggle count: ${th.fg("accent", String(this.toggleCount))}`, "", th.fg("dim", " Press 't' to hide for 1 second"), th.fg("dim", " (demonstrates setHidden API)"), "", th.fg("dim", " In real usage, a global keybinding"), th.fg("dim", " would toggle visibility externally."), "", th.fg("dim", " Press Esc to close"), "", ], width, "Toggle Demo", ); }}
// === Non-capturing passive overlay demo ===
class PassiveDemoController extends BaseOverlay { focused = false; private typed = ""; private timerComponent: TimerPanel; private timerHandle: OverlayHandle | null = null; private interval: ReturnType<typeof setInterval> | 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<typeof setInterval> | 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 {}}