mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 05:02:07 +00:00
feat(tui): overlay positioning API with CSS-like values
Add OverlayOptions for configurable positioning (anchor, margins, offsets, percentages). Add OverlayHandle for programmatic visibility control with hide/setHidden/isHidden. Add visible callback for responsive overlays. Extension API: ctx.ui.custom() now accepts overlayOptions and onHandle callback. Examples: overlay-qa-tests.ts (10 test commands), doom-overlay (DOOM at 35 FPS).
This commit is contained in:
parent
d29f268f46
commit
a4ccff382c
22 changed files with 1344 additions and 103 deletions
|
|
@ -3643,7 +3643,7 @@ export const MODELS = {
|
|||
cacheWrite: 18.75,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 4096,
|
||||
maxTokens: 32000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"anthropic/claude-opus-4.5": {
|
||||
id: "anthropic/claude-opus-4.5",
|
||||
|
|
@ -3660,7 +3660,7 @@ export const MODELS = {
|
|||
cacheWrite: 6.25,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 32000,
|
||||
maxTokens: 64000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"anthropic/claude-sonnet-4": {
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
|
|
@ -3977,13 +3977,13 @@ export const MODELS = {
|
|||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.39999999999999997,
|
||||
output: 1.75,
|
||||
input: 0.44999999999999996,
|
||||
output: 2.1500000000000004,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 163840,
|
||||
maxTokens: 65536,
|
||||
contextWindow: 131072,
|
||||
maxTokens: 32768,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"deepseek/deepseek-r1-distill-llama-70b": {
|
||||
id: "deepseek/deepseek-r1-distill-llama-70b",
|
||||
|
|
@ -4359,23 +4359,6 @@ export const MODELS = {
|
|||
contextWindow: 256000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"kwaipilot/kat-coder-pro:free": {
|
||||
id: "kwaipilot/kat-coder-pro:free",
|
||||
name: "Kwaipilot: KAT-Coder-Pro V1 (free)",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 256000,
|
||||
maxTokens: 128000,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"meta-llama/llama-3-70b-instruct": {
|
||||
id: "meta-llama/llama-3-70b-instruct",
|
||||
name: "Meta: Llama 3 70B Instruct",
|
||||
|
|
@ -4589,13 +4572,13 @@ export const MODELS = {
|
|||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0.28,
|
||||
output: 1.2,
|
||||
cacheRead: 0.14,
|
||||
input: 0.27,
|
||||
output: 1.12,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 196608,
|
||||
maxTokens: 4096,
|
||||
maxTokens: 65536,
|
||||
} satisfies Model<"openai-completions">,
|
||||
"mistralai/codestral-2508": {
|
||||
id: "mistralai/codestral-2508",
|
||||
|
|
|
|||
BIN
packages/ai/test/.temp-images/small.png
Normal file
BIN
packages/ai/test/.temp-images/small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 321 B |
|
|
@ -10,7 +10,9 @@
|
|||
|
||||
- Session naming: `/name <name>` command sets a display name shown in the session selector instead of the first message. Useful for distinguishing forked sessions. Extensions can use `pi.setSessionName()` and `pi.getSessionName()`. ([#650](https://github.com/badlogic/pi-mono/pull/650) by [@scutifer](https://github.com/scutifer))
|
||||
- `ctx.ui.custom()` now accepts `overlayOptions` for overlay positioning and sizing (anchor, margins, offsets, percentages, absolute positioning)
|
||||
- Extension example: `overlay-qa-tests.ts` for testing all overlay positioning scenarios
|
||||
- `ctx.ui.custom()` now accepts `onHandle` callback to receive the `OverlayHandle` for controlling overlay visibility
|
||||
- Extension example: `overlay-qa-tests.ts` with 10 commands for testing overlay positioning, animation, and toggle scenarios
|
||||
- Extension example: `doom-overlay/` - DOOM game running as an overlay at 35 FPS (auto-downloads WAD on first run)
|
||||
- Extension example: `notify.ts` for desktop notifications via OSC 777 escape sequence ([#658](https://github.com/badlogic/pi-mono/pull/658) by [@ferologics](https://github.com/ferologics))
|
||||
- Inline hint for queued messages showing the `Alt+Up` restore shortcut ([#657](https://github.com/badlogic/pi-mono/pull/657) by [@tmustier](https://github.com/tmustier))
|
||||
- Page-up/down navigation in `/resume` session selector to jump by 5 items ([#662](https://github.com/badlogic/pi-mono/pull/662) by [@aliou](https://github.com/aliou))
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ cp permission-gate.ts ~/.pi/agent/extensions/
|
|||
| `modal-editor.ts` | Custom vim-like modal editor via `ctx.ui.setEditorComponent()` |
|
||||
| `notify.ts` | Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm) |
|
||||
| `overlay-test.ts` | Test overlay compositing with inline text inputs and edge cases |
|
||||
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests: anchors, margins, stacking, overflow protection |
|
||||
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests: anchors, margins, stacking, overflow, animation |
|
||||
| `doom-overlay/` | DOOM game running as an overlay at 35 FPS (demonstrates real-time game rendering) |
|
||||
|
||||
### Git Integration
|
||||
|
||||
|
|
|
|||
2
packages/coding-agent/examples/extensions/doom-overlay/.gitignore
vendored
Normal file
2
packages/coding-agent/examples/extensions/doom-overlay/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Auto-downloaded on first run
|
||||
doom1.wad
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# DOOM Overlay Demo
|
||||
|
||||
Play DOOM as an overlay in pi. Demonstrates that the overlay system can handle real-time game rendering at 35 FPS.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pi --extension ./examples/extensions/doom-overlay
|
||||
```
|
||||
|
||||
Then run:
|
||||
```
|
||||
/doom-overlay
|
||||
```
|
||||
|
||||
The shareware WAD file (~4MB) is auto-downloaded on first run.
|
||||
|
||||
## Controls
|
||||
|
||||
| Action | Keys |
|
||||
|--------|------|
|
||||
| Move | WASD or Arrow Keys |
|
||||
| Run | Shift + WASD |
|
||||
| Fire | F or Ctrl |
|
||||
| Use/Open | Space |
|
||||
| Weapons | 1-7 |
|
||||
| Map | Tab |
|
||||
| Menu | Escape |
|
||||
| Pause/Quit | Q |
|
||||
|
||||
## How It Works
|
||||
|
||||
DOOM runs as WebAssembly compiled from [doomgeneric](https://github.com/ozkl/doomgeneric). Each frame is rendered using half-block characters (▀) with 24-bit color, where the top pixel is the foreground color and the bottom pixel is the background color.
|
||||
|
||||
The overlay uses:
|
||||
- `width: "90%"` - 90% of terminal width
|
||||
- `maxHeight: "80%"` - Maximum 80% of terminal height
|
||||
- `anchor: "center"` - Centered in terminal
|
||||
|
||||
Height is calculated from width to maintain DOOM's 3.2:1 aspect ratio (accounting for half-block rendering).
|
||||
|
||||
## Credits
|
||||
|
||||
- [id Software](https://github.com/id-Software/DOOM) for the original DOOM
|
||||
- [doomgeneric](https://github.com/ozkl/doomgeneric) for the portable DOOM implementation
|
||||
- [pi-doom](https://github.com/badlogic/pi-doom) for the original pi integration
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* DOOM Component for overlay mode
|
||||
*
|
||||
* Renders DOOM frames using half-block characters (▀) with 24-bit color.
|
||||
* Height is calculated from width to maintain DOOM's aspect ratio.
|
||||
*/
|
||||
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import { isKeyRelease, type TUI } from "@mariozechner/pi-tui";
|
||||
import type { DoomEngine } from "./doom-engine.js";
|
||||
import { DoomKeys, mapKeyToDoom } from "./doom-keys.js";
|
||||
|
||||
function renderHalfBlock(
|
||||
rgba: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
targetCols: number,
|
||||
targetRows: number,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
const scaleX = width / targetCols;
|
||||
const scaleY = height / (targetRows * 2);
|
||||
|
||||
for (let row = 0; row < targetRows; row++) {
|
||||
let line = "";
|
||||
const srcY1 = Math.floor(row * 2 * scaleY);
|
||||
const srcY2 = Math.floor((row * 2 + 1) * scaleY);
|
||||
|
||||
for (let col = 0; col < targetCols; col++) {
|
||||
const srcX = Math.floor(col * scaleX);
|
||||
const idx1 = (srcY1 * width + srcX) * 4;
|
||||
const idx2 = (srcY2 * width + srcX) * 4;
|
||||
const r1 = rgba[idx1] ?? 0,
|
||||
g1 = rgba[idx1 + 1] ?? 0,
|
||||
b1 = rgba[idx1 + 2] ?? 0;
|
||||
const r2 = rgba[idx2] ?? 0,
|
||||
g2 = rgba[idx2 + 1] ?? 0,
|
||||
b2 = rgba[idx2 + 2] ?? 0;
|
||||
line += `\x1b[38;2;${r1};${g1};${b1}m\x1b[48;2;${r2};${g2};${b2}m▀`;
|
||||
}
|
||||
line += "\x1b[0m";
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export class DoomOverlayComponent implements Component {
|
||||
private engine: DoomEngine;
|
||||
private tui: TUI;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private onExit: () => void;
|
||||
|
||||
// Opt-in to key release events for smooth movement
|
||||
wantsKeyRelease = true;
|
||||
|
||||
constructor(tui: TUI, engine: DoomEngine, onExit: () => void, resume = false) {
|
||||
this.tui = tui;
|
||||
this.engine = engine;
|
||||
this.onExit = onExit;
|
||||
|
||||
// Unpause if resuming
|
||||
if (resume) {
|
||||
this.engine.pushKey(true, DoomKeys.KEY_PAUSE);
|
||||
this.engine.pushKey(false, DoomKeys.KEY_PAUSE);
|
||||
}
|
||||
|
||||
this.startGameLoop();
|
||||
}
|
||||
|
||||
private startGameLoop(): void {
|
||||
this.interval = setInterval(() => {
|
||||
try {
|
||||
this.engine.tick();
|
||||
// Force full re-render to prevent bleed artifacts at high frame rates
|
||||
this.tui.requestRender(true);
|
||||
} catch {
|
||||
// WASM error (e.g., exit via DOOM menu) - treat as quit
|
||||
this.dispose();
|
||||
this.onExit();
|
||||
}
|
||||
}, 1000 / 35);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// Q to pause and exit (but not on release)
|
||||
if (!isKeyRelease(data) && (data === "q" || data === "Q")) {
|
||||
// Send DOOM's pause key before exiting
|
||||
this.engine.pushKey(true, DoomKeys.KEY_PAUSE);
|
||||
this.engine.pushKey(false, DoomKeys.KEY_PAUSE);
|
||||
this.dispose();
|
||||
this.onExit();
|
||||
return;
|
||||
}
|
||||
|
||||
const doomKeys = mapKeyToDoom(data);
|
||||
if (doomKeys.length === 0) return;
|
||||
|
||||
const released = isKeyRelease(data);
|
||||
|
||||
for (const key of doomKeys) {
|
||||
this.engine.pushKey(!released, key);
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// DOOM renders at 640x400 (1.6:1 ratio)
|
||||
// With half-block characters, each terminal row = 2 pixels
|
||||
// So effective ratio is 640:200 = 3.2:1 (width:height in terminal cells)
|
||||
// Add 1 row for footer
|
||||
const ASPECT_RATIO = 3.2;
|
||||
const MIN_HEIGHT = 10;
|
||||
const height = Math.max(MIN_HEIGHT, Math.floor(width / ASPECT_RATIO));
|
||||
|
||||
const rgba = this.engine.getFrameRGBA();
|
||||
const lines = renderHalfBlock(rgba, this.engine.width, this.engine.height, width, height);
|
||||
|
||||
// Footer
|
||||
const footer = " DOOM | Q=Pause | WASD=Move | Shift+WASD=Run | Space=Use | F=Fire | 1-7=Weapons";
|
||||
const truncatedFooter = footer.length > width ? footer.slice(0, width) : footer;
|
||||
lines.push(`\x1b[2m${truncatedFooter}\x1b[0m`);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
dispose(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* DOOM Engine - WebAssembly wrapper for doomgeneric
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export interface DoomModule {
|
||||
_doomgeneric_Create: (argc: number, argv: number) => void;
|
||||
_doomgeneric_Tick: () => void;
|
||||
_DG_GetFrameBuffer: () => number;
|
||||
_DG_GetScreenWidth: () => number;
|
||||
_DG_GetScreenHeight: () => number;
|
||||
_DG_PushKeyEvent: (pressed: number, key: number) => void;
|
||||
_malloc: (size: number) => number;
|
||||
_free: (ptr: number) => void;
|
||||
HEAPU8: Uint8Array;
|
||||
HEAPU32: Uint32Array;
|
||||
FS_createDataFile: (parent: string, name: string, data: number[], canRead: boolean, canWrite: boolean) => void;
|
||||
FS_createPath: (parent: string, path: string, canRead: boolean, canWrite: boolean) => string;
|
||||
setValue: (ptr: number, value: number, type: string) => void;
|
||||
getValue: (ptr: number, type: string) => number;
|
||||
}
|
||||
|
||||
export class DoomEngine {
|
||||
private module: DoomModule | null = null;
|
||||
private frameBufferPtr: number = 0;
|
||||
private initialized = false;
|
||||
private wadPath: string;
|
||||
private _width = 640;
|
||||
private _height = 400;
|
||||
|
||||
constructor(wadPath: string) {
|
||||
this.wadPath = wadPath;
|
||||
}
|
||||
|
||||
get width(): number {
|
||||
return this._width;
|
||||
}
|
||||
|
||||
get height(): number {
|
||||
return this._height;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
// Locate WASM build
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const buildDir = join(__dirname, "doom", "build");
|
||||
const doomJsPath = join(buildDir, "doom.js");
|
||||
|
||||
if (!existsSync(doomJsPath)) {
|
||||
throw new Error(`WASM not found at ${doomJsPath}. Run ./doom/build.sh first`);
|
||||
}
|
||||
|
||||
// Read WAD file
|
||||
const wadData = readFileSync(this.wadPath);
|
||||
const wadArray = Array.from(new Uint8Array(wadData));
|
||||
|
||||
// Load WASM module - eval to bypass jiti completely
|
||||
const doomJsCode = readFileSync(doomJsPath, "utf-8");
|
||||
const moduleExports: { exports: unknown } = { exports: {} };
|
||||
const nativeRequire = createRequire(doomJsPath);
|
||||
const moduleFunc = new Function("module", "exports", "__dirname", "__filename", "require", doomJsCode);
|
||||
moduleFunc(moduleExports, moduleExports.exports, buildDir, doomJsPath, nativeRequire);
|
||||
const createDoomModule = moduleExports.exports as (config: unknown) => Promise<DoomModule>;
|
||||
|
||||
const moduleConfig = {
|
||||
locateFile: (path: string) => {
|
||||
if (path.endsWith(".wasm")) {
|
||||
return join(buildDir, path);
|
||||
}
|
||||
return path;
|
||||
},
|
||||
print: () => {},
|
||||
printErr: () => {},
|
||||
preRun: [
|
||||
(module: DoomModule) => {
|
||||
// Create /doom directory and add WAD
|
||||
module.FS_createPath("/", "doom", true, true);
|
||||
module.FS_createDataFile("/doom", "doom1.wad", wadArray, true, false);
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
this.module = await createDoomModule(moduleConfig);
|
||||
if (!this.module) {
|
||||
throw new Error("Failed to initialize DOOM module");
|
||||
}
|
||||
|
||||
// Initialize DOOM
|
||||
this.initDoom();
|
||||
|
||||
// Get framebuffer info
|
||||
this.frameBufferPtr = this.module._DG_GetFrameBuffer();
|
||||
this._width = this.module._DG_GetScreenWidth();
|
||||
this._height = this.module._DG_GetScreenHeight();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private initDoom(): void {
|
||||
if (!this.module) return;
|
||||
|
||||
const args = ["doom", "-iwad", "/doom/doom1.wad"];
|
||||
const argPtrs: number[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
const ptr = this.module._malloc(arg.length + 1);
|
||||
for (let i = 0; i < arg.length; i++) {
|
||||
this.module.setValue(ptr + i, arg.charCodeAt(i), "i8");
|
||||
}
|
||||
this.module.setValue(ptr + arg.length, 0, "i8");
|
||||
argPtrs.push(ptr);
|
||||
}
|
||||
|
||||
const argvPtr = this.module._malloc(argPtrs.length * 4);
|
||||
for (let i = 0; i < argPtrs.length; i++) {
|
||||
this.module.setValue(argvPtr + i * 4, argPtrs[i]!, "i32");
|
||||
}
|
||||
|
||||
this.module._doomgeneric_Create(args.length, argvPtr);
|
||||
|
||||
for (const ptr of argPtrs) {
|
||||
this.module._free(ptr);
|
||||
}
|
||||
this.module._free(argvPtr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one game tick
|
||||
*/
|
||||
tick(): void {
|
||||
if (!this.module || !this.initialized) return;
|
||||
this.module._doomgeneric_Tick();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current frame as RGBA pixel data
|
||||
* DOOM outputs ARGB, we convert to RGBA
|
||||
*/
|
||||
getFrameRGBA(): Uint8Array {
|
||||
if (!this.module || !this.initialized) {
|
||||
return new Uint8Array(this._width * this._height * 4);
|
||||
}
|
||||
|
||||
const pixels = this._width * this._height;
|
||||
const buffer = new Uint8Array(pixels * 4);
|
||||
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const argb = this.module.getValue(this.frameBufferPtr + i * 4, "i32");
|
||||
const offset = i * 4;
|
||||
buffer[offset + 0] = (argb >> 16) & 0xff; // R
|
||||
buffer[offset + 1] = (argb >> 8) & 0xff; // G
|
||||
buffer[offset + 2] = argb & 0xff; // B
|
||||
buffer[offset + 3] = 255; // A
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a key event
|
||||
*/
|
||||
pushKey(pressed: boolean, key: number): void {
|
||||
if (!this.module || !this.initialized) return;
|
||||
this.module._DG_PushKeyEvent(pressed ? 1 : 0, key);
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* DOOM key codes (from doomkeys.h)
|
||||
*/
|
||||
export const DoomKeys = {
|
||||
KEY_RIGHTARROW: 0xae,
|
||||
KEY_LEFTARROW: 0xac,
|
||||
KEY_UPARROW: 0xad,
|
||||
KEY_DOWNARROW: 0xaf,
|
||||
KEY_STRAFE_L: 0xa0,
|
||||
KEY_STRAFE_R: 0xa1,
|
||||
KEY_USE: 0xa2,
|
||||
KEY_FIRE: 0xa3,
|
||||
KEY_ESCAPE: 27,
|
||||
KEY_ENTER: 13,
|
||||
KEY_TAB: 9,
|
||||
KEY_F1: 0x80 + 0x3b,
|
||||
KEY_F2: 0x80 + 0x3c,
|
||||
KEY_F3: 0x80 + 0x3d,
|
||||
KEY_F4: 0x80 + 0x3e,
|
||||
KEY_F5: 0x80 + 0x3f,
|
||||
KEY_F6: 0x80 + 0x40,
|
||||
KEY_F7: 0x80 + 0x41,
|
||||
KEY_F8: 0x80 + 0x42,
|
||||
KEY_F9: 0x80 + 0x43,
|
||||
KEY_F10: 0x80 + 0x44,
|
||||
KEY_F11: 0x80 + 0x57,
|
||||
KEY_F12: 0x80 + 0x58,
|
||||
KEY_BACKSPACE: 127,
|
||||
KEY_PAUSE: 0xff,
|
||||
KEY_EQUALS: 0x3d,
|
||||
KEY_MINUS: 0x2d,
|
||||
KEY_RSHIFT: 0x80 + 0x36,
|
||||
KEY_RCTRL: 0x80 + 0x1d,
|
||||
KEY_RALT: 0x80 + 0x38,
|
||||
} as const;
|
||||
|
||||
import { Key, matchesKey, parseKey } from "@mariozechner/pi-tui";
|
||||
|
||||
/**
|
||||
* Map terminal key input to DOOM key codes
|
||||
* Supports both raw terminal input and Kitty protocol sequences
|
||||
*/
|
||||
export function mapKeyToDoom(data: string): number[] {
|
||||
// Arrow keys
|
||||
if (matchesKey(data, Key.up)) return [DoomKeys.KEY_UPARROW];
|
||||
if (matchesKey(data, Key.down)) return [DoomKeys.KEY_DOWNARROW];
|
||||
if (matchesKey(data, Key.right)) return [DoomKeys.KEY_RIGHTARROW];
|
||||
if (matchesKey(data, Key.left)) return [DoomKeys.KEY_LEFTARROW];
|
||||
|
||||
// WASD - check both raw char and Kitty sequences
|
||||
if (data === "w" || matchesKey(data, "w")) return [DoomKeys.KEY_UPARROW];
|
||||
if (data === "W" || matchesKey(data, Key.shift("w"))) return [DoomKeys.KEY_UPARROW, DoomKeys.KEY_RSHIFT];
|
||||
if (data === "s" || matchesKey(data, "s")) return [DoomKeys.KEY_DOWNARROW];
|
||||
if (data === "S" || matchesKey(data, Key.shift("s"))) return [DoomKeys.KEY_DOWNARROW, DoomKeys.KEY_RSHIFT];
|
||||
if (data === "a" || matchesKey(data, "a")) return [DoomKeys.KEY_STRAFE_L];
|
||||
if (data === "A" || matchesKey(data, Key.shift("a"))) return [DoomKeys.KEY_STRAFE_L, DoomKeys.KEY_RSHIFT];
|
||||
if (data === "d" || matchesKey(data, "d")) return [DoomKeys.KEY_STRAFE_R];
|
||||
if (data === "D" || matchesKey(data, Key.shift("d"))) return [DoomKeys.KEY_STRAFE_R, DoomKeys.KEY_RSHIFT];
|
||||
|
||||
// Fire - F key
|
||||
if (data === "f" || data === "F" || matchesKey(data, "f") || matchesKey(data, Key.shift("f"))) {
|
||||
return [DoomKeys.KEY_FIRE];
|
||||
}
|
||||
|
||||
// Use/Open
|
||||
if (data === " " || matchesKey(data, Key.space)) return [DoomKeys.KEY_USE];
|
||||
|
||||
// Menu/UI keys
|
||||
if (matchesKey(data, Key.enter)) return [DoomKeys.KEY_ENTER];
|
||||
if (matchesKey(data, Key.escape)) return [DoomKeys.KEY_ESCAPE];
|
||||
if (matchesKey(data, Key.tab)) return [DoomKeys.KEY_TAB];
|
||||
if (matchesKey(data, Key.backspace)) return [DoomKeys.KEY_BACKSPACE];
|
||||
|
||||
// Ctrl keys (except Ctrl+C) = fire (legacy support)
|
||||
const parsed = parseKey(data);
|
||||
if (parsed?.startsWith("ctrl+") && parsed !== "ctrl+c") {
|
||||
return [DoomKeys.KEY_FIRE];
|
||||
}
|
||||
if (data.length === 1 && data.charCodeAt(0) < 32 && data !== "\x03") {
|
||||
return [DoomKeys.KEY_FIRE];
|
||||
}
|
||||
|
||||
// Weapon selection (0-9)
|
||||
if (data >= "0" && data <= "9") return [data.charCodeAt(0)];
|
||||
|
||||
// Plus/minus for screen size
|
||||
if (data === "+" || data === "=") return [DoomKeys.KEY_EQUALS];
|
||||
if (data === "-") return [DoomKeys.KEY_MINUS];
|
||||
|
||||
// Y/N for prompts
|
||||
if (data === "y" || data === "Y" || matchesKey(data, "y") || matchesKey(data, Key.shift("y"))) {
|
||||
return ["y".charCodeAt(0)];
|
||||
}
|
||||
if (data === "n" || data === "N" || matchesKey(data, "n") || matchesKey(data, Key.shift("n"))) {
|
||||
return ["n".charCodeAt(0)];
|
||||
}
|
||||
|
||||
// Other printable characters (for cheats)
|
||||
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
return [data.toLowerCase().charCodeAt(0)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
152
packages/coding-agent/examples/extensions/doom-overlay/doom/build.sh
Executable file
152
packages/coding-agent/examples/extensions/doom-overlay/doom/build.sh
Executable file
|
|
@ -0,0 +1,152 @@
|
|||
#!/bin/bash
|
||||
# Build DOOM for pi-doom using doomgeneric and Emscripten
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
DOOM_DIR="$PROJECT_ROOT/doom"
|
||||
BUILD_DIR="$PROJECT_ROOT/doom/build"
|
||||
|
||||
echo "=== pi-doom Build Script ==="
|
||||
|
||||
# Check for emcc
|
||||
if ! command -v emcc &> /dev/null; then
|
||||
echo "Error: Emscripten (emcc) not found!"
|
||||
echo ""
|
||||
echo "Install via Homebrew:"
|
||||
echo " brew install emscripten"
|
||||
echo ""
|
||||
echo "Or manually:"
|
||||
echo " git clone https://github.com/emscripten-core/emsdk.git ~/emsdk"
|
||||
echo " cd ~/emsdk && ./emsdk install latest && ./emsdk activate latest"
|
||||
echo " source ~/emsdk/emsdk_env.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clone doomgeneric if not present
|
||||
if [ ! -d "$DOOM_DIR/doomgeneric" ]; then
|
||||
echo "Cloning doomgeneric..."
|
||||
cd "$DOOM_DIR"
|
||||
git clone https://github.com/ozkl/doomgeneric.git
|
||||
fi
|
||||
|
||||
# Create build directory
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# Copy our platform file
|
||||
cp "$DOOM_DIR/doomgeneric_pi.c" "$DOOM_DIR/doomgeneric/doomgeneric/"
|
||||
|
||||
echo "Compiling DOOM to WebAssembly..."
|
||||
cd "$DOOM_DIR/doomgeneric/doomgeneric"
|
||||
|
||||
# Resolution - 640x400 is doomgeneric default, good balance of speed/quality
|
||||
RESX=${DOOM_RESX:-640}
|
||||
RESY=${DOOM_RESY:-400}
|
||||
|
||||
echo "Resolution: ${RESX}x${RESY}"
|
||||
|
||||
# Compile with Emscripten (no sound)
|
||||
emcc -O2 \
|
||||
-s WASM=1 \
|
||||
-s EXPORTED_FUNCTIONS="['_doomgeneric_Create','_doomgeneric_Tick','_DG_GetFrameBuffer','_DG_GetScreenWidth','_DG_GetScreenHeight','_DG_PushKeyEvent','_malloc','_free']" \
|
||||
-s EXPORTED_RUNTIME_METHODS="['ccall','cwrap','getValue','setValue','FS']" \
|
||||
-s ALLOW_MEMORY_GROWTH=1 \
|
||||
-s INITIAL_MEMORY=33554432 \
|
||||
-s MODULARIZE=1 \
|
||||
-s EXPORT_NAME="createDoomModule" \
|
||||
-s ENVIRONMENT='node' \
|
||||
-s FILESYSTEM=1 \
|
||||
-s FORCE_FILESYSTEM=1 \
|
||||
-s EXIT_RUNTIME=0 \
|
||||
-s NO_EXIT_RUNTIME=1 \
|
||||
-DDOOMGENERIC_RESX=$RESX \
|
||||
-DDOOMGENERIC_RESY=$RESY \
|
||||
-I. \
|
||||
am_map.c \
|
||||
d_event.c \
|
||||
d_items.c \
|
||||
d_iwad.c \
|
||||
d_loop.c \
|
||||
d_main.c \
|
||||
d_mode.c \
|
||||
d_net.c \
|
||||
doomdef.c \
|
||||
doomgeneric.c \
|
||||
doomgeneric_pi.c \
|
||||
doomstat.c \
|
||||
dstrings.c \
|
||||
f_finale.c \
|
||||
f_wipe.c \
|
||||
g_game.c \
|
||||
hu_lib.c \
|
||||
hu_stuff.c \
|
||||
i_cdmus.c \
|
||||
i_input.c \
|
||||
i_endoom.c \
|
||||
i_joystick.c \
|
||||
i_scale.c \
|
||||
i_sound.c \
|
||||
i_system.c \
|
||||
i_timer.c \
|
||||
i_video.c \
|
||||
icon.c \
|
||||
info.c \
|
||||
m_argv.c \
|
||||
m_bbox.c \
|
||||
m_cheat.c \
|
||||
m_config.c \
|
||||
m_controls.c \
|
||||
m_fixed.c \
|
||||
m_menu.c \
|
||||
m_misc.c \
|
||||
m_random.c \
|
||||
memio.c \
|
||||
p_ceilng.c \
|
||||
p_doors.c \
|
||||
p_enemy.c \
|
||||
p_floor.c \
|
||||
p_inter.c \
|
||||
p_lights.c \
|
||||
p_map.c \
|
||||
p_maputl.c \
|
||||
p_mobj.c \
|
||||
p_plats.c \
|
||||
p_pspr.c \
|
||||
p_saveg.c \
|
||||
p_setup.c \
|
||||
p_sight.c \
|
||||
p_spec.c \
|
||||
p_switch.c \
|
||||
p_telept.c \
|
||||
p_tick.c \
|
||||
p_user.c \
|
||||
r_bsp.c \
|
||||
r_data.c \
|
||||
r_draw.c \
|
||||
r_main.c \
|
||||
r_plane.c \
|
||||
r_segs.c \
|
||||
r_sky.c \
|
||||
r_things.c \
|
||||
s_sound.c \
|
||||
sha1.c \
|
||||
sounds.c \
|
||||
st_lib.c \
|
||||
st_stuff.c \
|
||||
statdump.c \
|
||||
tables.c \
|
||||
v_video.c \
|
||||
w_checksum.c \
|
||||
w_file.c \
|
||||
w_file_stdc.c \
|
||||
w_main.c \
|
||||
w_wad.c \
|
||||
wi_stuff.c \
|
||||
z_zone.c \
|
||||
dummy.c \
|
||||
-o "$BUILD_DIR/doom.js"
|
||||
|
||||
echo ""
|
||||
echo "Build complete!"
|
||||
echo "Output: $BUILD_DIR/doom.js and $BUILD_DIR/doom.wasm"
|
||||
File diff suppressed because one or more lines are too long
BIN
packages/coding-agent/examples/extensions/doom-overlay/doom/build/doom.wasm
Executable file
BIN
packages/coding-agent/examples/extensions/doom-overlay/doom/build/doom.wasm
Executable file
Binary file not shown.
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* pi-doom platform implementation for doomgeneric
|
||||
*
|
||||
* Minimal implementation - no sound, just framebuffer and input.
|
||||
*/
|
||||
|
||||
#include "doomgeneric.h"
|
||||
#include "doomkeys.h"
|
||||
#include <emscripten.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// Key event queue
|
||||
#define KEY_QUEUE_SIZE 256
|
||||
static struct {
|
||||
int pressed;
|
||||
unsigned char key;
|
||||
} key_queue[KEY_QUEUE_SIZE];
|
||||
static int key_queue_read = 0;
|
||||
static int key_queue_write = 0;
|
||||
|
||||
// Get the framebuffer pointer for JS to read
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
uint32_t *DG_GetFrameBuffer(void) { return DG_ScreenBuffer; }
|
||||
|
||||
// Get framebuffer dimensions
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int DG_GetScreenWidth(void) { return DOOMGENERIC_RESX; }
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int DG_GetScreenHeight(void) { return DOOMGENERIC_RESY; }
|
||||
|
||||
// Push a key event from JavaScript
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
void DG_PushKeyEvent(int pressed, unsigned char key) {
|
||||
int next_write = (key_queue_write + 1) % KEY_QUEUE_SIZE;
|
||||
if (next_write != key_queue_read) {
|
||||
key_queue[key_queue_write].pressed = pressed;
|
||||
key_queue[key_queue_write].key = key;
|
||||
key_queue_write = next_write;
|
||||
}
|
||||
}
|
||||
|
||||
void DG_Init(void) {
|
||||
// Nothing to initialize
|
||||
}
|
||||
|
||||
void DG_DrawFrame(void) {
|
||||
// Frame is in DG_ScreenBuffer, JS reads via DG_GetFrameBuffer
|
||||
}
|
||||
|
||||
void DG_SleepMs(uint32_t ms) {
|
||||
// No-op - JS handles timing
|
||||
(void)ms;
|
||||
}
|
||||
|
||||
uint32_t DG_GetTicksMs(void) {
|
||||
return (uint32_t)emscripten_get_now();
|
||||
}
|
||||
|
||||
int DG_GetKey(int *pressed, unsigned char *key) {
|
||||
if (key_queue_read != key_queue_write) {
|
||||
*pressed = key_queue[key_queue_read].pressed;
|
||||
*key = key_queue[key_queue_read].key;
|
||||
key_queue_read = (key_queue_read + 1) % KEY_QUEUE_SIZE;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void DG_SetWindowTitle(const char *title) {
|
||||
(void)title;
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* DOOM Overlay Demo - Play DOOM as an overlay
|
||||
*
|
||||
* Usage: pi --extension ./examples/extensions/doom-overlay
|
||||
*
|
||||
* Commands:
|
||||
* /doom-overlay - Play DOOM in an overlay (Q to pause/exit)
|
||||
*
|
||||
* This demonstrates that overlays can handle real-time game rendering at 35 FPS.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { DoomOverlayComponent } from "./doom-component.js";
|
||||
import { DoomEngine } from "./doom-engine.js";
|
||||
import { ensureWadFile } from "./wad-finder.js";
|
||||
|
||||
// Persistent engine instance - survives between invocations
|
||||
let activeEngine: DoomEngine | null = null;
|
||||
let activeWadPath: string | null = null;
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("doom-overlay", {
|
||||
description: "Play DOOM as an overlay. Q to pause and exit.",
|
||||
|
||||
handler: async (args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("DOOM requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-download WAD if not present
|
||||
ctx.ui.notify("Loading DOOM...", "info");
|
||||
const wad = args?.trim() ? args.trim() : await ensureWadFile();
|
||||
|
||||
if (!wad) {
|
||||
ctx.ui.notify("Failed to download DOOM WAD file. Check your internet connection.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Reuse existing engine if same WAD, otherwise create new
|
||||
let isResume = false;
|
||||
if (activeEngine && activeWadPath === wad) {
|
||||
ctx.ui.notify("Resuming DOOM...", "info");
|
||||
isResume = true;
|
||||
} else {
|
||||
ctx.ui.notify(`Loading DOOM from ${wad}...`, "info");
|
||||
activeEngine = new DoomEngine(wad);
|
||||
await activeEngine.init();
|
||||
activeWadPath = wad;
|
||||
}
|
||||
|
||||
await ctx.ui.custom(
|
||||
(tui, _theme, _keybindings, done) => {
|
||||
return new DoomOverlayComponent(tui, activeEngine!, () => done(undefined), isResume);
|
||||
},
|
||||
{
|
||||
overlay: true,
|
||||
overlayOptions: {
|
||||
width: "75%",
|
||||
maxHeight: "95%",
|
||||
anchor: "center",
|
||||
margin: { top: 1 },
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
ctx.ui.notify(`Failed to load DOOM: ${error}`, "error");
|
||||
activeEngine = null;
|
||||
activeWadPath = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Get the bundled WAD path (relative to this module)
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BUNDLED_WAD = join(__dirname, "doom1.wad");
|
||||
const WAD_URL = "https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad";
|
||||
|
||||
const DEFAULT_WAD_PATHS = ["./doom1.wad", "./DOOM1.WAD", "~/doom1.wad", "~/.doom/doom1.wad"];
|
||||
|
||||
export function findWadFile(customPath?: string): string | null {
|
||||
if (customPath) {
|
||||
const resolved = resolve(customPath.replace(/^~/, process.env.HOME || ""));
|
||||
if (existsSync(resolved)) return resolved;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check bundled WAD first
|
||||
if (existsSync(BUNDLED_WAD)) {
|
||||
return BUNDLED_WAD;
|
||||
}
|
||||
|
||||
// Fall back to default paths
|
||||
for (const p of DEFAULT_WAD_PATHS) {
|
||||
const resolved = resolve(p.replace(/^~/, process.env.HOME || ""));
|
||||
if (existsSync(resolved)) return resolved;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Download the shareware WAD if not present. Returns path or null on failure. */
|
||||
export async function ensureWadFile(): Promise<string | null> {
|
||||
// Check if already exists
|
||||
const existing = findWadFile();
|
||||
if (existing) return existing;
|
||||
|
||||
// Download to bundled location
|
||||
try {
|
||||
const response = await fetch(WAD_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
writeFileSync(BUNDLED_WAD, Buffer.from(buffer));
|
||||
return BUNDLED_WAD;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
* 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
|
||||
|
|
@ -11,14 +12,30 @@
|
|||
* /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)
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import type { OverlayAnchor, OverlayOptions, TUI } from "@mariozechner/pi-tui";
|
||||
import type { OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@mariozechner/pi-tui";
|
||||
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/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",
|
||||
|
|
@ -178,8 +195,8 @@ export default function (pi: ExtensionAPI) {
|
|||
overlay: true,
|
||||
overlayOptions: {
|
||||
width: 30,
|
||||
rowPercent: config.row,
|
||||
colPercent: config.col,
|
||||
row: `${config.row}%`,
|
||||
col: `${config.col}%`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -203,6 +220,42 @@ export default function (pi: ExtensionAPI) {
|
|||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
|
|
@ -215,7 +268,7 @@ abstract class BaseOverlay {
|
|||
|
||||
protected box(lines: string[], width: number, title?: string): string[] {
|
||||
const th = this.theme;
|
||||
const innerW = width - 2;
|
||||
const innerW = Math.max(1, width - 2);
|
||||
const result: string[] = [];
|
||||
|
||||
const titleStr = title ? truncateToWidth(` ${title} `, innerW) : "";
|
||||
|
|
@ -331,7 +384,7 @@ class StackOverlayComponent extends BaseOverlay {
|
|||
// 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 = width - 2;
|
||||
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[] = [];
|
||||
|
|
@ -443,7 +496,7 @@ class StreamingOverflowComponent extends BaseOverlay {
|
|||
|
||||
render(width: number): string[] {
|
||||
const th = this.theme;
|
||||
const innerW = width - 2;
|
||||
const innerW = Math.max(1, width - 2);
|
||||
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
|
||||
const border = (c: string) => th.fg("border", c);
|
||||
|
||||
|
|
@ -589,3 +642,240 @@ class MaxHeightTestComponent extends BaseOverlay {
|
|||
return this.box(contentLines, width, "MaxHeight Test");
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive sidepanel - demonstrates percentage width and visibility callback
|
||||
class 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-doom
|
||||
class 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 animation
|
||||
function 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 callback
|
||||
class 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,15 @@ import type {
|
|||
ThinkingLevel,
|
||||
} from "@mariozechner/pi-agent-core";
|
||||
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import type { Component, EditorComponent, EditorTheme, KeyId, OverlayOptions, TUI } from "@mariozechner/pi-tui";
|
||||
import type {
|
||||
Component,
|
||||
EditorComponent,
|
||||
EditorTheme,
|
||||
KeyId,
|
||||
OverlayHandle,
|
||||
OverlayOptions,
|
||||
TUI,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme.js";
|
||||
import type { BashResult } from "../bash-executor.js";
|
||||
|
|
@ -116,6 +124,8 @@ export interface ExtensionUIContext {
|
|||
overlay?: boolean;
|
||||
/** Overlay positioning/sizing options. Can be static or a function for dynamic updates. */
|
||||
overlayOptions?: OverlayOptions | (() => OverlayOptions);
|
||||
/** Called with the overlay handle after the overlay is shown. Use to control visibility. */
|
||||
onHandle?: (handle: OverlayHandle) => void;
|
||||
},
|
||||
): Promise<T>;
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
EditorComponent,
|
||||
EditorTheme,
|
||||
KeyId,
|
||||
OverlayHandle,
|
||||
OverlayOptions,
|
||||
SlashCommand,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
|
@ -1260,7 +1261,11 @@ export class InteractiveMode {
|
|||
keybindings: KeybindingsManager,
|
||||
done: (result: T) => void,
|
||||
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
|
||||
options?: { overlay?: boolean; overlayOptions?: OverlayOptions | (() => OverlayOptions) },
|
||||
options?: {
|
||||
overlay?: boolean;
|
||||
overlayOptions?: OverlayOptions | (() => OverlayOptions);
|
||||
onHandle?: (handle: OverlayHandle) => void;
|
||||
},
|
||||
): Promise<T> {
|
||||
const savedText = this.editor.getText();
|
||||
const isOverlay = options?.overlay ?? false;
|
||||
|
|
@ -1309,7 +1314,9 @@ export class InteractiveMode {
|
|||
const w = (component as { width?: number }).width;
|
||||
return w ? { width: w } : undefined;
|
||||
};
|
||||
this.ui.showOverlay(component, resolveOptions());
|
||||
const handle = this.ui.showOverlay(component, resolveOptions());
|
||||
// Expose handle to caller for visibility control
|
||||
options?.onHandle?.(handle);
|
||||
} else {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(component);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@
|
|||
|
||||
- `SettingsListOptions` with `enableSearch` for fuzzy filtering in `SettingsList` ([#643](https://github.com/badlogic/pi-mono/pull/643) by [@ninlds](https://github.com/ninlds))
|
||||
- `pageUp` and `pageDown` key support with `selectPageUp`/`selectPageDown` editor actions ([#662](https://github.com/badlogic/pi-mono/pull/662) by [@aliou](https://github.com/aliou))
|
||||
- `OverlayOptions` API for overlay positioning and sizing: `width`, `widthPercent`, `minWidth`, `maxHeight`, `maxHeightPercent`, `anchor`, `offsetX`, `offsetY`, `rowPercent`, `colPercent`, `row`, `col`, `margin`
|
||||
- New exported types: `OverlayAnchor`, `OverlayMargin`, `OverlayOptions`
|
||||
- `OverlayOptions` API for overlay positioning and sizing with CSS-like values: `width`, `maxHeight`, `row`, `col` accept numbers (absolute) or percentage strings (e.g., `"50%"`). Also supports `minWidth`, `anchor`, `offsetX`, `offsetY`, `margin`.
|
||||
- `OverlayOptions.visible` callback for responsive overlays - receives terminal dimensions, return false to hide
|
||||
- `showOverlay()` now returns `OverlayHandle` with `hide()`, `setHidden(boolean)`, `isHidden()` for programmatic visibility control
|
||||
- New exported types: `OverlayAnchor`, `OverlayHandle`, `OverlayMargin`, `OverlayOptions`, `SizeValue`
|
||||
- `truncateToWidth()` now accepts optional `pad` parameter to pad result with spaces to exactly `maxWidth`
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -76,8 +76,10 @@ export {
|
|||
type Component,
|
||||
Container,
|
||||
type OverlayAnchor,
|
||||
type OverlayHandle,
|
||||
type OverlayMargin,
|
||||
type OverlayOptions,
|
||||
type SizeValue,
|
||||
TUI,
|
||||
} from "./tui.js";
|
||||
// Utilities
|
||||
|
|
|
|||
|
|
@ -65,23 +65,33 @@ export interface OverlayMargin {
|
|||
left?: number;
|
||||
}
|
||||
|
||||
/** Value that can be absolute (number) or percentage (string like "50%") */
|
||||
export type SizeValue = number | `${number}%`;
|
||||
|
||||
/** Parse a SizeValue into absolute value given a reference size */
|
||||
function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value === "number") return value;
|
||||
// Parse percentage string like "50%"
|
||||
const match = value.match(/^(\d+(?:\.\d+)?)%$/);
|
||||
if (match) {
|
||||
return Math.floor((referenceSize * parseFloat(match[1])) / 100);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for overlay positioning and sizing
|
||||
* Options for overlay positioning and sizing.
|
||||
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
||||
*/
|
||||
export interface OverlayOptions {
|
||||
// === Sizing (absolute) ===
|
||||
/** Fixed width in columns */
|
||||
width?: number;
|
||||
// === Sizing ===
|
||||
/** Width in columns, or percentage of terminal width (e.g., "50%") */
|
||||
width?: SizeValue;
|
||||
/** Minimum width in columns */
|
||||
minWidth?: number;
|
||||
/** Maximum height in rows */
|
||||
maxHeight?: number;
|
||||
|
||||
// === Sizing (relative to terminal) ===
|
||||
/** Width as percentage of terminal width (0-100) */
|
||||
widthPercent?: number;
|
||||
/** Maximum height as percentage of terminal height (0-100) */
|
||||
maxHeightPercent?: number;
|
||||
/** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
|
||||
maxHeight?: SizeValue;
|
||||
|
||||
// === Positioning - anchor-based ===
|
||||
/** Anchor point for positioning (default: 'center') */
|
||||
|
|
@ -91,21 +101,35 @@ export interface OverlayOptions {
|
|||
/** Vertical offset from anchor position (positive = down) */
|
||||
offsetY?: number;
|
||||
|
||||
// === Positioning - percentage-based (alternative to anchor) ===
|
||||
/** Vertical position as percentage (0 = top, 100 = bottom) */
|
||||
rowPercent?: number;
|
||||
/** Horizontal position as percentage (0 = left, 100 = right) */
|
||||
colPercent?: number;
|
||||
|
||||
// === Positioning - absolute (low-level) ===
|
||||
/** Absolute row position (overrides anchor/percent) */
|
||||
row?: number;
|
||||
/** Absolute column position (overrides anchor/percent) */
|
||||
col?: number;
|
||||
// === Positioning - percentage or absolute ===
|
||||
/** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
|
||||
row?: SizeValue;
|
||||
/** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
|
||||
col?: SizeValue;
|
||||
|
||||
// === Margin from terminal edges ===
|
||||
/** Margin from terminal edges. Number applies to all sides. */
|
||||
margin?: OverlayMargin | number;
|
||||
|
||||
// === Visibility ===
|
||||
/**
|
||||
* Control overlay visibility based on terminal dimensions.
|
||||
* If provided, overlay is only rendered when this returns true.
|
||||
* Called each render cycle with current terminal dimensions.
|
||||
*/
|
||||
visible?: (termWidth: number, termHeight: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle returned by showOverlay for controlling the overlay
|
||||
*/
|
||||
export interface OverlayHandle {
|
||||
/** Permanently remove the overlay (cannot be shown again) */
|
||||
hide(): void;
|
||||
/** Temporarily hide or show the overlay */
|
||||
setHidden(hidden: boolean): void;
|
||||
/** Check if overlay is temporarily hidden */
|
||||
isHidden(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -165,6 +189,7 @@ export class TUI extends Container {
|
|||
component: Component;
|
||||
options?: OverlayOptions;
|
||||
preFocus: Component | null;
|
||||
hidden: boolean;
|
||||
}[] = [];
|
||||
|
||||
constructor(terminal: Terminal) {
|
||||
|
|
@ -176,25 +201,90 @@ export class TUI extends Container {
|
|||
this.focusedComponent = component;
|
||||
}
|
||||
|
||||
/** Show an overlay component with configurable positioning and sizing. */
|
||||
showOverlay(component: Component, options?: OverlayOptions): void {
|
||||
this.overlayStack.push({ component, options, preFocus: this.focusedComponent });
|
||||
this.setFocus(component);
|
||||
/**
|
||||
* Show an overlay component with configurable positioning and sizing.
|
||||
* Returns a handle to control the overlay's visibility.
|
||||
*/
|
||||
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
|
||||
const entry = { component, options, preFocus: this.focusedComponent, hidden: false };
|
||||
this.overlayStack.push(entry);
|
||||
// Only focus if overlay is actually visible
|
||||
if (this.isOverlayVisible(entry)) {
|
||||
this.setFocus(component);
|
||||
}
|
||||
this.terminal.hideCursor();
|
||||
this.requestRender();
|
||||
|
||||
// Return handle for controlling this overlay
|
||||
return {
|
||||
hide: () => {
|
||||
const index = this.overlayStack.indexOf(entry);
|
||||
if (index !== -1) {
|
||||
this.overlayStack.splice(index, 1);
|
||||
// Restore focus if this overlay had focus
|
||||
if (this.focusedComponent === component) {
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
this.setFocus(topVisible?.component ?? entry.preFocus);
|
||||
}
|
||||
if (this.overlayStack.length === 0) this.terminal.hideCursor();
|
||||
this.requestRender();
|
||||
}
|
||||
},
|
||||
setHidden: (hidden: boolean) => {
|
||||
if (entry.hidden === hidden) return;
|
||||
entry.hidden = hidden;
|
||||
// Update focus when hiding/showing
|
||||
if (hidden) {
|
||||
// If this overlay had focus, move focus to next visible or preFocus
|
||||
if (this.focusedComponent === component) {
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
this.setFocus(topVisible?.component ?? entry.preFocus);
|
||||
}
|
||||
} else {
|
||||
// Restore focus to this overlay when showing (if it's actually visible)
|
||||
if (this.isOverlayVisible(entry)) {
|
||||
this.setFocus(component);
|
||||
}
|
||||
}
|
||||
this.requestRender();
|
||||
},
|
||||
isHidden: () => entry.hidden,
|
||||
};
|
||||
}
|
||||
|
||||
/** Hide the topmost overlay and restore previous focus. */
|
||||
hideOverlay(): void {
|
||||
const overlay = this.overlayStack.pop();
|
||||
if (!overlay) return;
|
||||
this.setFocus(overlay.preFocus);
|
||||
// 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();
|
||||
}
|
||||
|
||||
/** Check if there are any visible overlays */
|
||||
hasOverlay(): boolean {
|
||||
return this.overlayStack.length > 0;
|
||||
return this.overlayStack.some((o) => this.isOverlayVisible(o));
|
||||
}
|
||||
|
||||
/** Check if an overlay entry is currently visible */
|
||||
private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {
|
||||
if (entry.hidden) return false;
|
||||
if (entry.options?.visible) {
|
||||
return entry.options.visible(this.terminal.columns, this.terminal.rows);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Find the topmost visible overlay, if any */
|
||||
private getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined {
|
||||
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
|
||||
if (this.isOverlayVisible(this.overlayStack[i])) {
|
||||
return this.overlayStack[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
|
|
@ -269,6 +359,20 @@ export class TUI extends Container {
|
|||
return;
|
||||
}
|
||||
|
||||
// If focused component is an overlay, verify it's still visible
|
||||
// (visibility can change due to terminal resize or visible() callback)
|
||||
const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);
|
||||
if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
|
||||
// Focused overlay is no longer visible, redirect to topmost visible overlay
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
if (topVisible) {
|
||||
this.setFocus(topVisible.component);
|
||||
} else {
|
||||
// No visible overlays, restore to preFocus
|
||||
this.setFocus(focusedOverlay.preFocus);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass input to focused component (including Ctrl+C)
|
||||
// The focused component can decide how to handle Ctrl+C
|
||||
if (this.focusedComponent?.handleInput) {
|
||||
|
|
@ -354,14 +458,7 @@ export class TUI extends Container {
|
|||
const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
|
||||
|
||||
// === Resolve width ===
|
||||
let width: number;
|
||||
if (opt.width !== undefined) {
|
||||
width = opt.width;
|
||||
} else if (opt.widthPercent !== undefined) {
|
||||
width = Math.floor((termWidth * opt.widthPercent) / 100);
|
||||
} else {
|
||||
width = Math.min(80, availWidth);
|
||||
}
|
||||
let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
|
||||
// Apply minWidth
|
||||
if (opt.minWidth !== undefined) {
|
||||
width = Math.max(width, opt.minWidth);
|
||||
|
|
@ -370,12 +467,7 @@ export class TUI extends Container {
|
|||
width = Math.max(1, Math.min(width, availWidth));
|
||||
|
||||
// === Resolve maxHeight ===
|
||||
let maxHeight: number | undefined;
|
||||
if (opt.maxHeight !== undefined) {
|
||||
maxHeight = opt.maxHeight;
|
||||
} else if (opt.maxHeightPercent !== undefined) {
|
||||
maxHeight = Math.floor((termHeight * opt.maxHeightPercent) / 100);
|
||||
}
|
||||
let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
|
||||
// Clamp to available space
|
||||
if (maxHeight !== undefined) {
|
||||
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
|
||||
|
|
@ -388,13 +480,22 @@ export class TUI extends Container {
|
|||
let row: number;
|
||||
let col: number;
|
||||
|
||||
// Absolute positioning takes precedence
|
||||
if (opt.row !== undefined) {
|
||||
row = opt.row;
|
||||
} else if (opt.rowPercent !== undefined) {
|
||||
// Percentage: 0 = top, 100 = bottom
|
||||
const maxRow = Math.max(0, availHeight - effectiveHeight);
|
||||
row = marginTop + Math.floor((maxRow * opt.rowPercent) / 100);
|
||||
if (typeof opt.row === "string") {
|
||||
// Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
|
||||
const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
|
||||
if (match) {
|
||||
const maxRow = Math.max(0, availHeight - effectiveHeight);
|
||||
const percent = parseFloat(match[1]) / 100;
|
||||
row = marginTop + Math.floor(maxRow * percent);
|
||||
} else {
|
||||
// Invalid format, fall back to center
|
||||
row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
|
||||
}
|
||||
} else {
|
||||
// Absolute row position
|
||||
row = opt.row;
|
||||
}
|
||||
} else {
|
||||
// Anchor-based (default: center)
|
||||
const anchor = opt.anchor ?? "center";
|
||||
|
|
@ -402,11 +503,21 @@ export class TUI extends Container {
|
|||
}
|
||||
|
||||
if (opt.col !== undefined) {
|
||||
col = opt.col;
|
||||
} else if (opt.colPercent !== undefined) {
|
||||
// Percentage: 0 = left, 100 = right
|
||||
const maxCol = Math.max(0, availWidth - width);
|
||||
col = marginLeft + Math.floor((maxCol * opt.colPercent) / 100);
|
||||
if (typeof opt.col === "string") {
|
||||
// Percentage: 0% = left, 100% = right (overlay stays within bounds)
|
||||
const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
|
||||
if (match) {
|
||||
const maxCol = Math.max(0, availWidth - width);
|
||||
const percent = parseFloat(match[1]) / 100;
|
||||
col = marginLeft + Math.floor(maxCol * percent);
|
||||
} else {
|
||||
// Invalid format, fall back to center
|
||||
col = this.resolveAnchorCol("center", width, availWidth, marginLeft);
|
||||
}
|
||||
} else {
|
||||
// Absolute column position
|
||||
col = opt.col;
|
||||
}
|
||||
} else {
|
||||
// Anchor-based (default: center)
|
||||
const anchor = opt.anchor ?? "center";
|
||||
|
|
@ -463,11 +574,16 @@ export class TUI extends Container {
|
|||
if (this.overlayStack.length === 0) return lines;
|
||||
const result = [...lines];
|
||||
|
||||
// Pre-render all overlays and calculate positions
|
||||
// Pre-render all visible overlays and calculate positions
|
||||
const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
|
||||
let minLinesNeeded = result.length;
|
||||
|
||||
for (const { component, options } of this.overlayStack) {
|
||||
for (const entry of this.overlayStack) {
|
||||
// Skip invisible overlays (hidden or visible() returns false)
|
||||
if (!this.isOverlayVisible(entry)) continue;
|
||||
|
||||
const { component, options } = entry;
|
||||
|
||||
// Get layout with height=0 first to determine width and maxHeight
|
||||
// (width and maxHeight don't depend on overlay height)
|
||||
const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
|
||||
|
|
|
|||
|
|
@ -167,14 +167,14 @@ describe("TUI overlay options", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("widthPercent", () => {
|
||||
describe("width percentage", () => {
|
||||
it("should render overlay at percentage of terminal width", async () => {
|
||||
const terminal = new VirtualTerminal(100, 24);
|
||||
const tui = new TUI(terminal);
|
||||
const overlay = new StaticOverlay(["test"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { widthPercent: 50 });
|
||||
tui.showOverlay(overlay, { width: "50%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
|
|
@ -188,7 +188,7 @@ describe("TUI overlay options", () => {
|
|||
const overlay = new StaticOverlay(["test"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { widthPercent: 10, minWidth: 30 });
|
||||
tui.showOverlay(overlay, { width: "10%", minWidth: 30 });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
|
|
@ -344,7 +344,7 @@ describe("TUI overlay options", () => {
|
|||
|
||||
tui.addChild(new EmptyContent());
|
||||
// 50% should center both ways
|
||||
tui.showOverlay(overlay, { width: 10, rowPercent: 50, colPercent: 50 });
|
||||
tui.showOverlay(overlay, { width: 10, row: "50%", col: "50%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
|
|
@ -368,7 +368,7 @@ describe("TUI overlay options", () => {
|
|||
const overlay = new StaticOverlay(["TOP"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 10, rowPercent: 0 });
|
||||
tui.showOverlay(overlay, { width: 10, row: "0%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
|
|
@ -383,7 +383,7 @@ describe("TUI overlay options", () => {
|
|||
const overlay = new StaticOverlay(["BOTTOM"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { width: 10, rowPercent: 100 });
|
||||
tui.showOverlay(overlay, { width: 10, row: "100%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
|
|
@ -421,7 +421,7 @@ describe("TUI overlay options", () => {
|
|||
const overlay = new StaticOverlay(["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8", "L9", "L10"]);
|
||||
|
||||
tui.addChild(new EmptyContent());
|
||||
tui.showOverlay(overlay, { maxHeightPercent: 50 });
|
||||
tui.showOverlay(overlay, { maxHeight: "50%" });
|
||||
tui.start();
|
||||
await renderAndFlush(tui, terminal);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue