/ extensions / dunnet.ts
dunnet.ts
  1  /**
  2   * Dunnet extension for pi coding agent
  3   * 
  4   * Play the classic Emacs text adventure in a TUI panel while the agent works.
  5   * Usage: /dunnet
  6   */
  7  
  8  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
  9  import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
 10  import * as path from "path";
 11  import * as os from "os";
 12  import { DunnetGame } from "../dist/engine/game.js";
 13  import { createGameData } from "../dist/index.js";
 14  
 15  // ==================== TUI COMPONENT ====================
 16  
 17  const DUNNET_SAVE_TYPE = "dunnet-save";
 18  const MAX_HISTORY = 500;
 19  
 20  class DunnetComponent {
 21  	private engine: DunnetGame;
 22  	private output: string[] = [];
 23  	private input: string = "";
 24  	private scrollOffset: number = 0;
 25  	private onClose: () => void;
 26  	private tui: { requestRender: () => void };
 27  	private cachedLines: string[] = [];
 28  	private cachedWidth = 0;
 29  	private dirty = true;
 30  
 31  	constructor(
 32  		tui: { requestRender: () => void },
 33  		onClose: () => void,
 34  	) {
 35  		this.tui = tui;
 36  		this.onClose = onClose;
 37  
 38  		this.engine = new DunnetGame(createGameData(), false, path.join(os.homedir(), ".pi-dunnet-save.json"));
 39  		const intro = this.engine.getIntro();
 40  		this.addOutput(intro);
 41  	}
 42  
 43  	private addOutput(text: string): void {
 44  		const lines = text.split('\n');
 45  		this.output.push(...lines);
 46  		// Auto-scroll to bottom
 47  		this.scrollOffset = 0;
 48  		this.dirty = true;
 49  	}
 50  
 51  	handleInput(data: string): void {
 52  		// Escape closes the game panel
 53  		if (matchesKey(data, "escape")) {
 54  			this.onClose();
 55  			return;
 56  		}
 57  
 58  		// Enter sends the command
 59  		if (matchesKey(data, "enter")) {
 60  			const cmd = this.input;
 61  			this.input = "";
 62  
 63  			if (cmd.trim()) {
 64  				// Show the command in output
 65  				this.output.push(`> ${cmd}`);
 66  
 67  				// Process it
 68  				const result = this.engine.processCommand(cmd);
 69  				if (result) {
 70  					this.addOutput(result);
 71  				}
 72  
 73  				// Append game-over marker if the engine reports it
 74  				if (this.engine.isGameOver() && cmd.toLowerCase() === 'quit') {
 75  					this.addOutput("Game over. Type commands to start a new session, or press ESC to close.");
 76  				}
 77  			}
 78  
 79  			this.dirty = true;
 80  			this.tui.requestRender();
 81  			return;
 82  		}
 83  
 84  		// Backspace / delete
 85  		if (matchesKey(data, "backspace") || matchesKey(data, "delete")) {
 86  			if (this.input.length > 0) {
 87  				this.input = this.input.slice(0, -1);
 88  				this.dirty = true;
 89  				this.tui.requestRender();
 90  			}
 91  			return;
 92  		}
 93  
 94  		// Regular printable character
 95  		if (data.length === 1 && data.charCodeAt(0) >= 32) {
 96  			this.input += data;
 97  			this.dirty = true;
 98  			this.tui.requestRender();
 99  			return;
100  		}
101  
102  		// Scrolling
103  		if (matchesKey(data, "pageup")) {
104  			this.scrollOffset = Math.min(this.scrollOffset + 10, Math.max(0, this.output.length - 5));
105  			this.dirty = true;
106  			this.tui.requestRender();
107  			return;
108  		}
109  		if (matchesKey(data, "pagedown")) {
110  			this.scrollOffset = Math.max(0, this.scrollOffset - 10);
111  			this.dirty = true;
112  			this.tui.requestRender();
113  			return;
114  		}
115  	}
116  
117  	invalidate(): void {
118  		this.cachedWidth = 0;
119  		this.dirty = true;
120  	}
121  
122  	render(width: number): string[] {
123  		if (width === this.cachedWidth && !this.dirty) {
124  			return this.cachedLines;
125  		}
126  
127  		const lines: string[] = [];
128  		const contentWidth = Math.max(20, width - 2);
129  
130  		// Colors
131  		const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
132  		const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
133  		const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
134  		const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
135  
136  		// Header
137  		const headerLabel = bold(green(" DUNNET "));
138  		const headerPad = Math.max(0, width - visibleWidth(headerLabel) - 2);
139  		lines.push(dim("╭") + headerLabel + "─".repeat(headerPad) + dim("╯"));
140  
141  		// Output area — show the most recent lines that fit
142  		const maxOutputLines = Math.max(8, 20);
143  		const totalOutput = this.output.length;
144  		const effectiveLines = Math.min(totalOutput, maxOutputLines);
145  
146  		const startIdx = Math.max(0, totalOutput - effectiveLines - this.scrollOffset);
147  		const endIdx = Math.min(totalOutput, startIdx + effectiveLines);
148  
149  		for (let i = startIdx; i < endIdx; i++) {
150  			const line = this.output[i] || "";
151  			const wrapped = wrapTextWithAnsi(line, contentWidth);
152  			for (const wline of wrapped) {
153  				lines.push(truncateToWidth(" " + wline, width));
154  			}
155  		}
156  
157  		// Separator
158  		lines.push(dim("├" + "─".repeat(width - 2) + "┤"));
159  
160  		// Input line with cursor
161  		const promptStr = yellow("> ");
162  		const cursor = "\x1b[5m\u2588\x1b[0m"; // blinking block cursor
163  		const inputLine = promptStr + this.input + cursor;
164  		lines.push(truncateToWidth(inputLine, width));
165  
166  		// Footer
167  		const gameOver = this.engine.isGameOver();
168  		const footer = gameOver
169  			? dim(" ESC: close │ ") + bold("GAME OVER") + dim(" │ start new session or close")
170  			: dim(" ESC: close │ Enter: send │ PgUp/PgDn: scroll");
171  		lines.push(truncateToWidth(footer, width));
172  
173  		this.cachedLines = lines;
174  		this.cachedWidth = width;
175  		this.dirty = false;
176  		return lines;
177  	}
178  }
179  
180  // ==================== EXTENSION ====================
181  
182  export default function (pi: ExtensionAPI) {
183  	pi.registerCommand("dunnet", {
184  		description: "Play Dunnet — classic Emacs text adventure",
185  
186  		handler: async (_args, ctx) => {
187  			if (!ctx.hasUI) {
188  				ctx.ui.notify("Dunnet requires interactive mode", "error");
189  				return;
190  			}
191  
192  			await ctx.ui.custom<void>((tui, _theme, _kb, done) => {
193  				const component = new DunnetComponent(
194  					tui,
195  					() => done(undefined),
196  				);
197  
198  				return {
199  					render: (w: number) => component.render(w),
200  					invalidate: () => component.invalidate(),
201  					handleInput: (data: string) => component.handleInput(data),
202  				};
203  			});
204  		},
205  	});
206  }