@ramarivera/coding-buddy 0.4.0-alpha.7 → 0.4.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +18 -39
  2. package/adapters/claude/hooks/buddy-comment.sh +4 -1
  3. package/adapters/claude/hooks/name-react.sh +4 -1
  4. package/adapters/claude/hooks/react.sh +4 -1
  5. package/adapters/claude/install/backup.ts +36 -118
  6. package/adapters/claude/install/disable.ts +9 -14
  7. package/adapters/claude/install/doctor.ts +26 -87
  8. package/adapters/claude/install/install.ts +39 -66
  9. package/adapters/claude/install/test-statusline.ts +8 -18
  10. package/adapters/claude/install/uninstall.ts +18 -26
  11. package/adapters/claude/plugin/marketplace.json +4 -4
  12. package/adapters/claude/plugin/plugin.json +3 -5
  13. package/adapters/claude/server/index.ts +132 -5
  14. package/adapters/claude/server/path.ts +12 -0
  15. package/adapters/claude/skills/buddy/SKILL.md +16 -1
  16. package/adapters/claude/statusline/buddy-status.sh +22 -3
  17. package/adapters/claude/storage/paths.ts +9 -0
  18. package/adapters/claude/storage/settings.ts +53 -3
  19. package/adapters/claude/storage/state.ts +22 -4
  20. package/adapters/pi/README.md +19 -0
  21. package/adapters/pi/events.ts +176 -19
  22. package/adapters/pi/index.ts +3 -1
  23. package/adapters/pi/logger.ts +52 -0
  24. package/adapters/pi/prompt.ts +18 -0
  25. package/adapters/pi/storage.ts +1 -0
  26. package/cli/biomes.ts +309 -0
  27. package/cli/buddy-shell.ts +818 -0
  28. package/cli/index.ts +7 -0
  29. package/cli/tui.tsx +2244 -0
  30. package/cli/upgrade.ts +213 -0
  31. package/core/model.ts +6 -0
  32. package/package.json +78 -62
  33. package/scripts/paths.sh +40 -0
  34. package/server/achievements.ts +15 -0
  35. package/server/art.ts +1 -0
  36. package/server/engine.ts +1 -0
  37. package/server/mcp-launcher.sh +16 -0
  38. package/server/path.ts +30 -0
  39. package/server/reactions.ts +1 -0
  40. package/server/state.ts +3 -0
  41. package/adapters/claude/popup/buddy-popup.sh +0 -92
  42. package/adapters/claude/popup/buddy-render.sh +0 -540
  43. package/adapters/claude/popup/popup-manager.sh +0 -355
@@ -0,0 +1,818 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * buddy-shell — terminal wrapper with fixed buddy panel at bottom.
4
+ *
5
+ * Intercepts specific ANSI sequences from the PTY (alternate screen,
6
+ * screen clear, scroll region reset) and repairs the panel after each.
7
+ * Everything else passes through unmodified.
8
+ *
9
+ * Usage:
10
+ * bun run buddy-shell # preferred — routes through tsx/Node.js
11
+ * npx tsx cli/buddy-shell.ts # same thing, manual
12
+ * npx tsx cli/buddy-shell.ts bash # runs bash instead of claude
13
+ *
14
+ * This script cannot be executed directly by Bun (`bun run <path>`) —
15
+ * node-pty uses libuv functions Bun does not yet implement (oven-sh/bun
16
+ * #18546). The preflight below refuses early with a helpful message
17
+ * rather than letting the Bun runtime panic on the native module load.
18
+ */
19
+
20
+ // Preflight: refuse to run under Bun. Must happen BEFORE the dynamic
21
+ // node-pty import below, otherwise Bun crashes on the module load.
22
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") {
23
+ process.stderr.write(
24
+ "\n ✗ buddy-shell cannot run under Bun — node-pty triggers a known\n" +
25
+ " Bun issue (https://github.com/oven-sh/bun/issues/18546).\n\n" +
26
+ " Use the npm script instead — it routes through Node.js via tsx:\n\n" +
27
+ " bun run buddy-shell\n\n" +
28
+ " Or invoke tsx directly:\n\n" +
29
+ " npx tsx cli/buddy-shell.ts\n\n",
30
+ );
31
+ process.exit(1);
32
+ }
33
+
34
+ import { execSync } from "node:child_process";
35
+ import { readFileSync, existsSync } from "node:fs";
36
+ import { join, dirname } from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+
39
+ import { buddyStateDir } from "../server/path.ts";
40
+ import { getArtFrame, HAT_ART } from "../server/art.ts";
41
+ import type { Species, Eye, Hat } from "../server/engine.ts";
42
+ import { getBiome, listBiomes } from "./biomes.ts";
43
+ import xtermPkg from "@xterm/headless";
44
+ import serializePkg from "@xterm/addon-serialize";
45
+
46
+ // Dynamic import so Bun doesn't try to load node-pty at module-resolution
47
+ // time — the preflight above has already exited if we're under Bun.
48
+ // Using the Homebridge fork rather than Microsoft's upstream because the
49
+ // upstream ships no Linux prebuilds, forcing every Linux user to install
50
+ // node-gyp + Python + build-essential just to run this script.
51
+ const { spawn: ptySpawn } = await import("@homebridge/node-pty-prebuilt-multiarch");
52
+
53
+ const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
54
+ const { Terminal } = xtermPkg as any;
55
+ const { SerializeAddon } = serializePkg as any;
56
+
57
+ if (!process.stdin.isTTY && !process.argv.includes("--biomes")) {
58
+ console.error("buddy-shell requires an interactive terminal (TTY)");
59
+ process.exit(1);
60
+ }
61
+
62
+ // --biomes flag: list all and exit
63
+ if (process.argv.includes("--biomes")) {
64
+ console.log("\nAvailable biomes:\n");
65
+ for (const b of listBiomes()) {
66
+ const tag = b.isDefault ? " (default)" : "";
67
+ console.log(` ${b.name}${tag}`);
68
+ }
69
+ console.log(`\nUsage: npx tsx cli/buddy-shell.ts claude --biome volcano\n`);
70
+ process.exit(0);
71
+ }
72
+
73
+ // Parse --biome <name> from args
74
+ const biomeArgIdx = process.argv.indexOf("--biome");
75
+ const biomeOverride = biomeArgIdx >= 0 ? process.argv[biomeArgIdx + 1] : undefined;
76
+
77
+ const ESC = "\x1b";
78
+ const CSI = `${ESC}[`;
79
+ const moveTo = (r: number, c: number) => `${CSI}${r};${c}H`;
80
+ const clearLine = `${CSI}2K`;
81
+ const setScrollRegion = (top: number, bot: number) => `${CSI}${top};${bot}r`;
82
+ const BOLD = `${CSI}1m`;
83
+ const DIM = `${CSI}2m`;
84
+ const NC = `${CSI}0m`;
85
+ const CYAN = `${CSI}36m`;
86
+ const GREEN = `${CSI}32m`;
87
+ const YELLOW = `${CSI}33m`;
88
+ const MAGENTA = `${CSI}35m`;
89
+ const GRAY = `${CSI}90m`;
90
+
91
+ const RED = `${CSI}31m`;
92
+ const BLUE = `${CSI}34m`;
93
+
94
+ const RARITY_CLR: Record<string, string> = {
95
+ common: GRAY, uncommon: GREEN, rare: BLUE,
96
+ epic: MAGENTA, legendary: YELLOW,
97
+ };
98
+
99
+ const STATE_DIR = buddyStateDir();
100
+
101
+ // ─── xterm cell → ANSI renderer ─────────────────────────────────────────────
102
+ //
103
+ // Converts a single cell's color modes (default/palette/rgb) into the
104
+ // corresponding ANSI escape sequence. Tracks previous attributes so we
105
+ // only emit escape codes when something changes (massive perf win).
106
+
107
+ function fgForCell(cell: any): string {
108
+ if (cell.isFgDefault()) return "39";
109
+ if (cell.isFgRGB()) {
110
+ const color = cell.getFgColor();
111
+ const r = (color >> 16) & 0xff;
112
+ const g = (color >> 8) & 0xff;
113
+ const b = color & 0xff;
114
+ return `38;2;${r};${g};${b}`;
115
+ }
116
+ // Palette (16 or 256)
117
+ return `38;5;${cell.getFgColor()}`;
118
+ }
119
+ function bgForCell(cell: any): string {
120
+ if (cell.isBgDefault()) return "49";
121
+ if (cell.isBgRGB()) {
122
+ const color = cell.getBgColor();
123
+ const r = (color >> 16) & 0xff;
124
+ const g = (color >> 8) & 0xff;
125
+ const b = color & 0xff;
126
+ return `48;2;${r};${g};${b}`;
127
+ }
128
+ return `48;5;${cell.getBgColor()}`;
129
+ }
130
+
131
+ const SCROLLBAR_WIDTH = 2;
132
+ const SCROLLBAR_GAP = 1;
133
+ const SCROLLBAR_RESERVED = SCROLLBAR_WIDTH + SCROLLBAR_GAP;
134
+
135
+ // ─── Custom selection (buffer-coord anchors, survives scroll) ───────────────
136
+
137
+ interface SelPoint { line: number; col: number }
138
+ interface Selection {
139
+ anchor: SelPoint; // where the click started (xterm buffer coords)
140
+ cursor: SelPoint; // where the drag is now
141
+ mode: "char" | "word" | "line";
142
+ dragging: boolean; // mouse button currently held
143
+ }
144
+
145
+ let selection: Selection | null = null;
146
+
147
+ // Multi-click tracking for double-click word / triple-click line selection
148
+ let lastClick: { time: number; line: number; col: number; count: number } | null = null;
149
+ const MULTI_CLICK_MS = 600;
150
+
151
+ function isWordChar(ch: string): boolean {
152
+ return /[\w-]/.test(ch);
153
+ }
154
+
155
+ function charAt(line: number, col: number): string {
156
+ const l = xterm.buffer.active.getLine(line);
157
+ if (!l) return " ";
158
+ const c = l.getCell(col);
159
+ return c?.getChars() || " ";
160
+ }
161
+
162
+ function lineLen(line: number): number {
163
+ return xterm.buffer.active.getLine(line)?.length ?? 0;
164
+ }
165
+
166
+ function wordBoundsAt(line: number, col: number): { start: number; end: number } {
167
+ const len = lineLen(line);
168
+ if (len === 0) return { start: 0, end: 0 };
169
+ const c = Math.min(col, len - 1);
170
+ if (!isWordChar(charAt(line, c))) return { start: c, end: c };
171
+ let start = c;
172
+ while (start > 0 && isWordChar(charAt(line, start - 1))) start--;
173
+ let end = c;
174
+ while (end < len - 1 && isWordChar(charAt(line, end + 1))) end++;
175
+ return { start, end };
176
+ }
177
+
178
+ function rawOrder(): { s: SelPoint; e: SelPoint } | null {
179
+ if (!selection) return null;
180
+ const { anchor, cursor } = selection;
181
+ const sFirst = anchor.line < cursor.line
182
+ || (anchor.line === cursor.line && anchor.col <= cursor.col);
183
+ return sFirst ? { s: anchor, e: cursor } : { s: cursor, e: anchor };
184
+ }
185
+
186
+ function selStart(): SelPoint | null {
187
+ if (!selection) return null;
188
+ const { s } = rawOrder()!;
189
+ if (selection.mode === "word") {
190
+ return { line: s.line, col: wordBoundsAt(s.line, s.col).start };
191
+ }
192
+ if (selection.mode === "line") {
193
+ return { line: s.line, col: 0 };
194
+ }
195
+ return s;
196
+ }
197
+
198
+ function selEnd(): SelPoint | null {
199
+ if (!selection) return null;
200
+ const { e } = rawOrder()!;
201
+ if (selection.mode === "word") {
202
+ return { line: e.line, col: wordBoundsAt(e.line, e.col).end };
203
+ }
204
+ if (selection.mode === "line") {
205
+ return { line: e.line, col: Math.max(0, lineLen(e.line) - 1) };
206
+ }
207
+ return e;
208
+ }
209
+
210
+ function isCellSelected(line: number, col: number): boolean {
211
+ if (!selection) return false;
212
+ const s = selStart()!;
213
+ const e = selEnd()!;
214
+ // Hide empty (single-cell, non-dragged) selections — a plain click
215
+ // shouldn't leave a lingering inverse character on the screen.
216
+ if (!selection.dragging && selection.mode === "char"
217
+ && s.line === e.line && s.col === e.col) return false;
218
+ if (line < s.line || line > e.line) return false;
219
+ if (s.line === e.line) return col >= s.col && col <= e.col;
220
+ if (line === s.line) return col >= s.col;
221
+ if (line === e.line) return col <= e.col;
222
+ return true;
223
+ }
224
+
225
+ function renderScrollbar(term: any, startRow: number, codeRows: number, col: number): string {
226
+ const buf = term.buffer.active;
227
+ if (buf.baseY === 0) return "";
228
+
229
+ const ratio = buf.viewportY / buf.baseY;
230
+ const totalLines = buf.length;
231
+ const thumbSize = Math.max(1, Math.floor((codeRows * codeRows) / totalLines));
232
+ const thumbTop = Math.round(ratio * (codeRows - thumbSize));
233
+
234
+ const out: string[] = [];
235
+ for (let i = 0; i < codeRows; i++) {
236
+ const isThumb = i >= thumbTop && i < thumbTop + thumbSize;
237
+ const seg = isThumb ? `${CSI}36m██${CSI}0m` : `${CSI}90m╎╎${CSI}0m`;
238
+ out.push(moveTo(startRow + i, col - SCROLLBAR_WIDTH + 1) + seg);
239
+ }
240
+ return out.join("");
241
+ }
242
+
243
+ function renderXtermViewport(term: any, startRow: number, codeRows: number, cols: number): string {
244
+ const buf = term.buffer.active;
245
+ const viewportTop = buf.viewportY;
246
+ const out: string[] = [];
247
+
248
+ for (let vy = 0; vy < codeRows; vy++) {
249
+ const bufY = viewportTop + vy;
250
+ const line = buf.getLine(bufY);
251
+ out.push(moveTo(startRow + vy, 1));
252
+ out.push(`${CSI}0m`);
253
+
254
+ if (!line) {
255
+ out.push(" ".repeat(cols));
256
+ continue;
257
+ }
258
+
259
+ let lastAttrs = "";
260
+ let rendered = 0;
261
+
262
+ for (let x = 0; x < Math.min(line.length, cols); x++) {
263
+ const cell = line.getCell(x);
264
+ if (!cell) { out.push(" "); rendered++; continue; }
265
+
266
+ const selected = isCellSelected(bufY, x);
267
+ const parts: string[] = ["0"];
268
+ if (cell.isBold()) parts.push("1");
269
+ if (cell.isDim()) parts.push("2");
270
+ if (cell.isItalic()) parts.push("3");
271
+ if (cell.isUnderline()) parts.push("4");
272
+ if (Boolean(cell.isInverse()) !== selected) parts.push("7"); // XOR
273
+ parts.push(fgForCell(cell));
274
+ parts.push(bgForCell(cell));
275
+ const attrs = parts.join(";");
276
+
277
+ if (attrs !== lastAttrs) {
278
+ out.push(`${CSI}${attrs}m`);
279
+ lastAttrs = attrs;
280
+ }
281
+
282
+ const chars = cell.getChars();
283
+ const width = cell.getWidth();
284
+ if (width === 0) continue;
285
+ out.push(chars || " ");
286
+ rendered += width || 1;
287
+ }
288
+
289
+ // Pad remainder of row with spaces (reset first so bg doesn't bleed)
290
+ if (rendered < cols) {
291
+ out.push(`${CSI}0m`);
292
+ out.push(" ".repeat(cols - rendered));
293
+ }
294
+ }
295
+
296
+ return out.join("");
297
+ }
298
+
299
+ function layout() {
300
+ const cols = process.stdout.columns || 80;
301
+ const rows = process.stdout.rows || 24;
302
+ const panel = Math.max(5, Math.floor(rows * 0.20));
303
+ const code = rows - panel;
304
+ return { cols, rows, panel, code };
305
+ }
306
+
307
+ function loadStatus(): Record<string, any> | null {
308
+ try {
309
+ return JSON.parse(readFileSync(join(STATE_DIR, "status.json"), "utf8"));
310
+ } catch { return null; }
311
+ }
312
+
313
+ function loadStats(): Record<string, any> | null {
314
+ try {
315
+ const m = JSON.parse(readFileSync(join(STATE_DIR, "menagerie.json"), "utf8"));
316
+ return m.companions?.[m.active]?.bones ?? null;
317
+ } catch { return null; }
318
+ }
319
+
320
+ // ─── Render panel + set scroll region ───────────────────────────────────────
321
+
322
+ // ─── Interactive panel state ────────────────────────────────────────────────
323
+
324
+ let panelFocus = false;
325
+ let menuCursor = 0;
326
+ let pauseOutput = false; // when true, swallow PTY output (Claude is "hidden")
327
+ const MENU_ITEMS = ["Dashboard"];
328
+ let panelMessage = "";
329
+
330
+ function setupPanel() {
331
+ const { cols, code, panel } = layout();
332
+ const s = loadStatus();
333
+ const bones = loadStats();
334
+
335
+ const out: string[] = [];
336
+
337
+ // Set scroll region to code area only
338
+ out.push(setScrollRegion(1, code));
339
+
340
+ // Clear panel area
341
+ for (let i = 0; i < panel; i++) {
342
+ out.push(moveTo(code + 1 + i, 1) + clearLine);
343
+ }
344
+
345
+ // Separator line with focus hint
346
+ {
347
+ const clrLine = panelFocus ? `${CSI}33m` : CYAN;
348
+ const label = panelFocus ? " buddy [FOCUS] " : " buddy ";
349
+ const hint = panelFocus ? " esc back " : " Ctrl+Space / F2 to open ";
350
+ const used = label.length + hint.length + 2;
351
+ out.push(moveTo(code + 1, 1) +
352
+ `${clrLine}─${label}${DIM}${hint}${NC}${clrLine}${"─".repeat(Math.max(0, cols - used))}${NC}`);
353
+ }
354
+
355
+ if (!s) {
356
+ out.push(moveTo(code + 2, 1) +
357
+ `${DIM} No buddy. Run: bun run install-buddy${NC}`);
358
+ process.stdout.write(out.join(""));
359
+ return;
360
+ }
361
+
362
+ const clr = RARITY_CLR[s.rarity] ?? GRAY;
363
+ const shiny = s.shiny ? " ✨" : "";
364
+
365
+ // ─── 3-column layout ──────────────────────────────────────────
366
+ //
367
+ // | left: speech bubble | center: buddy art | far right: stats |
368
+ //
369
+
370
+ // Get ASCII art
371
+ let artLines: string[] = [];
372
+ try {
373
+ artLines = getArtFrame(s.species as Species, s.eye as Eye, 0);
374
+ const hatLine = HAT_ART[s.hat as Hat];
375
+ if (hatLine && artLines[0] && !artLines[0].trim()) artLines[0] = hatLine;
376
+ artLines = artLines.filter(l => l.trim());
377
+ } catch {
378
+ artLines = [s.face || "(??)"];
379
+ }
380
+
381
+ const contentRows = panel - 1;
382
+ const artW = 14;
383
+ const artStart = Math.floor(cols / 2) - Math.floor(artW / 2);
384
+
385
+ // ── Speech bubble (simple, above buddy) ──
386
+ let bubbleLines: string[] = [];
387
+ const maxBubbleW = Math.min(40, artStart - 4); // up to 40 chars or available space
388
+ const maxBubbleLines = Math.max(1, contentRows - 2); // leave room for top/bottom border
389
+
390
+ if (s.reaction && !s.muted && maxBubbleW > 8) {
391
+ const text = s.reaction;
392
+ const wrapped: string[] = [];
393
+ let line = "";
394
+ for (const word of text.split(" ")) {
395
+ if (line.length + word.length + 1 > maxBubbleW) {
396
+ if (line) wrapped.push(line);
397
+ line = word.length > maxBubbleW ? word.slice(0, maxBubbleW - 1) + "…" : word;
398
+ } else {
399
+ line = line ? line + " " + word : word;
400
+ }
401
+ }
402
+ if (line) wrapped.push(line);
403
+ const maxLines = Math.min(wrapped.length, maxBubbleLines);
404
+ const bw = Math.max(...wrapped.slice(0, maxLines).map(l => l.length));
405
+ bubbleLines.push(`╭${"─".repeat(bw + 2)}╮`);
406
+ for (let i = 0; i < maxLines; i++) {
407
+ bubbleLines.push(`│ ${wrapped[i].padEnd(bw)} │`);
408
+ }
409
+ bubbleLines.push(`╰${"─".repeat(bw + 2)}╯`);
410
+ }
411
+ const bubbleW = bubbleLines.length > 0 ? bubbleLines[0].length : 0;
412
+ // Position bubble upper-left of the buddy (right edge slightly overlaps buddy's left side)
413
+ const bubbleCol = Math.max(1, artStart + 2 - bubbleW);
414
+
415
+ // ── Right column: name + stats (far right) ──
416
+ const statW = 20;
417
+ const rightStart = cols - statW;
418
+ const rightLines: string[] = [];
419
+ rightLines.push(`${BOLD}${clr}${s.name}${NC}${shiny}`);
420
+ rightLines.push(`${clr}${s.rarity?.toUpperCase()} ${s.species} ${s.stars}${NC}`);
421
+
422
+ if (bones?.stats) {
423
+ for (const [k, v] of Object.entries(bones.stats as Record<string, number>)) {
424
+ const marker = k === bones.peak ? "▲" : k === bones.dump ? "▼" : " ";
425
+ const c = k === bones.peak ? GREEN : k === bones.dump ? `${CSI}31m` : DIM;
426
+ rightLines.push(`${c}${k.padEnd(10)} ${String(v).padStart(3)}${marker}${NC}`);
427
+ }
428
+ }
429
+
430
+ // ── Generate landscape from biome ──
431
+ function renderBgRow(row: number, seed: number, isGround: boolean): string {
432
+ const bgOut: string[] = [];
433
+ bgOut.push(moveTo(row, 1) + clearLine);
434
+ if (isGround) {
435
+ bgOut.push(moveTo(row, 1));
436
+ let line = "";
437
+ const gc = biome.groundChars;
438
+ for (let x = 0; x < cols; x++) {
439
+ line += gc[(x * 13 + 7) % gc.length];
440
+ }
441
+ bgOut.push(`${biome.ground}${line}${NC}`);
442
+ } else {
443
+ const pChars = biome.particle.chars;
444
+ for (let x = 1; x <= cols; x++) {
445
+ const h = ((seed * 31 + x * 17) % 97);
446
+ if (h < 3) {
447
+ bgOut.push(moveTo(row, x) + `${biome.particle.color}${pChars[0]}${NC}`);
448
+ } else if (h < 5 && pChars.length > 1) {
449
+ bgOut.push(moveTo(row, x) + `${biome.particle.color}${pChars[1]}${NC}`);
450
+ } else if (h < 6 && pChars.length > 2) {
451
+ bgOut.push(moveTo(row, x) + `${biome.particle.color}${pChars[2]}${NC}`);
452
+ }
453
+ }
454
+ }
455
+ return bgOut.join("");
456
+ }
457
+
458
+ // Structure from biome (house/lighthouse/tower/etc)
459
+ const biome = getBiome(s.rarity, biomeOverride);
460
+ const structureLines = biome.structure.slice(-contentRows);
461
+ const structureStart = artStart + artW + 2;
462
+
463
+ // Position buddy so feet are on the ground (last art line = last row)
464
+ // If art has 4 lines and contentRows is 4:
465
+ // row 0: art[0] (sky)
466
+ // row 1: art[1] (sky)
467
+ // row 2: art[2] (sky)
468
+ // row 3: art[3] (ground) — feet on grass
469
+ const artOffset = Math.max(0, contentRows - artLines.length);
470
+ const structOffset = Math.max(0, contentRows - structureLines.length);
471
+
472
+ // ── Render rows ──
473
+ for (let i = 0; i < contentRows; i++) {
474
+ const row = code + 2 + i;
475
+ const isLastRow = i === contentRows - 1;
476
+
477
+ // Background: sky or ground
478
+ out.push(renderBgRow(row, i * 3 + 1, isLastRow));
479
+
480
+ // Interactive menu (top-left of panel)
481
+ if (i < MENU_ITEMS.length) {
482
+ const isCursor = panelFocus && i === menuCursor;
483
+ const prefix = isCursor ? `${CSI}33m▸ ` : " ";
484
+ const text = MENU_ITEMS[i];
485
+ const colorOn = panelFocus ? (isCursor ? `${CSI}33m${BOLD}` : `${CSI}37m`) : DIM;
486
+ out.push(moveTo(row, 2) + `${colorOn}${prefix}${text}${NC}`);
487
+ }
488
+
489
+ // Panel message (bottom row if there's a message)
490
+ if (i === contentRows - 1 && panelMessage) {
491
+ out.push(moveTo(row, 2) + `${GREEN}✓ ${panelMessage}${NC}`);
492
+ }
493
+
494
+ // Structure from biome (right of buddy, on the ground)
495
+ const structIdx = i - structOffset;
496
+ if (structIdx >= 0 && structIdx < structureLines.length && structureStart + 16 < rightStart) {
497
+ out.push(moveTo(row, structureStart) + structureLines[structIdx]);
498
+ }
499
+
500
+ // Buddy art (feet on ground) — rendered BEFORE the bubble
501
+ const artIdx = i - artOffset;
502
+ if (artIdx >= 0 && artIdx < artLines.length) {
503
+ out.push(moveTo(row, artStart) + `${clr}${BOLD}${artLines[artIdx]}${NC}`);
504
+ }
505
+
506
+ // Stats (far right)
507
+ if (i < rightLines.length) {
508
+ out.push(moveTo(row, rightStart) + rightLines[i]);
509
+ }
510
+
511
+ // Speech bubble (rendered LAST — highest z-index, always on top)
512
+ if (i < bubbleLines.length && bubbleCol > 0) {
513
+ out.push(moveTo(row, bubbleCol) + `${clr}${bubbleLines[i]}${NC}`);
514
+ }
515
+ }
516
+
517
+ process.stdout.write(out.join(""));
518
+ }
519
+
520
+ // ─── Sequences that destroy our panel ───────────────────────────────────────
521
+
522
+ const DESTRUCTIVE = [
523
+ "\x1b[?1049h", // enter alternate screen
524
+ "\x1b[?1049l", // leave alternate screen
525
+ "\x1b[2J", // clear entire screen
526
+ "\x1b[r", // reset scroll region
527
+ ];
528
+
529
+ function containsDestructive(data: string): boolean {
530
+ return DESTRUCTIVE.some(seq => data.includes(seq));
531
+ }
532
+
533
+ // ─── Main ───────────────────────────────────────────────────────────────────
534
+
535
+ const { cols, code } = layout();
536
+
537
+ process.stdin.setRawMode(true);
538
+ process.stdin.resume();
539
+
540
+ // Enter alternate screen buffer. Since xterm-headless manages Claude's scrollback
541
+ // internally and we intercept mouse wheel to scroll xterm, we don't need the
542
+ // terminal's native scrollback. Alt screen gives us:
543
+ // - No native scrollbar confusion (nothing to show in main buffer)
544
+ // - No resize pollution (alt buffer has no scrollback)
545
+ // - Clean exit (terminal's pre-wrapper content is restored, like vim/htop)
546
+ process.stdout.write(`${CSI}?1049h`);
547
+ process.stdout.write(`${CSI}2J${moveTo(1, 1)}`);
548
+ setupPanel();
549
+
550
+ // Spawn PTY (filter out --biome args)
551
+ const rawArgs = process.argv.slice(2).filter((a, i, arr) =>
552
+ a !== "--biome" && (i === 0 || arr[i - 1] !== "--biome")
553
+ );
554
+ const cmd = rawArgs[0] || "claude";
555
+ const args = rawArgs.slice(1);
556
+
557
+ // Create a virtual xterm terminal for Claude. We feed Claude's PTY output
558
+ // into xterm, which parses ANSI and maintains its own cell buffer + scrollback.
559
+ // Then we render the visible viewport into the top area of the real terminal.
560
+ // This gives us true scrollback isolation — the real terminal's main buffer
561
+ // is not polluted by Claude's output.
562
+ const xterm = new Terminal({
563
+ cols: cols - SCROLLBAR_RESERVED,
564
+ rows: code,
565
+ scrollback: 5000,
566
+ allowProposedApi: true,
567
+ });
568
+
569
+ // Serialize addon lets us save/restore the buffer as an ANSI string.
570
+ // Used on resize: save → clear → resize → restore → Claude's redraw goes on top.
571
+ const serializeAddon = new SerializeAddon();
572
+ xterm.loadAddon(serializeAddon);
573
+
574
+ const pty = ptySpawn(cmd, args, {
575
+ name: "xterm-256color",
576
+ cols: cols - SCROLLBAR_RESERVED,
577
+ rows: code,
578
+ cwd: process.cwd(),
579
+ env: { ...process.env, BUDDY_SHELL: "1" } as Record<string, string>,
580
+ });
581
+
582
+ // Coalesced renderer — we don't re-render on every tiny PTY chunk,
583
+ // we accumulate and render at most every ~16ms (60 fps).
584
+ let renderPending = false;
585
+ // Track what we last told the real terminal so we only send updates on change
586
+ let lastCursorX = -1, lastCursorY = -1;
587
+ let lastCursorVisible = true;
588
+
589
+ function renderNow() {
590
+ renderPending = false;
591
+ if (pauseOutput) return;
592
+ const { cols: c, code: h } = layout();
593
+ const innerCols = c - SCROLLBAR_RESERVED;
594
+ const buf = xterm.buffer.active;
595
+ const isAtBottom = buf.viewportY === buf.baseY;
596
+
597
+ const parts: string[] = [];
598
+ parts.push(`${CSI}?25l`);
599
+ parts.push(renderXtermViewport(xterm, 1, h, innerCols));
600
+ parts.push(renderScrollbar(xterm, 1, h, c));
601
+ if (isAtBottom) {
602
+ parts.push(moveTo(buf.cursorY + 1, buf.cursorX + 1));
603
+ parts.push(`${CSI}?25h`);
604
+ }
605
+ process.stdout.write(parts.join(""));
606
+ }
607
+
608
+ function scheduleRender() {
609
+ if (renderPending) return;
610
+ renderPending = true;
611
+ setTimeout(renderNow, 16);
612
+ }
613
+
614
+ pty.onData((data: string) => {
615
+ xterm.write(data);
616
+ if (!pauseOutput) scheduleRender();
617
+ });
618
+
619
+ // Enable SGR mouse tracking: 1002 = button-event (press + motion while held),
620
+ // 1006 = SGR protocol (distinct sequences from keyboard). We implement our
621
+ // own selection + clipboard because native terminal selection can't stay in
622
+ // sync when we scroll xterm's virtual buffer.
623
+ process.stdout.write(`${CSI}?1002h${CSI}?1006h`);
624
+
625
+ // Keyboard → PTY or panel
626
+ process.stdin.on("data", (data: Buffer) => {
627
+ const s = data.toString();
628
+
629
+ // SGR mouse events: \x1b[<btn;x;y[Mm]
630
+ // M = press/motion, m = release
631
+ // btn 0 = left, btn 32 = motion-with-button, btn 64 = wheel up, btn 65 = wheel down
632
+ // (modifier flags: +4 shift, +8 alt, +16 ctrl)
633
+ const mouseEvents = [...s.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)];
634
+ if (mouseEvents.length > 0 && !panelFocus) {
635
+ const { code: mouseCode } = layout();
636
+ let handled = false;
637
+
638
+ for (const m of mouseEvents) {
639
+ const rawBtn = parseInt(m[1], 10);
640
+ const mx = parseInt(m[2], 10);
641
+ const my = parseInt(m[3], 10);
642
+ const release = m[4] === "m";
643
+ const btn = rawBtn & 3; // 0=left, 1=mid, 2=right, 3=none
644
+ const isMotion = (rawBtn & 32) !== 0;
645
+ const isWheel = (rawBtn & 64) !== 0;
646
+
647
+ // Wheel scroll
648
+ if (isWheel) {
649
+ xterm.scrollLines((rawBtn === 64 ? -3 : 3));
650
+ handled = true;
651
+ continue;
652
+ }
653
+
654
+ // Only handle mouse in the code area (not panel)
655
+ if (my < 1 || my > mouseCode) continue;
656
+
657
+ // Left button only (press/motion/release)
658
+ if (btn === 0 || (release && rawBtn === 0)) {
659
+ const buf = xterm.buffer.active;
660
+ const bufLine = buf.viewportY + my - 1;
661
+ const bufCol = Math.min(mx - 1, buf.getLine(bufLine)?.length ?? mx - 1);
662
+
663
+ if (release) {
664
+ // End of drag — finalize cursor at release point, keep selection visible
665
+ if (selection) {
666
+ selection.cursor = { line: bufLine, col: bufCol };
667
+ selection.dragging = false;
668
+ }
669
+ } else if (isMotion) {
670
+ // Motion with button held — update cursor (only if we have an active drag)
671
+ if (selection && selection.dragging) {
672
+ selection.cursor = { line: bufLine, col: bufCol };
673
+ }
674
+ } else {
675
+ // Press — detect single/double/triple click by time + proximity
676
+ const now = Date.now();
677
+ const sameSpot = lastClick
678
+ && now - lastClick.time < MULTI_CLICK_MS
679
+ && Math.abs(lastClick.line - bufLine) <= 1
680
+ && Math.abs(lastClick.col - bufCol) <= 3;
681
+ const count = sameSpot ? Math.min(lastClick!.count + 1, 3) : 1;
682
+ lastClick = { time: now, line: bufLine, col: bufCol, count };
683
+
684
+ const mode = count === 1 ? "char" : count === 2 ? "word" : "line";
685
+ selection = {
686
+ anchor: { line: bufLine, col: bufCol },
687
+ cursor: { line: bufLine, col: bufCol },
688
+ mode,
689
+ dragging: true,
690
+ };
691
+ }
692
+ handled = true;
693
+ }
694
+ }
695
+
696
+ if (handled) renderNow();
697
+ return;
698
+ }
699
+
700
+ // Ctrl+Space (\x00) or F2 (\x1bOQ or \x1b[12~) — toggle panel focus
701
+ if (s === "\x00" || s === "\x1bOQ" || s === "\x1b[12~") {
702
+ panelFocus = !panelFocus;
703
+ panelMessage = "";
704
+ refreshPanel();
705
+ return;
706
+ }
707
+
708
+ // Panel focus mode (small menu at bottom)
709
+ if (panelFocus) {
710
+ if (s === "\x1b[A") { menuCursor = Math.max(0, menuCursor - 1); refreshPanel(); return; }
711
+ if (s === "\x1b[B") { menuCursor = Math.min(MENU_ITEMS.length - 1, menuCursor + 1); refreshPanel(); return; }
712
+ if (s === "\r" || s === "\n") {
713
+ if (menuCursor === 0) {
714
+ panelFocus = false;
715
+ launchDashboard();
716
+ }
717
+ return;
718
+ }
719
+ if (s === "\x1b") { panelFocus = false; panelMessage = ""; refreshPanel(); return; }
720
+ return;
721
+ }
722
+
723
+ // Normal mode: typing clears any lingering selection, then forwards to PTY
724
+ if (selection) {
725
+ selection = null;
726
+ scheduleRender();
727
+ }
728
+ pty.write(s);
729
+ });
730
+
731
+ function refreshPanel() {
732
+ process.stdout.write(`${ESC}7`);
733
+ setupPanel();
734
+ process.stdout.write(`${ESC}8`);
735
+ }
736
+
737
+ // Launch the full TUI dashboard as a blocking child process.
738
+ //
739
+ // IMPORTANT: we stay in the alt-screen buffer throughout. Ink renders inline
740
+ // (it does not use its own alt-screen), so if we exited to main here the TUI
741
+ // would paint into the user's original terminal — and its remnants would
742
+ // surface when buddy-shell finally exits. By staying in alt, any TUI output
743
+ // is contained in the alt buffer which the terminal discards on exit.
744
+ function launchDashboard() {
745
+ pauseOutput = true;
746
+
747
+ // Release terminal state the TUI conflicts with, but keep alt screen.
748
+ process.stdout.write(`${CSI}?1002l${CSI}?1006l`); // disable our mouse tracking
749
+ process.stdout.write(`${CSI}r`); // reset scroll region
750
+ process.stdout.write(`${CSI}2J${moveTo(1, 1)}`); // clear alt buffer
751
+ process.stdout.write(`${CSI}?25h`); // show cursor
752
+ try { process.stdin.setRawMode(false); } catch {}
753
+
754
+ try {
755
+ execSync("bun run tui", {
756
+ stdio: "inherit",
757
+ cwd: PROJECT_ROOT,
758
+ // Propagate the flag so the TUI can show a "suppressed while in
759
+ // buddy-shell" hint next to the Status Line setting.
760
+ env: { ...process.env, BUDDY_SHELL: "1" },
761
+ });
762
+ } catch {
763
+ // user exited or TUI errored — either way we return to the panel
764
+ }
765
+
766
+ // Re-acquire terminal and redraw our layout.
767
+ try { process.stdin.setRawMode(true); } catch {}
768
+ process.stdout.write(`${CSI}?1002h${CSI}?1006h`); // re-enable mouse
769
+ process.stdout.write(`${CSI}2J${moveTo(1, 1)}`); // wipe any TUI leftovers
770
+
771
+ const { cols: c, code: h } = layout();
772
+ const innerCols = c - SCROLLBAR_RESERVED;
773
+ process.stdout.write(setScrollRegion(1, h));
774
+ process.stdout.write(renderXtermViewport(xterm, 1, h, innerCols));
775
+ process.stdout.write(renderScrollbar(xterm, 1, h, c));
776
+ setupPanel();
777
+
778
+ panelMessage = "";
779
+ pauseOutput = false;
780
+ }
781
+
782
+ // Resize: clear xterm completely (no history preservation — like tmux).
783
+ // Claude's SIGWINCH redraw lands on a clean buffer. Loses conversation
784
+ // history across resize, but avoids ghost echoes.
785
+ process.stdout.on("resize", () => {
786
+ const l = layout();
787
+ const innerCols = l.cols - SCROLLBAR_RESERVED;
788
+ xterm.reset();
789
+ xterm.resize(innerCols, l.code);
790
+ pty.resize(innerCols, l.code);
791
+ process.stdout.write(`${CSI}2J`);
792
+ process.stdout.write(renderXtermViewport(xterm, 1, l.code, innerCols));
793
+ setupPanel();
794
+ pty.write("\x0c");
795
+ });
796
+
797
+ // Periodic panel refresh (repairs gradual damage). Skipped while the TUI
798
+ // dashboard owns the terminal — pauseOutput is set during that window.
799
+ const timer = setInterval(() => {
800
+ if (pauseOutput) return;
801
+ process.stdout.write(`${ESC}7`);
802
+ setupPanel();
803
+ process.stdout.write(`${ESC}8`);
804
+ }, 3000);
805
+
806
+ // Cleanup: leave alt screen — original terminal content comes back
807
+ pty.onExit(({ exitCode }) => {
808
+ clearInterval(timer);
809
+ process.stdout.write(`${CSI}r`); // reset scroll region
810
+ process.stdout.write(`${CSI}?1002l${CSI}?1006l`); // disable mouse tracking
811
+ process.stdout.write(`${CSI}?1049l`); // leave alt screen
812
+ process.stdout.write(`${CSI}?25h`); // show cursor
813
+ try { process.stdin.setRawMode(false); } catch {}
814
+ process.stdin.pause();
815
+ process.exit(exitCode);
816
+ });
817
+
818
+ await new Promise(() => {});