Monorepo for Aesthetic.Computer
aesthetic.computer
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 };