diff --git a/package-lock.json b/package-lock.json index 1f28f7c5..43d53ba1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "workspaces": [ "packages/*", "packages/web-ui/example", - "packages/coding-agent/examples/extensions/with-deps" + "packages/coding-agent/examples/extensions/with-deps", + "packages/coding-agent/examples/extensions/pi-dosbox" ], "dependencies": { "@mariozechner/jiti": "^2.6.5", @@ -5056,6 +5057,12 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/emulators": { + "version": "8.3.9", + "resolved": "https://registry.npmjs.org/emulators/-/emulators-8.3.9.tgz", + "integrity": "sha512-KRoi5rvWCrRTzboCQlftbASdsdmnAtkGQdBTcjXV9GZ9hmGL01cxDVUQYpKSH0O4Lcoatwb+2HcYUJFohijNmw==", + "license": "GPL-2.0" + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6938,6 +6945,10 @@ "@napi-rs/canvas": "^0.1.81" } }, + "node_modules/pi-dosbox": { + "resolved": "packages/coding-agent/examples/extensions/pi-dosbox", + "link": true + }, "node_modules/pi-extension-with-deps": { "resolved": "packages/coding-agent/examples/extensions/with-deps", "link": true @@ -8716,6 +8727,43 @@ "node": ">=20.0.0" } }, + "packages/coding-agent/examples/extensions/dosbox": { + "name": "pi-extension-dosbox", + "version": "0.0.1", + "extraneous": true, + "dependencies": { + "emulators": "^8.3.9" + }, + "devDependencies": { + "@types/node": "^20.11.30" + } + }, + "packages/coding-agent/examples/extensions/pi-dosbox": { + "version": "0.0.1", + "dependencies": { + "emulators": "^8.3.9" + }, + "devDependencies": { + "@types/node": "^20.11.30" + } + }, + "packages/coding-agent/examples/extensions/pi-dosbox/node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/coding-agent/examples/extensions/pi-dosbox/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", "version": "1.13.3", diff --git a/package.json b/package.json index fcb08556..fe3d6a75 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "workspaces": [ "packages/*", "packages/web-ui/example", - "packages/coding-agent/examples/extensions/with-deps" + "packages/coding-agent/examples/extensions/with-deps", + "packages/coding-agent/examples/extensions/pi-dosbox" ], "scripts": { "clean": "npm run clean --workspaces", diff --git a/packages/coding-agent/examples/extensions/pi-dosbox/index.ts b/packages/coding-agent/examples/extensions/pi-dosbox/index.ts new file mode 100644 index 00000000..df8c3fe8 --- /dev/null +++ b/packages/coding-agent/examples/extensions/pi-dosbox/index.ts @@ -0,0 +1,44 @@ +/** + * DOSBox extension for pi + * + * Usage: pi --extension ./examples/extensions/pi-dosbox + * Command: /dosbox [bundle.jsdos] + */ + +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { DosboxComponent } from "./src/dosbox-component.js"; + +export default function (pi: ExtensionAPI) { + pi.registerCommand("dosbox", { + description: "Run DOSBox emulator", + + handler: async (args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("DOSBox requires interactive mode", "error"); + return; + } + + const bundlePath = args?.trim(); + let bundleData: Uint8Array | undefined; + if (bundlePath) { + try { + const resolvedPath = resolve(ctx.cwd, bundlePath); + bundleData = await readFile(resolvedPath); + } catch (error) { + ctx.ui.notify( + `Failed to load bundle: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + return; + } + } + + await ctx.ui.custom((tui, theme, _kb, done) => { + const fallbackColor = (s: string) => theme.fg("warning", s); + return new DosboxComponent(tui, fallbackColor, () => done(undefined), bundleData); + }); + }, + }); +} diff --git a/packages/coding-agent/examples/extensions/pi-dosbox/package.json b/packages/coding-agent/examples/extensions/pi-dosbox/package.json new file mode 100644 index 00000000..ac95e876 --- /dev/null +++ b/packages/coding-agent/examples/extensions/pi-dosbox/package.json @@ -0,0 +1,23 @@ +{ + "name": "pi-dosbox", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "start": "npx tsx src/main.ts", + "clean": "echo 'nothing to clean'", + "build": "echo 'nothing to build'", + "check": "echo 'nothing to check'" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "emulators": "^8.3.9" + }, + "devDependencies": { + "@types/node": "^20.11.30" + } +} diff --git a/packages/coding-agent/examples/extensions/pi-dosbox/src/dosbox-component.ts b/packages/coding-agent/examples/extensions/pi-dosbox/src/dosbox-component.ts new file mode 100644 index 00000000..65ec1313 --- /dev/null +++ b/packages/coding-agent/examples/extensions/pi-dosbox/src/dosbox-component.ts @@ -0,0 +1,486 @@ +/** + * DOSBox TUI Component + * + * Renders DOSBox framebuffer as an image in the terminal. + */ + +import { createRequire } from "node:module"; +import { dirname } from "node:path"; +import { deflateSync } from "node:zlib"; +import type { Component } from "@mariozechner/pi-tui"; +import { Image, type ImageTheme, isKeyRelease, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; +import type { CommandInterface, Emulators } from "emulators"; + +const MAX_WIDTH_CELLS = 120; + +let emulatorsInstance: Emulators | undefined; + +async function getEmulators(): Promise { + if (!emulatorsInstance) { + const require = createRequire(import.meta.url); + const distPath = dirname(require.resolve("emulators")); + // The emulators package assigns to global.emulators, not module.exports + await import("emulators"); + const g = globalThis as unknown as { emulators: Emulators }; + const emu = g.emulators; + emu.pathPrefix = `${distPath}/`; + emu.pathSuffix = ""; + emulatorsInstance = emu; + } + return emulatorsInstance; +} + +export class DosboxComponent implements Component { + private tui: { requestRender: () => void }; + private onClose: () => void; + private ci: CommandInterface | null = null; + private image: Image | null = null; + private imageTheme: ImageTheme; + private frameWidth = 0; + private frameHeight = 0; + private loadingMessage = "Loading DOSBox..."; + private errorMessage: string | null = null; + private cachedLines: string[] = []; + private cachedWidth = 0; + private cachedVersion = -1; + private version = 0; + private disposed = false; + private bundleData?: Uint8Array; + private kittyPushed = false; + + wantsKeyRelease = true; + + constructor( + tui: { requestRender: () => void }, + fallbackColor: (s: string) => string, + onClose: () => void, + bundleData?: Uint8Array, + ) { + this.tui = tui; + this.onClose = onClose; + this.bundleData = bundleData; + this.imageTheme = { fallbackColor }; + void this.init(); + } + + private async init(): Promise { + try { + const emu = await getEmulators(); + const initData = this.bundleData ?? (await this.createDefaultBundle(emu)); + this.ci = await emu.dosboxDirect(initData); + + const events = this.ci.events(); + events.onFrameSize((width: number, height: number) => { + this.frameWidth = width; + this.frameHeight = height; + this.version++; + this.tui.requestRender(); + }); + events.onFrame((rgb: Uint8Array | null, rgba: Uint8Array | null) => { + this.updateFrame(rgb, rgba); + }); + events.onExit(() => { + this.dispose(); + this.onClose(); + }); + + // Push Kitty enhanced mode for proper key press/release + process.stdout.write("\x1b[>15u"); + this.kittyPushed = true; + } catch (error) { + this.errorMessage = error instanceof Error ? error.message : String(error); + this.tui.requestRender(); + } + } + + private async createDefaultBundle(emu: Emulators): Promise { + const bundle = await emu.bundle(); + bundle.autoexec( + "@echo off", + "cls", + "echo pi DOSBox", + "echo.", + "echo Pass a .jsdos bundle to run a game", + "echo.", + "dir", + ); + return bundle.toUint8Array(true); + } + + private updateFrame(rgb: Uint8Array | null, rgba: Uint8Array | null): void { + if (!this.frameWidth || !this.frameHeight) { + if (this.ci) { + this.frameWidth = this.ci.width(); + this.frameHeight = this.ci.height(); + } + if (!this.frameWidth || !this.frameHeight) return; + } + const rgbaFrame = rgba ?? (rgb ? expandRgbToRgba(rgb) : null); + if (!rgbaFrame) return; + + const png = encodePng(this.frameWidth, this.frameHeight, rgbaFrame); + const base64 = png.toString("base64"); + this.image = new Image( + base64, + "image/png", + this.imageTheme, + { maxWidthCells: MAX_WIDTH_CELLS }, + { widthPx: this.frameWidth, heightPx: this.frameHeight }, + ); + this.version++; + this.tui.requestRender(); + } + + handleInput(data: string): void { + const released = isKeyRelease(data); + + if (!released && matchesKey(data, Key.ctrl("q"))) { + this.dispose(); + this.onClose(); + return; + } + + if (!this.ci) return; + + const parsed = parseKeyWithModifiers(data); + if (!parsed) return; + + const { keyCode, shift, ctrl, alt } = parsed; + + if (shift) this.ci.sendKeyEvent(KBD.leftshift, !released); + if (ctrl) this.ci.sendKeyEvent(KBD.leftctrl, !released); + if (alt) this.ci.sendKeyEvent(KBD.leftalt, !released); + + this.ci.sendKeyEvent(keyCode, !released); + } + + invalidate(): void { + this.cachedWidth = 0; + } + + render(width: number): string[] { + if (this.errorMessage) { + return [truncateToWidth(`DOSBox error: ${this.errorMessage}`, width)]; + } + if (!this.ci) { + return [truncateToWidth(this.loadingMessage, width)]; + } + if (!this.image) { + return [truncateToWidth("Waiting for DOSBox frame...", width)]; + } + if (width === this.cachedWidth && this.cachedVersion === this.version) { + return this.cachedLines; + } + + const imageLines = this.image.render(width); + const footer = truncateToWidth("\x1b[2mCtrl+Q to quit\x1b[22m", width); + const lines = [...imageLines, footer]; + + this.cachedLines = lines; + this.cachedWidth = width; + this.cachedVersion = this.version; + + return lines; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.kittyPushed) { + process.stdout.write("\x1b[ undefined); + this.ci = null; + } + } +} + +function expandRgbToRgba(rgb: Uint8Array): Uint8Array { + const rgba = new Uint8Array((rgb.length / 3) * 4); + for (let i = 0, j = 0; i < rgb.length; i += 3, j += 4) { + rgba[j] = rgb[i] ?? 0; + rgba[j + 1] = rgb[i + 1] ?? 0; + rgba[j + 2] = rgb[i + 2] ?? 0; + rgba[j + 3] = 255; + } + return rgba; +} + +const CRC_TABLE = createCrcTable(); + +function encodePng(width: number, height: number, rgba: Uint8Array): Buffer { + const stride = width * 4; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y++) { + const rowOffset = y * (stride + 1); + raw[rowOffset] = 0; + raw.set(rgba.subarray(y * stride, y * stride + stride), rowOffset + 1); + } + + const compressed = deflateSync(raw); + + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 6; + header[10] = 0; + header[11] = 0; + header[12] = 0; + + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdr = createChunk("IHDR", header); + const idat = createChunk("IDAT", compressed); + const iend = createChunk("IEND", Buffer.alloc(0)); + + return Buffer.concat([signature, ihdr, idat, iend]); +} + +function createChunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + const typeBuffer = Buffer.from(type, "ascii"); + const crcBuffer = Buffer.concat([typeBuffer, data]); + const crc = crc32(crcBuffer); + const crcOut = Buffer.alloc(4); + crcOut.writeUInt32BE(crc, 0); + return Buffer.concat([length, typeBuffer, data, crcOut]); +} + +function crc32(buffer: Buffer): number { + let crc = 0xffffffff; + for (const byte of buffer) { + crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createCrcTable(): Uint32Array { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let j = 0; j < 8; j++) { + if (c & 1) { + c = 0xedb88320 ^ (c >>> 1); + } else { + c >>>= 1; + } + } + table[i] = c >>> 0; + } + return table; +} + +// js-dos key codes (from js-dos/src/window/dos/controls/keys.ts) +const KBD = { + enter: 257, + backspace: 259, + tab: 258, + esc: 256, + space: 32, + leftshift: 340, + rightshift: 344, + leftctrl: 341, + rightctrl: 345, + leftalt: 342, + rightalt: 346, + up: 265, + down: 264, + left: 263, + right: 262, + home: 268, + end: 269, + pageup: 266, + pagedown: 267, + insert: 260, + delete: 261, + f1: 290, + f2: 291, + f3: 292, + f4: 293, + f5: 294, + f6: 295, + f7: 296, + f8: 297, + f9: 298, + f10: 299, + f11: 300, + f12: 301, +}; + +interface ParsedKey { + keyCode: number; + shift: boolean; + ctrl: boolean; + alt: boolean; +} + +function decodeModifiers(modifierField: number): { shift: boolean; ctrl: boolean; alt: boolean } { + const modifiers = modifierField - 1; + return { + shift: (modifiers & 1) !== 0, + alt: (modifiers & 2) !== 0, + ctrl: (modifiers & 4) !== 0, + }; +} + +function parseKeyWithModifiers(data: string): ParsedKey | null { + // Kitty CSI u sequences: \x1b[codepoint(:shifted(:base))?;modifier(:event)?u + if (data.startsWith("\x1b[") && data.endsWith("u")) { + const body = data.slice(2, -1); + const [keyPart, modifierPart] = body.split(";"); + if (keyPart) { + const codepoint = parseInt(keyPart.split(":")[0], 10); + if (!Number.isNaN(codepoint)) { + const modifierField = modifierPart ? parseInt(modifierPart.split(":")[0], 10) : 1; + const { shift, alt, ctrl } = decodeModifiers(Number.isNaN(modifierField) ? 1 : modifierField); + const keyCode = codepointToJsDosKey(codepoint); + if (keyCode !== null) { + return { keyCode, shift, ctrl, alt }; + } + } + } + } + + // CSI sequences: \x1b[;: + const csiMatch = data.match(/^\x1b\[(\d+);(\d+)(?::\d+)?([~A-Za-z])$/); + if (csiMatch) { + const code = parseInt(csiMatch[1], 10); + const modifierField = parseInt(csiMatch[2], 10); + const suffix = csiMatch[3]; + const { shift, alt, ctrl } = decodeModifiers(modifierField); + const keyCode = mapCsiKeyToJsDos(code, suffix); + if (keyCode === null) return null; + return { keyCode, shift, ctrl, alt }; + } + + // Legacy/simple input + const keyCode = mapKeyToJsDos(data); + if (keyCode === null) return null; + const shift = data.length === 1 && data >= "A" && data <= "Z"; + return { keyCode, shift, ctrl: false, alt: false }; +} + +function codepointToJsDosKey(codepoint: number): number | null { + if (codepoint === 13) return KBD.enter; + if (codepoint === 9) return KBD.tab; + if (codepoint === 27) return KBD.esc; + if (codepoint === 8 || codepoint === 127) return KBD.backspace; + if (codepoint === 32) return KBD.space; + if (codepoint >= 97 && codepoint <= 122) return codepoint - 32; // a-z -> A-Z + if (codepoint >= 65 && codepoint <= 90) return codepoint; // A-Z + if (codepoint >= 48 && codepoint <= 57) return codepoint; // 0-9 + return null; +} + +function mapCsiKeyToJsDos(code: number, suffix: string): number | null { + switch (suffix) { + case "A": + return KBD.up; + case "B": + return KBD.down; + case "C": + return KBD.right; + case "D": + return KBD.left; + case "H": + return KBD.home; + case "F": + return KBD.end; + case "P": + return KBD.f1; + case "Q": + return KBD.f2; + case "R": + return KBD.f3; + case "S": + return KBD.f4; + case "Z": + return KBD.tab; + case "~": + switch (code) { + case 1: + case 7: + return KBD.home; + case 2: + return KBD.insert; + case 3: + return KBD.delete; + case 4: + case 8: + return KBD.end; + case 5: + return KBD.pageup; + case 6: + return KBD.pagedown; + case 11: + return KBD.f1; + case 12: + return KBD.f2; + case 13: + return KBD.f3; + case 14: + return KBD.f4; + case 15: + return KBD.f5; + case 17: + return KBD.f6; + case 18: + return KBD.f7; + case 19: + return KBD.f8; + case 20: + return KBD.f9; + case 21: + return KBD.f10; + case 23: + return KBD.f11; + case 24: + return KBD.f12; + default: + return null; + } + default: + return null; + } +} + +function mapKeyToJsDos(data: string): number | null { + if (matchesKey(data, Key.enter)) return KBD.enter; + if (matchesKey(data, Key.backspace)) return KBD.backspace; + if (matchesKey(data, Key.tab)) return KBD.tab; + if (matchesKey(data, Key.escape)) return KBD.esc; + if (matchesKey(data, Key.space)) return KBD.space; + if (matchesKey(data, Key.up)) return KBD.up; + if (matchesKey(data, Key.down)) return KBD.down; + if (matchesKey(data, Key.left)) return KBD.left; + if (matchesKey(data, Key.right)) return KBD.right; + if (matchesKey(data, Key.pageUp)) return KBD.pageup; + if (matchesKey(data, Key.pageDown)) return KBD.pagedown; + if (matchesKey(data, Key.home)) return KBD.home; + if (matchesKey(data, Key.end)) return KBD.end; + if (matchesKey(data, Key.insert)) return KBD.insert; + if (matchesKey(data, Key.delete)) return KBD.delete; + if (matchesKey(data, Key.f1)) return KBD.f1; + if (matchesKey(data, Key.f2)) return KBD.f2; + if (matchesKey(data, Key.f3)) return KBD.f3; + if (matchesKey(data, Key.f4)) return KBD.f4; + if (matchesKey(data, Key.f5)) return KBD.f5; + if (matchesKey(data, Key.f6)) return KBD.f6; + if (matchesKey(data, Key.f7)) return KBD.f7; + if (matchesKey(data, Key.f8)) return KBD.f8; + if (matchesKey(data, Key.f9)) return KBD.f9; + if (matchesKey(data, Key.f10)) return KBD.f10; + if (matchesKey(data, Key.f11)) return KBD.f11; + if (matchesKey(data, Key.f12)) return KBD.f12; + + if (data.length === 1) { + const code = data.charCodeAt(0); + if (data >= "a" && data <= "z") return code - 32; + if (data >= "A" && data <= "Z") return code; + if (data >= "0" && data <= "9") return code; + } + return null; +} diff --git a/packages/coding-agent/examples/extensions/pi-dosbox/src/main.ts b/packages/coding-agent/examples/extensions/pi-dosbox/src/main.ts new file mode 100644 index 00000000..dbc0b7dd --- /dev/null +++ b/packages/coding-agent/examples/extensions/pi-dosbox/src/main.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env npx tsx +/** + * Standalone DOSBox TUI app + * + * Usage: npx tsx src/main.ts [bundle.jsdos] + */ + +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; +import { DosboxComponent } from "./dosbox-component.js"; + +async function main() { + const bundlePath = process.argv[2]; + let bundleData: Uint8Array | undefined; + + if (bundlePath) { + try { + const resolvedPath = resolve(process.cwd(), bundlePath); + bundleData = await readFile(resolvedPath); + console.log(`Loading bundle: ${resolvedPath}`); + } catch (error) { + console.error(`Failed to load bundle: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } + + const terminal = new ProcessTerminal(); + const tui = new TUI(terminal); + + const fallbackColor = (s: string) => `\x1b[33m${s}\x1b[0m`; + + const component = new DosboxComponent( + tui, + fallbackColor, + () => { + tui.stop(); + process.exit(0); + }, + bundleData, + ); + + tui.addChild(component); + tui.setFocus(component); + tui.start(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});