Monorepo for Aesthetic.Computer aesthetic.computer
at main 425 lines 15 kB view raw
1// terminal.mjs — Tile-based terminal emulator for AC native 2// Renders a PTY process (bash, claude, etc.) through the ac-native graphics system. 3// Uses system.pty API to spawn and communicate with the child process. 4 5let grid = null; 6let cols = 0, rows = 0; 7let cellW = 6, cellH = 10; // font_1 (6x10) cell size 8let started = false; 9let shiftHeld = false; 10let ctrlHeld = false; 11let altHeld = false; 12let cursorBlink = 0; 13let lastExitCode = -1; 14let lastCmd = ""; 15 16// ANSI 16-color palette → RGB 17const COLORS = [ 18 [0, 0, 0], // 0 black 19 [170, 0, 0], // 1 red 20 [0, 170, 0], // 2 green 21 [170, 85, 0], // 3 yellow/brown 22 [0, 0, 170], // 4 blue 23 [170, 0, 170], // 5 magenta 24 [0, 170, 170], // 6 cyan 25 [170, 170, 170], // 7 white 26 [85, 85, 85], // 8 bright black 27 [255, 85, 85], // 9 bright red 28 [85, 255, 85], // 10 bright green 29 [255, 255, 85], // 11 bright yellow 30 [85, 85, 255], // 12 bright blue 31 [255, 85, 255], // 13 bright magenta 32 [85, 255, 255], // 14 bright cyan 33 [255, 255, 255], // 15 bright white 34]; 35 36const DEFAULT_FG = [170, 170, 170]; // default foreground (light grey) 37const DEFAULT_BG = [0, 0, 0]; // default background (black) 38const CELL_SIZE = 10; // elements per cell: ch, fg, bg, bold, fg_r, fg_g, fg_b, bg_r, bg_g, bg_b 39 40function fgColor(idx, bold, r, g, b) { 41 if (idx === 255) return [r, g, b]; // truecolor 42 if (idx === 16) return bold ? COLORS[15] : DEFAULT_FG; // default 43 if (idx >= 0 && idx < 8 && bold) return COLORS[idx + 8]; 44 if (idx >= 0 && idx < 16) return COLORS[idx]; 45 return DEFAULT_FG; 46} 47 48function bgColor(idx, r, g, b) { 49 if (idx === 255) return [r, g, b]; // truecolor 50 if (idx === 16) return DEFAULT_BG; // default 51 if (idx >= 0 && idx < 16) return COLORS[idx]; 52 return DEFAULT_BG; 53} 54 55// Map Unicode to ASCII replacements for our 6x10 bitmap font. 56// Claude Code uses heavy Unicode UI — we map everything we can. 57const UNICODE_MAP = { 58 // Box drawing (light) 59 0x2500: "-", 0x2501: "=", 0x2502: "|", 0x2503: "|", 60 0x250C: "+", 0x250D: "+", 0x250E: "+", 0x250F: "+", 61 0x2510: "+", 0x2511: "+", 0x2512: "+", 0x2513: "+", 62 0x2514: "+", 0x2515: "+", 0x2516: "+", 0x2517: "+", 63 0x2518: "+", 0x2519: "+", 0x251A: "+", 0x251B: "+", 64 0x251C: "+", 0x251D: "+", 0x2523: "+", 0x2524: "+", 65 0x2525: "+", 0x252B: "+", 0x252C: "+", 0x2533: "+", 66 0x2534: "+", 0x253B: "+", 0x253C: "+", 0x254B: "+", 67 // Box drawing (double) 68 0x2550: "=", 0x2551: "|", 0x2552: "+", 0x2553: "+", 69 0x2554: "+", 0x2555: "+", 0x2556: "+", 0x2557: "+", 70 0x2558: "+", 0x2559: "+", 0x255A: "+", 0x255B: "+", 71 0x255C: "+", 0x255D: "+", 0x255E: "+", 0x255F: "+", 72 0x2560: "+", 0x2561: "+", 0x2562: "+", 0x2563: "+", 73 0x2564: "+", 0x2565: "+", 0x2566: "+", 0x2567: "+", 74 0x2568: "+", 0x2569: "+", 0x256A: "+", 0x256B: "+", 75 0x256C: "+", 76 // Box drawing (rounded corners) 77 0x256D: "+", 0x256E: "+", 0x256F: "+", 0x2570: "+", 78 // Block elements 79 0x2580: "#", 0x2581: "_", 0x2582: "_", 0x2583: "_", 80 0x2584: "#", 0x2585: "#", 0x2586: "#", 0x2587: "#", 81 0x2588: "#", 0x2589: "#", 0x258A: "#", 0x258B: "#", 82 0x258C: "#", 0x258D: "#", 0x258E: "#", 0x258F: "#", 83 0x2590: "#", 0x2591: ".", 0x2592: ":", 0x2593: "#", 84 0x2594: "-", 0x2595: "|", 85 // Arrows 86 0x2190: "<", 0x2191: "^", 0x2192: ">", 0x2193: "v", 87 0x2194: "<>", 0x2195: "^v", 0x2196: "\\", 0x2197: "/", 88 0x2198: "\\", 0x2199: "/", 89 0x21B5: "<-", // ↵ return 90 0x21E6: "<=", 0x21E8: "=>", 91 // Bullets and symbols 92 0x2022: "*", 0x2023: ">", 0x25CF: "*", 0x25CB: "o", 93 0x25A0: "#", 0x25A1: "[]", 0x25AA: ".", 0x25AB: ".", 94 0x25B2: "^", 0x25B6: ">", 0x25BA: ">", 0x25BC: "v", 95 0x25C0: "<", 0x25C4: "<", 96 0x25C6: "*", 0x25C7: "*", 0x25CA: "<>", 97 0x25E6: "o", 0x25EF: "O", 98 // Check marks, crosses, stars 99 0x2714: "+", 0x2715: "x", 0x2716: "x", 0x2718: "x", 100 0x2713: "+", 0x2717: "x", 101 0x2605: "*", 0x2606: "*", 0x2764: "<3", 102 // Math / misc symbols 103 0x2026: "...", 0x00B7: ".", 0x2219: ".", 104 0x00D7: "x", 0x00F7: "/", 0x2260: "!=", 0x2264: "<=", 105 0x2265: ">=", 0x221E: "inf", 0x2248: "~=", 106 // Spinners / braille (Claude Code progress indicators) 107 0x280B: "|", 0x2819: "/", 0x2838: "-", 0x2830: "\\", 108 0x2826: "|", 0x2807: "/", 0x280E: "-", 0x2821: "\\", 109 // Braille patterns — map all to dots/blocks 110 // (range 0x2800-0x28FF used by Claude spinners) 111 // General punctuation 112 0x2018: "'", 0x2019: "'", 0x201C: '"', 0x201D: '"', 113 0x2013: "-", 0x2014: "--", 0x2015: "--", 114 0x2039: "<", 0x203A: ">", 115 // Emoji shorthand (common in Claude output) 116 0x1F512: "[lock]", 0x1F513: "[open]", 117 0x1F4E6: "[pkg]", 0x1F4DD: "[note]", 118 0x1F680: "[go]", 0x1F50D: "[?]", 119 0x2728: "*", 0x26A0: "!!", 0x2699: "[*]", 120 0x1F916: "[bot]", 0x1F4AC: "[..]", 121 0x1F4C1: "[dir]", 0x1F4C4: "[doc]", 122 0x1F527: "[fix]", 0x1F41B: "[bug]", 123 0x2705: "[ok]", 0x274C: "[no]", 124 0x1F6D1: "[stop]", 125 // Currency 126 0x00A3: "L", 0x00A5: "Y", 0x20AC: "E", 127 // Latin extensions 128 0x00E9: "e", 0x00E8: "e", 0x00EA: "e", 0x00EB: "e", 129 0x00E0: "a", 0x00E1: "a", 0x00E2: "a", 0x00E4: "a", 130 0x00F2: "o", 0x00F3: "o", 0x00F4: "o", 0x00F6: "o", 131 0x00F9: "u", 0x00FA: "u", 0x00FB: "u", 0x00FC: "u", 132 0x00ED: "i", 0x00EE: "i", 0x00EF: "i", 133 0x00F1: "n", 0x00E7: "c", 0x00DF: "ss", 134}; 135 136function charReplace(ch) { 137 if (ch >= 32 && ch < 127) return String.fromCharCode(ch); 138 const mapped = UNICODE_MAP[ch]; 139 if (mapped) return mapped; 140 // Braille block (0x2800-0x28FF) — Claude spinners 141 if (ch >= 0x2800 && ch <= 0x28FF) return "."; 142 // Box drawing range catch-all 143 if (ch >= 0x2500 && ch <= 0x257F) return "+"; 144 // Block elements catch-all 145 if (ch >= 0x2580 && ch <= 0x259F) return "#"; 146 // Geometric shapes catch-all 147 if (ch >= 0x25A0 && ch <= 0x25FF) return "*"; 148 // Dingbats catch-all 149 if (ch >= 0x2700 && ch <= 0x27BF) return "*"; 150 // CJK / emoji — skip silently (don't show ?) 151 if (ch >= 0x1F000) return " "; 152 // Everything else unmapped — blank rather than ? 153 if (ch > 127) return " "; 154 return null; 155} 156 157function boot({ system, screen, params }) { 158 cols = Math.floor(screen.width / cellW); 159 rows = Math.floor(screen.height / cellH); 160 161 // What to spawn — check params 162 let cmd = "/bin/sh"; 163 let args = []; 164 // params come from colon-separated jump (e.g. "terminal:claude" → params=["claude"]) 165 // Also check lastCmd global in case params didn't propagate 166 const p0 = (params && params.length > 0 ? params[0] : null) || globalThis.__terminalCmd || null; 167 globalThis.__terminalCmd = undefined; 168 console.log("[terminal] params:", JSON.stringify(params), "p0:", p0, "len:", params ? params.length : "null"); 169 if (p0 === "claude") { 170 // Claude Code native binary 171 // Auth: reads ANTHROPIC_API_KEY from /mnt/config.json "claude_api_key" field 172 // If no key, Claude will show OAuth URL (QR code auto-detected in terminal) 173 cmd = "/bin/claude"; 174 args = ["claude"]; 175 } else if (p0) { 176 cmd = p0; 177 } 178 179 lastCmd = cmd; 180 system.pty.spawn(cmd, args, cols, rows); 181 started = true; 182} 183 184function paint({ wipe, ink, box, write, qr, system, screen }) { 185 const T = __theme.update(); 186 const w = screen.width, h = screen.height; 187 wipe(T.bgDim[0], T.bgDim[1], T.bgDim[2]); 188 cursorBlink++; 189 190 const pty = system.pty; 191 if (!pty.active) { 192 if (pty.exitCode !== undefined) lastExitCode = pty.exitCode; 193 ink(T.fgDim, T.fgDim, T.fgDim); 194 write("terminal exited", { x: 10, y: 10, font: 1 }); 195 196 if (lastExitCode === 127) { 197 ink(T.err[0], T.err[1], T.err[2]); 198 write(`'${lastCmd}' not found (exit 127)`, { x: 10, y: 24, font: 1 }); 199 ink(T.warn[0], T.warn[1], T.warn[2]); 200 write("command missing from initramfs", { x: 10, y: 38, font: 1 }); 201 } else if (lastExitCode === 126) { 202 ink(T.err[0], T.err[1], T.err[2]); 203 write(`'${lastCmd}' permission denied (exit 126)`, { x: 10, y: 24, font: 1 }); 204 } else if (lastExitCode > 128) { 205 ink(T.err[0], T.err[1], T.err[2]); 206 write(`killed by signal ${lastExitCode - 128}`, { x: 10, y: 24, font: 1 }); 207 } else if (lastExitCode >= 0) { 208 ink(lastExitCode === 0 ? T.ok[0] : T.err[0], lastExitCode === 0 ? T.ok[1] : T.warn[1], lastExitCode === 0 ? T.ok[2] : T.warn[2]); 209 write(`exit code: ${lastExitCode}`, { x: 10, y: 24, font: 1 }); 210 } 211 212 // Show last grid content 213 if (grid) { 214 const ptyCols = cols; 215 let textY = 56; 216 ink(T.fgMute, T.fgMute, T.fgMute); 217 write("last output:", { x: 10, y: textY, font: 1 }); 218 textY += 14; 219 for (let y = 0; y < Math.min(rows, 10); y++) { 220 let line = ""; 221 for (let x = 0; x < ptyCols; x++) { 222 const ch = grid[(y * ptyCols + x) * CELL_SIZE]; 223 const rep = charReplace(ch); 224 if (rep) line += rep; 225 else if (line.length > 0) line += " "; 226 } 227 line = line.trimEnd(); 228 if (line.length > 0) { 229 ink(T.warn[0], T.warn[1], T.warn[2]); 230 write(line, { x: 10, y: textY, font: 1 }); 231 textY += 12; 232 } 233 } 234 } 235 236 ink(T.fgMute, T.fgMute, T.fgMute); 237 write("enter: retry esc: back", { x: 10, y: screen.height - 16, font: 1 }); 238 return; 239 } 240 241 // Cache grid data when available 242 if (pty.grid) grid = pty.grid; 243 if (!grid) return; 244 245 const ptyCols = pty.cols || cols; 246 const ptyRows = pty.rows || rows; 247 248 // Check for terminal resize (screen changed since last frame) 249 const newCols = Math.floor(w / cellW); 250 const newRows = Math.floor(h / cellH); 251 if (newCols !== cols || newRows !== rows) { 252 cols = newCols; 253 rows = newRows; 254 if (cols > 0 && rows > 0) system.pty.resize(cols, rows); 255 } 256 257 // Render each cell 258 for (let y = 0; y < ptyRows; y++) { 259 for (let x = 0; x < ptyCols; x++) { 260 const i = (y * ptyCols + x) * CELL_SIZE; 261 const ch = grid[i]; 262 const fg = grid[i + 1]; 263 const bg = grid[i + 2]; 264 const bold = grid[i + 3]; 265 const fg_r = grid[i + 4], fg_g = grid[i + 5], fg_b = grid[i + 6]; 266 const bg_r = grid[i + 7], bg_g = grid[i + 8], bg_b = grid[i + 9]; 267 268 const px = x * cellW; 269 const py = y * cellH; 270 271 // Draw background if not default black 272 const [br, bg2, bb] = bgColor(bg, bg_r, bg_g, bg_b); 273 if (br !== 0 || bg2 !== 0 || bb !== 0) { 274 ink(br, bg2, bb); 275 box(px, py, cellW, cellH); 276 } 277 278 // Draw character (ASCII + Unicode replacements) 279 if (ch > 32) { 280 const rep = charReplace(ch); 281 if (rep) { 282 const [fr, fg2, fb] = fgColor(fg, bold, fg_r, fg_g, fg_b); 283 ink(fr, fg2, fb); 284 write(rep, { x: px, y: py, font: 1 }); 285 } 286 } 287 } 288 } 289 290 // Blinking cursor — only when PTY reports cursor visible (?25h) 291 if (pty.cursorVisible !== false && Math.floor(cursorBlink / 30) % 2 === 0) { 292 const cx = (pty.cursorX || 0) * cellW; 293 const cy = (pty.cursorY || 0) * cellH; 294 // Solid block cursor — always visible regardless of cell color beneath 295 ink(T.fg, T.fg, T.fg); 296 box(cx, cy, cellW, cellH); 297 // Redraw character at cursor position in background color (inverted) so it's readable 298 const ci = ((pty.cursorY || 0) * ptyCols + (pty.cursorX || 0)) * CELL_SIZE; 299 if (ci >= 0 && ci < grid.length) { 300 const ch = grid[ci]; 301 if (ch > 32) { 302 const rep = charReplace(ch); 303 if (rep) { 304 ink(0, 0, 0); 305 write(rep, { x: cx, y: cy, font: 1 }); 306 } 307 } 308 } 309 } 310} 311 312// Keyboard → PTY input mapping 313const SHIFT_MAP = { 314 "1":"!", "2":"@", "3":"#", "4":"$", "5":"%", 315 "6":"^", "7":"&", "8":"*", "9":"(", "0":")", 316 "-":"_", "=":"+", "[":"{", "]":"}", "\\":"|", 317 ";":":", "'":'"', ",":"<", ".":">", "/":"?", "`":"~", 318}; 319 320function keyToAnsi(key) { 321 if (key === "arrowup") return "\x1b[A"; 322 if (key === "arrowdown") return "\x1b[B"; 323 if (key === "arrowright") return "\x1b[C"; 324 if (key === "arrowleft") return "\x1b[D"; 325 if (key === "home") return "\x1b[H"; 326 if (key === "end") return "\x1b[F"; 327 if (key === "pageup") return "\x1b[5~"; 328 if (key === "pagedown") return "\x1b[6~"; 329 if (key === "insert") return "\x1b[2~"; 330 if (key === "delete") return "\x1b[3~"; 331 if (key === "enter" || key === "return") return "\r"; 332 if (key === "backspace") return "\x7f"; 333 if (key === "tab") return "\t"; 334 if (key === "escape") return "\x1b"; 335 if (key === "f1") return "\x1bOP"; 336 if (key === "f2") return "\x1bOQ"; 337 if (key === "f3") return "\x1bOR"; 338 if (key === "f4") return "\x1bOS"; 339 if (key === "f5") return "\x1b[15~"; 340 if (key === "f6") return "\x1b[17~"; 341 if (key === "f7") return "\x1b[18~"; 342 if (key === "f8") return "\x1b[19~"; 343 if (key === "f9") return "\x1b[20~"; 344 if (key === "f10") return "\x1b[21~"; 345 if (key === "f11") return "\x1b[23~"; 346 if (key === "f12") return "\x1b[24~"; 347 return null; 348} 349 350function act({ event: e, system }) { 351 if (e.is("keyboard:down:shift")) { shiftHeld = true; return; } 352 if (e.is("keyboard:up:shift")) { shiftHeld = false; return; } 353 if (e.is("keyboard:down:control")) { ctrlHeld = true; return; } 354 if (e.is("keyboard:up:control")) { ctrlHeld = false; return; } 355 if (e.is("keyboard:down:alt")) { altHeld = true; return; } 356 if (e.is("keyboard:up:alt")) { altHeld = false; return; } 357 358 if (!e.is("keyboard:down")) return; 359 const key = e.key; 360 if (!key) return; 361 362 // If terminal exited: escape goes back, enter retries 363 if (!system.pty.active && started) { 364 if (key === "escape" || key === "backspace") { 365 system?.jump?.("prompt"); 366 return; 367 } 368 if (key === "enter" || key === "return") { 369 system.pty.spawn(lastCmd || "/bin/sh", [], cols, rows); 370 grid = null; 371 return; 372 } 373 return; 374 } 375 376 // Ctrl+N — open split view (left=current cmd, right=sh) 377 if (ctrlHeld && key === "n") { 378 const name = lastCmd.split("/").pop() || "claude"; 379 system.jump("split:" + name); 380 return; 381 } 382 383 // Ctrl+key → control character 384 if (ctrlHeld && key.length === 1) { 385 const code = key.toLowerCase().charCodeAt(0); 386 if (code >= 97 && code <= 122) { 387 system.pty.write(String.fromCharCode(code - 96)); 388 return; 389 } 390 } 391 392 // Special keys → ANSI sequences 393 const ansi = keyToAnsi(key); 394 if (ansi) { 395 system.pty.write(ansi); 396 cursorBlink = 0; 397 return; 398 } 399 400 // Space bar 401 if (key === "space") { 402 system.pty.write(" "); 403 cursorBlink = 0; 404 return; 405 } 406 407 // Printable characters 408 if (key.length === 1) { 409 let ch = key; 410 if (shiftHeld) { 411 if (SHIFT_MAP[ch]) ch = SHIFT_MAP[ch]; 412 else ch = ch.toUpperCase(); 413 } 414 system.pty.write(ch); 415 cursorBlink = 0; 416 } 417} 418 419function leave({ system }) { 420 if (system.pty.active) { 421 system.pty.kill(); 422 } 423} 424 425export { boot, paint, act, leave };