feat(pi-dosbox): persistent DOSBox with QBasic and agent tool
- DOSBox now starts at session_start and persists in background - /dosbox command attaches UI to running instance (Ctrl+Q detaches) - Added dosbox tool with actions: send_keys, screenshot, read_text - Bundled QuickBASIC 4.5 files, mounted at C:\QB on startup - Agent can interact with DOSBox programmatically via tool Use: pi -e ./examples/extensions/pi-dosbox Then: /dosbox to view, or let agent use the dosbox tool
This commit is contained in:
@@ -2,11 +2,9 @@
|
||||
* DOSBox TUI Component
|
||||
*
|
||||
* Renders DOSBox framebuffer as an image in the terminal.
|
||||
* Connects to the persistent DosboxInstance.
|
||||
*/
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname } from "node:path";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import {
|
||||
allocateImageId,
|
||||
@@ -18,293 +16,12 @@ import {
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type { CommandInterface, Emulators } from "emulators";
|
||||
import { DosboxInstance } from "./dosbox-instance.js";
|
||||
|
||||
const MAX_WIDTH_CELLS = 120;
|
||||
|
||||
let emulatorsInstance: Emulators | undefined;
|
||||
|
||||
async function getEmulators(): Promise<Emulators> {
|
||||
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;
|
||||
private imageId: number;
|
||||
|
||||
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 };
|
||||
this.imageId = allocateImageId();
|
||||
void this.init();
|
||||
}
|
||||
|
||||
private async init(): Promise<void> {
|
||||
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<Uint8Array> {
|
||||
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, imageId: this.imageId },
|
||||
{ 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;
|
||||
|
||||
// Delete the terminal image
|
||||
process.stdout.write(deleteKittyImage(this.imageId));
|
||||
|
||||
if (this.kittyPushed) {
|
||||
process.stdout.write("\x1b[<u");
|
||||
this.kittyPushed = false;
|
||||
}
|
||||
if (this.ci) {
|
||||
// Suppress emulators exit logging
|
||||
const origLog = console.log;
|
||||
const origError = console.error;
|
||||
console.log = () => {};
|
||||
console.error = () => {};
|
||||
const ci = this.ci;
|
||||
this.ci = null;
|
||||
void ci
|
||||
.exit()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
// Restore after a delay to catch async logging
|
||||
setTimeout(() => {
|
||||
console.log = origLog;
|
||||
console.error = origError;
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
// js-dos key codes
|
||||
const KBD: Record<string, number> = {
|
||||
enter: 257,
|
||||
backspace: 259,
|
||||
tab: 258,
|
||||
@@ -340,6 +57,209 @@ const KBD = {
|
||||
f12: 301,
|
||||
};
|
||||
|
||||
export class DosboxComponent implements Component {
|
||||
private tui: { requestRender: () => void };
|
||||
private onClose: () => void;
|
||||
private instance: DosboxInstance | null = null;
|
||||
private image: Image | null = null;
|
||||
private imageTheme: ImageTheme;
|
||||
private loadingMessage = "Connecting to DOSBox...";
|
||||
private errorMessage: string | null = null;
|
||||
private cachedLines: string[] = [];
|
||||
private cachedWidth = 0;
|
||||
private cachedVersion = -1;
|
||||
private version = 0;
|
||||
private disposed = false;
|
||||
private imageId: number;
|
||||
private kittyPushed = false;
|
||||
private frameListener: ((rgba: Uint8Array, width: number, height: number) => void) | null = null;
|
||||
|
||||
wantsKeyRelease = true;
|
||||
|
||||
constructor(tui: { requestRender: () => void }, fallbackColor: (s: string) => string, onClose: () => void) {
|
||||
this.tui = tui;
|
||||
this.onClose = onClose;
|
||||
this.imageTheme = { fallbackColor };
|
||||
this.imageId = allocateImageId();
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
try {
|
||||
this.instance = await DosboxInstance.getInstance();
|
||||
|
||||
// Set up frame listener
|
||||
this.frameListener = (rgba: Uint8Array, width: number, height: number) => {
|
||||
this.updateFrame(rgba, width, height);
|
||||
};
|
||||
this.instance.addFrameListener(this.frameListener);
|
||||
|
||||
// Get initial state
|
||||
const state = this.instance.getState();
|
||||
if (state.lastFrame && state.width && state.height) {
|
||||
this.updateFrame(state.lastFrame, state.width, state.height);
|
||||
}
|
||||
|
||||
// Push Kitty enhanced mode for proper key press/release
|
||||
process.stdout.write("\x1b[>15u");
|
||||
this.kittyPushed = true;
|
||||
|
||||
this.tui.requestRender();
|
||||
} catch (error) {
|
||||
this.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
private updateFrame(rgba: Uint8Array, width: number, height: number): void {
|
||||
const png = this.encodePng(width, height, rgba);
|
||||
const base64 = png.toString("base64");
|
||||
this.image = new Image(
|
||||
base64,
|
||||
"image/png",
|
||||
this.imageTheme,
|
||||
{ maxWidthCells: MAX_WIDTH_CELLS, imageId: this.imageId },
|
||||
{ widthPx: width, heightPx: height },
|
||||
);
|
||||
this.version++;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private encodePng(width: number, height: number, rgba: Uint8Array): Buffer {
|
||||
const { deflateSync } = require("node:zlib");
|
||||
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;
|
||||
|
||||
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const ihdr = this.createChunk("IHDR", header);
|
||||
const idat = this.createChunk("IDAT", compressed);
|
||||
const iend = this.createChunk("IEND", Buffer.alloc(0));
|
||||
|
||||
return Buffer.concat([signature, ihdr, idat, iend]);
|
||||
}
|
||||
|
||||
private 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 = this.crc32(crcBuffer);
|
||||
const crcOut = Buffer.alloc(4);
|
||||
crcOut.writeUInt32BE(crc, 0);
|
||||
return Buffer.concat([length, typeBuffer, data, crcOut]);
|
||||
}
|
||||
|
||||
private crc32(buffer: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const released = isKeyRelease(data);
|
||||
|
||||
if (!released && matchesKey(data, Key.ctrl("q"))) {
|
||||
this.dispose();
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const ci = this.instance?.getCommandInterface();
|
||||
if (!ci) return;
|
||||
|
||||
const parsed = parseKeyWithModifiers(data);
|
||||
if (!parsed) return;
|
||||
|
||||
const { keyCode, shift, ctrl, alt } = parsed;
|
||||
|
||||
if (shift) ci.sendKeyEvent(KBD.leftshift, !released);
|
||||
if (ctrl) ci.sendKeyEvent(KBD.leftctrl, !released);
|
||||
if (alt) ci.sendKeyEvent(KBD.leftalt, !released);
|
||||
|
||||
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.instance?.isReady()) {
|
||||
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 detach (DOSBox keeps running)\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;
|
||||
|
||||
// Delete the terminal image
|
||||
process.stdout.write(deleteKittyImage(this.imageId));
|
||||
|
||||
if (this.kittyPushed) {
|
||||
process.stdout.write("\x1b[<u");
|
||||
this.kittyPushed = false;
|
||||
}
|
||||
|
||||
// Remove frame listener but DON'T dispose the instance
|
||||
if (this.instance && this.frameListener) {
|
||||
this.instance.removeFrameListener(this.frameListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CRC_TABLE = createCrcTable();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface ParsedKey {
|
||||
keyCode: number;
|
||||
shift: boolean;
|
||||
@@ -357,7 +277,6 @@ function decodeModifiers(modifierField: number): { shift: boolean; ctrl: boolean
|
||||
}
|
||||
|
||||
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(";");
|
||||
@@ -374,7 +293,6 @@ function parseKeyWithModifiers(data: string): ParsedKey | null {
|
||||
}
|
||||
}
|
||||
|
||||
// CSI sequences: \x1b[<num>;<modifier>:<event><suffix>
|
||||
const csiMatch = data.match(/^\x1b\[(\d+);(\d+)(?::\d+)?([~A-Za-z])$/);
|
||||
if (csiMatch) {
|
||||
const code = parseInt(csiMatch[1], 10);
|
||||
@@ -386,7 +304,6 @@ function parseKeyWithModifiers(data: string): ParsedKey | 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";
|
||||
@@ -399,9 +316,9 @@ function codepointToJsDosKey(codepoint: number): number | null {
|
||||
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
|
||||
if (codepoint >= 97 && codepoint <= 122) return codepoint - 32;
|
||||
if (codepoint >= 65 && codepoint <= 90) return codepoint;
|
||||
if (codepoint >= 48 && codepoint <= 57) return codepoint;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -445,14 +362,6 @@ function mapCsiKeyToJsDos(code: number, suffix: string): number | null {
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user