/ 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 }