@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.
- package/README.md +18 -39
- package/adapters/claude/hooks/buddy-comment.sh +4 -1
- package/adapters/claude/hooks/name-react.sh +4 -1
- package/adapters/claude/hooks/react.sh +4 -1
- package/adapters/claude/install/backup.ts +36 -118
- package/adapters/claude/install/disable.ts +9 -14
- package/adapters/claude/install/doctor.ts +26 -87
- package/adapters/claude/install/install.ts +39 -66
- package/adapters/claude/install/test-statusline.ts +8 -18
- package/adapters/claude/install/uninstall.ts +18 -26
- package/adapters/claude/plugin/marketplace.json +4 -4
- package/adapters/claude/plugin/plugin.json +3 -5
- package/adapters/claude/server/index.ts +132 -5
- package/adapters/claude/server/path.ts +12 -0
- package/adapters/claude/skills/buddy/SKILL.md +16 -1
- package/adapters/claude/statusline/buddy-status.sh +22 -3
- package/adapters/claude/storage/paths.ts +9 -0
- package/adapters/claude/storage/settings.ts +53 -3
- package/adapters/claude/storage/state.ts +22 -4
- package/adapters/pi/README.md +19 -0
- package/adapters/pi/events.ts +176 -19
- package/adapters/pi/index.ts +3 -1
- package/adapters/pi/logger.ts +52 -0
- package/adapters/pi/prompt.ts +18 -0
- package/adapters/pi/storage.ts +1 -0
- package/cli/biomes.ts +309 -0
- package/cli/buddy-shell.ts +818 -0
- package/cli/index.ts +7 -0
- package/cli/tui.tsx +2244 -0
- package/cli/upgrade.ts +213 -0
- package/core/model.ts +6 -0
- package/package.json +78 -62
- package/scripts/paths.sh +40 -0
- package/server/achievements.ts +15 -0
- package/server/art.ts +1 -0
- package/server/engine.ts +1 -0
- package/server/mcp-launcher.sh +16 -0
- package/server/path.ts +30 -0
- package/server/reactions.ts +1 -0
- package/server/state.ts +3 -0
- package/adapters/claude/popup/buddy-popup.sh +0 -92
- package/adapters/claude/popup/buddy-render.sh +0 -540
- 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(() => {});
|