wispy-cli 2.7.27 → 2.7.29

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.
@@ -0,0 +1,1236 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wispy-tui-v3.mjs — pi-tui based TUI for Wispy
4
+ *
5
+ * Replaces the Ink/React TUI with @mariozechner/pi-tui for proper CJK/Korean IME support.
6
+ * pi-tui uses differential rendering and positions the hardware cursor via CURSOR_MARKER
7
+ * so the OS IME candidate window appears at the correct location.
8
+ */
9
+
10
+ import {
11
+ TUI, Container, Box, Text, Editor, Markdown, Loader,
12
+ Spacer, ProcessTerminal, Key, matchesKey, isKeyRelease,
13
+ } from "@mariozechner/pi-tui";
14
+
15
+ import chalk from "chalk";
16
+ import path from "node:path";
17
+ import { readFile, writeFile, readdir, stat, mkdir } from "node:fs/promises";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import {
21
+ WispyEngine,
22
+ CONVERSATIONS_DIR,
23
+ PROVIDERS,
24
+ WISPY_DIR,
25
+ MEMORY_DIR,
26
+ } from "../core/index.mjs";
27
+
28
+ // ─── Version ──────────────────────────────────────────────────────────────────
29
+
30
+ let PKG_VERSION = "?";
31
+ try {
32
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
33
+ const pkgPath = path.join(__dirname, "..", "package.json");
34
+ PKG_VERSION = JSON.parse(await readFile(pkgPath, "utf8")).version ?? "?";
35
+ } catch {}
36
+
37
+ // ─── CLI args ─────────────────────────────────────────────────────────────────
38
+
39
+ const rawArgs = process.argv.slice(2);
40
+ const wsIdx = rawArgs.findIndex((a) => a === "-w" || a === "--workstream");
41
+ const INITIAL_WORKSTREAM =
42
+ process.env.WISPY_WORKSTREAM ??
43
+ (wsIdx !== -1 ? rawArgs[wsIdx + 1] : null) ??
44
+ "default";
45
+
46
+ // ─── Constants ────────────────────────────────────────────────────────────────
47
+
48
+ const VIEWS = ["chat", "overview", "agents", "memory", "audit", "settings"];
49
+ const SIDEBAR_WIDTH = 20;
50
+
51
+ const TOOL_ICONS = {
52
+ read_file: "[file]", write_file: "[edit]", file_edit: "[edit]",
53
+ run_command: "[exec]", git: "[git]", web_search: "[search]",
54
+ web_fetch: "[web]", list_directory: "[dir]", spawn_subagent: "[sub]",
55
+ spawn_agent: "[agent]", memory_save: "[save]", memory_search: "[find]",
56
+ memory_list: "[list]", delete_file: "[delete]", node_execute: "[run]",
57
+ update_work_context: "[update]",
58
+ };
59
+
60
+ // ─── Theme ────────────────────────────────────────────────────────────────────
61
+
62
+ const C = {
63
+ green: (s) => chalk.green(s),
64
+ cyan: (s) => chalk.cyan(s),
65
+ yellow: (s) => chalk.yellow(s),
66
+ red: (s) => chalk.red(s),
67
+ blue: (s) => chalk.blue(s),
68
+ magenta: (s) => chalk.magenta(s),
69
+ white: (s) => chalk.white(s),
70
+ dim: (s) => chalk.dim(s),
71
+ bold: (s) => chalk.bold(s),
72
+ bgGreen: (s) => chalk.bgGreen.black(s),
73
+ bgYellow:(s) => chalk.bgYellow.black(s),
74
+ };
75
+
76
+ const editorTheme = {
77
+ borderColor: (s) => chalk.dim(s),
78
+ selectList: {
79
+ itemText: (s, selected) => selected ? chalk.bold(chalk.cyan(s)) : s,
80
+ selectedText: (s) => chalk.bold(chalk.cyan(s)),
81
+ description: (s) => chalk.dim(s),
82
+ matchHighlight: (s) => chalk.bold(chalk.yellow(s)),
83
+ cursor: chalk.cyan("→ "),
84
+ scrollInfo: (s) => chalk.dim(s),
85
+ noMatch: (s) => chalk.dim(s),
86
+ filterLabel: (s) => chalk.dim(s),
87
+ searchPrompt: (s) => chalk.dim(s),
88
+ searchInput: (s) => s,
89
+ },
90
+ };
91
+
92
+ const markdownTheme = {
93
+ heading: (s) => chalk.bold(chalk.cyan(s)),
94
+ link: (s) => chalk.cyan(s),
95
+ linkUrl: (s) => chalk.dim(s),
96
+ code: (s) => chalk.yellow(s),
97
+ codeBlock: (s) => chalk.white(s),
98
+ codeBlockBorder:(s) => chalk.dim(s),
99
+ quote: (s) => chalk.dim(s),
100
+ quoteBorder: (s) => chalk.dim(s),
101
+ hr: (s) => chalk.dim(s),
102
+ listBullet: (s) => chalk.green(s),
103
+ bold: (s) => chalk.bold(s),
104
+ italic: (s) => chalk.italic(s),
105
+ strikethrough: (s) => chalk.strikethrough(s),
106
+ underline: (s) => chalk.underline(s),
107
+ };
108
+
109
+ // ─── Utilities ────────────────────────────────────────────────────────────────
110
+
111
+ function fmtTime(iso) {
112
+ if (!iso) return "";
113
+ try {
114
+ return new Date(iso).toLocaleTimeString("en-US", {
115
+ hour12: false, hour: "2-digit", minute: "2-digit",
116
+ });
117
+ } catch { return ""; }
118
+ }
119
+
120
+ function fmtRelTime(iso) {
121
+ if (!iso) return "";
122
+ try {
123
+ const diff = Date.now() - new Date(iso).getTime();
124
+ if (diff < 60_000) return "just now";
125
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}min ago`;
126
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}hr ago`;
127
+ return "yesterday";
128
+ } catch { return ""; }
129
+ }
130
+
131
+ function fmtDuration(ms) {
132
+ if (!ms || ms < 0) return "";
133
+ if (ms < 1000) return `${ms}ms`;
134
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
135
+ return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`;
136
+ }
137
+
138
+ function truncate(str, n) {
139
+ if (!str) return "";
140
+ return str.length > n ? str.slice(0, n - 1) + "…" : str;
141
+ }
142
+
143
+ function padRight(str, n) {
144
+ // visible-width pad (naive, good enough for ASCII+labels)
145
+ const visible = str.replace(/\x1b\[[^m]*m/g, "").replace(/\x1b_pi:c\x07/g, "");
146
+ const need = n - visible.length;
147
+ return str + (need > 0 ? " ".repeat(need) : "");
148
+ }
149
+
150
+ // ─── Persistence helpers ──────────────────────────────────────────────────────
151
+
152
+ async function loadConversation(workstream) {
153
+ const file = path.join(CONVERSATIONS_DIR, `${workstream}.json`);
154
+ try {
155
+ const raw = await readFile(file, "utf8");
156
+ return JSON.parse(raw);
157
+ } catch { return []; }
158
+ }
159
+
160
+ async function saveConversation(workstream, messages) {
161
+ await mkdir(CONVERSATIONS_DIR, { recursive: true });
162
+ const file = path.join(CONVERSATIONS_DIR, `${workstream}.json`);
163
+ await writeFile(
164
+ file,
165
+ JSON.stringify(messages.slice(-50), null, 2) + "\n",
166
+ "utf8"
167
+ );
168
+ }
169
+
170
+ // ─── Data loaders ─────────────────────────────────────────────────────────────
171
+
172
+ async function loadWorkstreams() {
173
+ try {
174
+ const wsDir = path.join(WISPY_DIR, "workstreams");
175
+ const dirs = await readdir(wsDir);
176
+ const result = [];
177
+ for (const d of dirs) {
178
+ try {
179
+ const s = await stat(path.join(wsDir, d));
180
+ if (s.isDirectory()) result.push(d);
181
+ } catch {}
182
+ }
183
+ return result.length ? result : ["default"];
184
+ } catch { return ["default"]; }
185
+ }
186
+
187
+ async function loadMemoryFiles() {
188
+ try {
189
+ const files = await readdir(MEMORY_DIR);
190
+ const result = [];
191
+ for (const f of files) {
192
+ if (!f.endsWith(".md")) continue;
193
+ const fp = path.join(MEMORY_DIR, f);
194
+ try {
195
+ const s = await stat(fp);
196
+ const raw = await readFile(fp, "utf8");
197
+ result.push({
198
+ key: f.replace(".md", ""),
199
+ preview: raw.split("\n").find(l => l.trim() && !l.startsWith("_")) ?? "",
200
+ size: s.size,
201
+ updatedAt: s.mtime.toISOString(),
202
+ });
203
+ } catch {}
204
+ }
205
+ return result;
206
+ } catch { return []; }
207
+ }
208
+
209
+ async function loadOverviewData(workstreams) {
210
+ const result = {};
211
+ for (const ws of workstreams) {
212
+ try {
213
+ const convFile = path.join(CONVERSATIONS_DIR, `${ws}.json`);
214
+ const raw = await readFile(convFile, "utf8");
215
+ const conv = JSON.parse(raw);
216
+ const lastMsg = conv.filter(m => m.role === "assistant").pop();
217
+ const fileStat = await stat(convFile).catch(() => null);
218
+ result[ws] = {
219
+ lastActivity: fileStat?.mtime?.toISOString() ?? null,
220
+ lastMessage: lastMsg?.content?.slice(0, 80) ?? null,
221
+ agents: 0,
222
+ };
223
+ } catch {
224
+ result[ws] = { lastActivity: null, lastMessage: null, agents: 0 };
225
+ }
226
+ try {
227
+ const workMdPath = path.join(WISPY_DIR, "workstreams", ws, "work.md");
228
+ const wmd = await readFile(workMdPath, "utf8");
229
+ result[ws].workMd = wmd.split("\n").find(l => l.trim())?.slice(0, 60);
230
+ } catch {}
231
+ }
232
+ return result;
233
+ }
234
+
235
+ async function loadAgents() {
236
+ try {
237
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
238
+ const raw = await readFile(agentsFile, "utf8");
239
+ const all = JSON.parse(raw);
240
+ return all.filter(a =>
241
+ ["running","pending","completed","done"].includes(a.status)
242
+ ).slice(-10);
243
+ } catch { return []; }
244
+ }
245
+
246
+ // ─── Custom components ────────────────────────────────────────────────────────
247
+
248
+ /**
249
+ * A simple component that renders pre-built lines (strings[]).
250
+ */
251
+ class RawLines {
252
+ constructor() {
253
+ this._lines = [];
254
+ }
255
+ setLines(lines) { this._lines = lines; }
256
+ invalidate() {}
257
+ render(_width) { return this._lines; }
258
+ }
259
+
260
+ /**
261
+ * ScrollableContent — wraps a Container-like set of lines with vertical scrolling.
262
+ * We render the full content and then slice to visible height.
263
+ */
264
+ class ScrollableContent {
265
+ constructor() {
266
+ this._lines = [];
267
+ this._scrollTop = 0;
268
+ this._height = 20;
269
+ }
270
+
271
+ setLines(lines) {
272
+ this._lines = lines;
273
+ // Auto-scroll to bottom
274
+ this._scrollTop = Math.max(0, this._lines.length - this._height);
275
+ }
276
+
277
+ setHeight(h) {
278
+ this._height = h;
279
+ this._scrollTop = Math.max(0, this._lines.length - this._height);
280
+ }
281
+
282
+ scrollUp() { this._scrollTop = Math.max(0, this._scrollTop - 3); }
283
+ scrollDown() { this._scrollTop = Math.min(Math.max(0, this._lines.length - this._height), this._scrollTop + 3); }
284
+
285
+ invalidate() {}
286
+
287
+ render(width) {
288
+ const visible = this._lines.slice(this._scrollTop, this._scrollTop + this._height);
289
+ // Pad to height
290
+ while (visible.length < this._height) visible.push("");
291
+ return visible;
292
+ }
293
+ }
294
+
295
+ // ─── WispyTUI — main class ────────────────────────────────────────────────────
296
+
297
+ class WispyTUI {
298
+ constructor() {
299
+ this.terminal = new ProcessTerminal();
300
+ this.tui = new TUI(this.terminal, true); // true = show hardware cursor
301
+
302
+ this.engine = null;
303
+ this.messages = []; // { role: "user"|"assistant"|"system_info", content }
304
+ this.timeline = []; // tool call events
305
+ this.loading = false;
306
+ this.view = "chat";
307
+
308
+ // Sidebar state
309
+ this.workstreams = [INITIAL_WORKSTREAM];
310
+ this.activeWorkstream = INITIAL_WORKSTREAM;
311
+ this.memoryFiles = [];
312
+ this.agents = [];
313
+ this.budgetSpent = 0;
314
+ this.maxBudget = null;
315
+ this.browserStatus = null;
316
+
317
+ // Overview
318
+ this.overviewData = {};
319
+
320
+ // Conversation ref for persistence
321
+ this._conversation = [];
322
+
323
+ // Pending approval
324
+ this._approvalResolver = null;
325
+ this._pendingApproval = null;
326
+
327
+ // Components
328
+ this.statusBarText = null;
329
+ this.sidebarLines = null;
330
+ this.mainContent = null;
331
+ this.inputEditor = null;
332
+ this.loaderComp = null;
333
+ this.mainScrollable = null;
334
+
335
+ // Refresh intervals
336
+ this._intervals = [];
337
+ }
338
+
339
+ // ─── Init ──────────────────────────────────────────────────────────────────
340
+
341
+ async init() {
342
+ this.engine = new WispyEngine({ workstream: this.activeWorkstream });
343
+ const ok = await this.engine.init();
344
+ if (!ok) {
345
+ console.error("No API key found. Run `wispy setup` first.");
346
+ process.exit(1);
347
+ }
348
+
349
+ // Load initial conversation
350
+ this._conversation = await loadConversation(this.activeWorkstream);
351
+ this.messages = this._conversation.map(m => ({ ...m }));
352
+
353
+ // Load sidebar data
354
+ await this._refreshSidebarData();
355
+
356
+ // Build UI
357
+ this._buildUI();
358
+
359
+ // Wire engine events
360
+ this._wireEngineEvents();
361
+
362
+ // Set up approval handler
363
+ this.engine.permissions.setApprovalHandler(async (action) => {
364
+ return new Promise((resolve) => {
365
+ this._approvalResolver = resolve;
366
+ this._pendingApproval = action;
367
+ this._updateMainContent();
368
+ this.tui.requestRender();
369
+ });
370
+ });
371
+
372
+ // Periodic refresh
373
+ const sid1 = setInterval(() => this._refreshSidebarData().then(() => this.tui.requestRender()), 15_000);
374
+ const sid2 = setInterval(() => this._refreshAgents().then(() => this.tui.requestRender()), 10_000);
375
+ this._intervals.push(sid1, sid2);
376
+
377
+ // Initial render
378
+ this._updateAll();
379
+ this.tui.requestRender();
380
+ }
381
+
382
+ // ─── UI build ─────────────────────────────────────────────────────────────
383
+
384
+ _buildUI() {
385
+ const tui = this.tui;
386
+
387
+ // Root container (vertical stack)
388
+ const root = new Container();
389
+
390
+ // 1. Status bar
391
+ this.statusBarText = new Text("", 0, 0, (line) => chalk.bgGreen.black(line));
392
+ root.addChild(this.statusBarText);
393
+
394
+ // 2. Content area (sidebar + main)
395
+ // We'll render it as a custom component that does column layout
396
+ this.contentArea = new ContentArea(this);
397
+ root.addChild(this.contentArea);
398
+
399
+ // 3. Loader (hidden by default, shown when loading)
400
+ this.loaderComp = new Loader(
401
+ tui,
402
+ (s) => chalk.yellow(s),
403
+ (s) => chalk.dim(s),
404
+ " thinking..."
405
+ );
406
+
407
+ // 4. Input editor
408
+ this.inputEditor = new WispyEditor(tui, editorTheme, this);
409
+
410
+ // Initially loader is not added — we add/remove dynamically
411
+ root.addChild(this.inputEditor);
412
+
413
+ tui.addChild(root);
414
+ tui.setFocus(this.inputEditor);
415
+
416
+ this._root = root;
417
+ this._loaderActive = false;
418
+ }
419
+
420
+ _setLoaderActive(active) {
421
+ if (active === this._loaderActive) return;
422
+ this._loaderActive = active;
423
+ const root = this._root;
424
+ // Remove all except statusBar and contentArea
425
+ root.clear();
426
+ root.addChild(this.statusBarText);
427
+ root.addChild(this.contentArea);
428
+ if (active) {
429
+ this.loaderComp.start();
430
+ root.addChild(this.loaderComp);
431
+ } else {
432
+ this.loaderComp.stop();
433
+ }
434
+ root.addChild(this.inputEditor);
435
+ }
436
+
437
+ // ─── Data refresh ─────────────────────────────────────────────────────────
438
+
439
+ async _refreshSidebarData() {
440
+ const [wsList, memFiles] = await Promise.all([
441
+ loadWorkstreams(),
442
+ loadMemoryFiles(),
443
+ ]);
444
+ const wsSet = new Set(wsList);
445
+ wsSet.add(this.activeWorkstream);
446
+ this.workstreams = Array.from(wsSet);
447
+ this.memoryFiles = memFiles;
448
+
449
+ try {
450
+ this.browserStatus = this.engine?.browser?.status?.() ?? null;
451
+ } catch {}
452
+ try {
453
+ const bud = this.engine?.budget;
454
+ if (bud) {
455
+ this.budgetSpent = bud.sessionSpend ?? 0;
456
+ this.maxBudget = bud.maxBudgetUsd ?? null;
457
+ }
458
+ } catch {}
459
+ }
460
+
461
+ async _refreshAgents() {
462
+ this.agents = await loadAgents();
463
+ }
464
+
465
+ // ─── Engine events ─────────────────────────────────────────────────────────
466
+
467
+ _wireEngineEvents() {
468
+ const harness = this.engine?.harness;
469
+ if (!harness) return;
470
+
471
+ harness.on("tool:start", ({ toolName, args }) => {
472
+ const primaryArg = args?.path ?? args?.command ?? args?.query ?? args?.task ?? args?.key ?? "";
473
+ this.timeline.push({
474
+ toolName, args, primaryArg,
475
+ timestamp: new Date().toISOString(),
476
+ success: null, denied: false,
477
+ });
478
+ this._updateMainContent();
479
+ this.tui.requestRender();
480
+ });
481
+
482
+ harness.on("tool:complete", ({ toolName, receipt }) => {
483
+ for (let i = this.timeline.length - 1; i >= 0; i--) {
484
+ if (this.timeline[i].toolName === toolName && this.timeline[i].success === null) {
485
+ this.timeline[i] = { ...this.timeline[i], success: receipt?.success ?? true };
486
+ break;
487
+ }
488
+ }
489
+ this._updateMainContent();
490
+ this.tui.requestRender();
491
+ });
492
+
493
+ harness.on("tool:denied", ({ toolName }) => {
494
+ for (let i = this.timeline.length - 1; i >= 0; i--) {
495
+ if (this.timeline[i].toolName === toolName && this.timeline[i].success === null) {
496
+ this.timeline[i] = { ...this.timeline[i], success: false, denied: true };
497
+ break;
498
+ }
499
+ }
500
+ this._updateMainContent();
501
+ this.tui.requestRender();
502
+ });
503
+ }
504
+
505
+ // ─── Content rendering ─────────────────────────────────────────────────────
506
+
507
+ _updateAll() {
508
+ this._updateStatusBar();
509
+ this._updateMainContent();
510
+ }
511
+
512
+ _updateStatusBar() {
513
+ const ws = this.activeWorkstream;
514
+ const model = this.engine?.model ?? "?";
515
+ const permMode = this.engine?.permissions?.getPolicy?.("run_command") ?? "approve";
516
+ const cost = this.budgetSpent;
517
+ const costStr = cost > 0 ? ` $${cost.toFixed(4)}` : "";
518
+ const viewStr = `[${this.view.toUpperCase()}] ? for help`;
519
+ this.statusBarText.setText(
520
+ ` Wispy ─ ${ws} ─ ${truncate(model, 20)} ─ ${permMode}${costStr} ${viewStr}`
521
+ );
522
+ }
523
+
524
+ /**
525
+ * Build sidebar lines (returns string[])
526
+ */
527
+ _buildSidebarLines(sidebarWidth) {
528
+ const lines = [];
529
+ const w = sidebarWidth;
530
+
531
+ const div = "─".repeat(w);
532
+
533
+ // WORKSTREAMS
534
+ lines.push(C.dim(" WORKSTREAMS"));
535
+ for (const ws of this.workstreams) {
536
+ const isActive = ws === this.activeWorkstream;
537
+ const indicator = isActive ? C.green("●") : C.dim("◯");
538
+ const label = truncate(ws, w - 3);
539
+ const line = ` ${indicator} ${isActive ? C.bold(label) : C.dim(label)}`;
540
+ lines.push(line);
541
+ }
542
+
543
+ lines.push(C.dim(div));
544
+
545
+ // AGENTS
546
+ lines.push(`${C.dim(" AGENTS ")}${this.agents.length > 0 ? C.yellow(String(this.agents.length)) : C.dim("0")}`);
547
+ if (this.agents.length === 0) {
548
+ lines.push(C.dim(" (none)"));
549
+ } else {
550
+ for (const a of this.agents.slice(0, 3)) {
551
+ const isRunning = a.status === "running";
552
+ const dot = isRunning ? C.green("●") : C.dim("◯");
553
+ lines.push(` ${dot} ${truncate(a.label ?? a.role ?? "agent", w - 4)}`);
554
+ }
555
+ }
556
+
557
+ lines.push(C.dim(div));
558
+
559
+ // MEMORY
560
+ lines.push(`${C.dim(" MEMORY ")}${C.cyan(String(this.memoryFiles.length))}`);
561
+
562
+ lines.push(C.dim(div));
563
+
564
+ // BROWSER
565
+ lines.push(C.dim(" BROWSER"));
566
+ if (this.browserStatus?.session) {
567
+ lines.push(` ${C.green("●")} ${truncate(this.browserStatus.session.browser ?? "connected", w - 4)}`);
568
+ } else {
569
+ lines.push(C.dim(" ◯ off"));
570
+ }
571
+
572
+ lines.push(C.dim(div));
573
+
574
+ // BUDGET
575
+ lines.push(C.dim(" BUDGET"));
576
+ lines.push(
577
+ this.budgetSpent > 0
578
+ ? ` ${C.yellow(`$${this.budgetSpent.toFixed(3)}${this.maxBudget ? `/$${this.maxBudget.toFixed(2)}` : ""}`)}`
579
+ : C.dim(" $0.000")
580
+ );
581
+
582
+ return lines;
583
+ }
584
+
585
+ /**
586
+ * Build main content lines for current view (returns string[])
587
+ */
588
+ _buildMainContentLines(mainWidth) {
589
+ switch (this.view) {
590
+ case "chat": return this._buildChatLines(mainWidth);
591
+ case "overview": return this._buildOverviewLines(mainWidth);
592
+ case "agents": return this._buildAgentsLines(mainWidth);
593
+ case "memory": return this._buildMemoryLines(mainWidth);
594
+ case "audit": return this._buildAuditLines(mainWidth);
595
+ case "settings": return this._buildSettingsLines(mainWidth);
596
+ default: return this._buildChatLines(mainWidth);
597
+ }
598
+ }
599
+
600
+ _buildChatLines(width) {
601
+ const lines = [];
602
+
603
+ if (this._pendingApproval) {
604
+ return this._buildApprovalLines(width);
605
+ }
606
+
607
+ if (this.messages.length === 0) {
608
+ lines.push("");
609
+ lines.push(C.dim(" Wispy — AI workspace assistant"));
610
+ lines.push(C.dim(" Type a message to start. ? for help."));
611
+ return lines;
612
+ }
613
+
614
+ const display = this.messages.slice(-30);
615
+ for (const msg of display) {
616
+ if (msg.role === "user") {
617
+ lines.push("");
618
+ lines.push(` ${C.green(C.bold("you ›"))} ${msg.content}`);
619
+ } else if (msg.role === "assistant") {
620
+ lines.push("");
621
+ lines.push(C.cyan(C.bold(" wispy ›")));
622
+ // Simple markdown rendering using plain text (full Markdown component used in content area)
623
+ const msgLines = msg.content.split("\n");
624
+ for (const l of msgLines) {
625
+ if (l.startsWith("# ")) lines.push(` ${C.bold(C.cyan(l.slice(2)))}`);
626
+ else if (l.startsWith("## ")) lines.push(` ${C.bold(C.blue(l.slice(3)))}`);
627
+ else if (l.startsWith("### "))lines.push(` ${C.bold(l.slice(4))}`);
628
+ else if (l.startsWith("- ") || l.startsWith("* ")) lines.push(` ${C.green("•")} ${l.slice(2)}`);
629
+ else if (l.startsWith("```")) lines.push(` ${C.dim(l)}`);
630
+ else if (l.includes("**")) lines.push(" " + l.replace(/\*\*([^*]+)\*\*/g, (_, t) => C.bold(t)));
631
+ else if (l === "") lines.push("");
632
+ else lines.push(" " + l);
633
+ }
634
+ } else if (msg.role === "tool_call") {
635
+ const icon = TOOL_ICONS[msg.name] ?? "[tool]";
636
+ const status = msg.receipt
637
+ ? (msg.receipt.success ? C.green("✓") : C.red("✗"))
638
+ : C.yellow("●");
639
+ const arg = msg.args?.path ?? msg.args?.command ?? msg.args?.query ?? "";
640
+ lines.push(` ${status} ${C.cyan(`${icon} ${msg.name}`)}${arg ? C.dim(` → ${truncate(arg, 35)}`) : ""}`);
641
+ } else if (msg.role === "system_info") {
642
+ lines.push(C.dim(` ℹ ${msg.content}`));
643
+ }
644
+ }
645
+
646
+ return lines;
647
+ }
648
+
649
+ _buildApprovalLines(width) {
650
+ const action = this._pendingApproval;
651
+ const lines = [];
652
+ lines.push("");
653
+ lines.push(C.yellow(" ! Permission Required"));
654
+ lines.push("");
655
+ lines.push(` ${C.dim("Tool: ")} ${C.bold(action.toolName ?? "?")}`);
656
+ const arg = action.args?.command ?? action.args?.path ?? JSON.stringify(action.args ?? {}).slice(0, 50);
657
+ if (arg) lines.push(` ${C.dim("Action: ")} ${truncate(arg, width - 14)}`);
658
+ lines.push("");
659
+ lines.push(` ${C.green(C.bold("[Y] Approve"))} ${C.red(C.bold("[N] Deny"))} ${C.cyan(C.bold("[D] Dry-run"))}`);
660
+ lines.push("");
661
+ return lines;
662
+ }
663
+
664
+ _buildOverviewLines(mainWidth) {
665
+ const lines = [];
666
+ lines.push(` ${C.bold(C.green("Workstream Overview"))}`);
667
+ lines.push("");
668
+ lines.push(` ${C.bold(C.cyan("Browser"))}`);
669
+ if (this.browserStatus?.session) {
670
+ lines.push(` ${C.green("●")} Connected — ${this.browserStatus.session.browser ?? "unknown"}`);
671
+ } else {
672
+ lines.push(C.dim(" ◯ Not connected"));
673
+ }
674
+ lines.push("");
675
+ lines.push(" " + C.dim("─".repeat(Math.min(40, mainWidth - 4))));
676
+ lines.push("");
677
+ for (const ws of this.workstreams) {
678
+ const data = this.overviewData[ws] ?? {};
679
+ const isActive = ws === this.activeWorkstream;
680
+ const dot = isActive ? C.green("●") : C.dim("◯");
681
+ const wsLabel = isActive ? C.bold(C.green(ws)) : ws;
682
+ const rel = data.lastActivity ? C.dim(` ${fmtRelTime(data.lastActivity)}`) : "";
683
+ lines.push(` ${dot} ${wsLabel}${rel}`);
684
+ if (data.lastMessage) {
685
+ lines.push(` ${C.dim("└── ")}${truncate(data.lastMessage, mainWidth - 10)}`);
686
+ }
687
+ if (data.workMd) {
688
+ lines.push(` ${C.dim("└── ")}${C.dim(`"${truncate(data.workMd, mainWidth - 20)}"`)}`);
689
+ }
690
+ lines.push("");
691
+ }
692
+ return lines;
693
+ }
694
+
695
+ _buildAgentsLines(mainWidth) {
696
+ const lines = [];
697
+ lines.push(` ${C.bold(C.green("Sub-Agents"))}`);
698
+ lines.push("");
699
+ if (this.agents.length === 0) {
700
+ lines.push(C.dim(" No sub-agents recorded."));
701
+ return lines;
702
+ }
703
+ const running = this.agents.filter(a => a.status === "running");
704
+ const done = this.agents.filter(a => ["completed","done"].includes(a.status));
705
+ lines.push(` ${running.length} running, ${done.length} completed`);
706
+ lines.push("");
707
+ for (const a of this.agents) {
708
+ const color = a.status === "running" ? C.green : a.status === "pending" ? C.yellow : C.dim;
709
+ const icon = a.status === "running" ? "●" : a.status === "completed" || a.status === "done" ? "✓" : "◯";
710
+ lines.push(` ${color(icon + " " + truncate(a.label ?? a.id ?? "agent", 20))} ${C.dim(`[${a.model ?? "?"}]`)}`);
711
+ if (a.task) lines.push(` ${C.dim(truncate(a.task, 60))}`);
712
+ lines.push("");
713
+ }
714
+ return lines;
715
+ }
716
+
717
+ _buildMemoryLines(mainWidth) {
718
+ const lines = [];
719
+ lines.push(` ${C.bold(C.green("Memory Files"))} ${C.dim(`(${this.memoryFiles.length})`)}`);
720
+ lines.push("");
721
+ if (this.memoryFiles.length === 0) {
722
+ lines.push(C.dim(" No memory files found."));
723
+ return lines;
724
+ }
725
+ for (const f of this.memoryFiles.slice(0, 20)) {
726
+ const size = f.size ? C.dim(` (${(f.size / 1024).toFixed(1)}KB)`) : "";
727
+ const rel = f.updatedAt ? C.dim(` ${fmtRelTime(f.updatedAt)}`) : "";
728
+ lines.push(` ${C.cyan(f.key)}${size}${rel}`);
729
+ if (f.preview) lines.push(` ${C.dim(truncate(f.preview, mainWidth - 6))}`);
730
+ }
731
+ return lines;
732
+ }
733
+
734
+ _buildAuditLines(mainWidth) {
735
+ const lines = [];
736
+ lines.push(` ${C.bold(C.green("Action Timeline"))}`);
737
+ lines.push("");
738
+ if (this.timeline.length === 0) {
739
+ lines.push(C.dim(" No actions recorded yet."));
740
+ return lines;
741
+ }
742
+ for (const evt of [...this.timeline].reverse().slice(0, 30)) {
743
+ const icon = TOOL_ICONS[evt.toolName] ?? "[tool]";
744
+ const statusIcon = evt.denied ? "✗" : evt.success ? "✓" : "●";
745
+ const color = evt.denied ? C.red : evt.success ? C.green : C.yellow;
746
+ const ts = fmtTime(evt.timestamp);
747
+ const arg = evt.primaryArg ? C.dim(` → ${truncate(evt.primaryArg, 25)}`) : "";
748
+ lines.push(` ${C.dim(ts)} ${C.dim(icon)} ${C.cyan(evt.toolName)}${arg} ${color(statusIcon)}`);
749
+ }
750
+ return lines;
751
+ }
752
+
753
+ _buildSettingsLines(mainWidth) {
754
+ const lines = [];
755
+ lines.push(` ${C.bold(C.green("Settings"))}`);
756
+ lines.push("");
757
+ lines.push(` ${C.bold(C.cyan("General"))}`);
758
+ const entries = [
759
+ ["Version", PKG_VERSION],
760
+ ["Config dir", path.join(process.env.HOME ?? "~", ".wispy")],
761
+ ["Provider", this.engine?.provider ?? "?"],
762
+ ["Model", this.engine?.model ?? "?"],
763
+ ["Security", this.engine?.permissions?.getPolicy?.("run_command") ?? "approve"],
764
+ ["Workstream", this.activeWorkstream],
765
+ ];
766
+ for (const [k, v] of entries) {
767
+ lines.push(` ${C.dim(k.padEnd(14))} ${v ?? "?"}`);
768
+ }
769
+ lines.push("");
770
+ lines.push(` ${C.bold(C.cyan("Budget"))}`);
771
+ lines.push(` ${C.dim("Spent".padEnd(14))} ${this.budgetSpent > 0 ? C.yellow(`$${this.budgetSpent.toFixed(4)}`) : "$0.0000"}${this.maxBudget ? ` / $${this.maxBudget.toFixed(2)}` : ""}`);
772
+ lines.push("");
773
+ lines.push(` ${C.bold(C.cyan("Browser"))}`);
774
+ if (this.browserStatus?.session) {
775
+ lines.push(` ${C.green("●")} connected (${this.browserStatus.session.browser ?? "unknown"})`);
776
+ } else {
777
+ lines.push(C.dim(" ◯ disconnected"));
778
+ }
779
+ lines.push("");
780
+ lines.push(C.dim(" Commands: /model <n> /trust <level> /agent <n> /cost /clear"));
781
+ return lines;
782
+ }
783
+
784
+ _buildHelpLines() {
785
+ return [
786
+ "",
787
+ ` ${C.bold(C.cyan("Wispy — Keyboard Shortcuts"))}`,
788
+ "",
789
+ ` ${C.yellow("Tab")} Switch view`,
790
+ ` ${C.yellow("1-6")} Jump to view (1=chat 2=overview 3=agents 4=memory 5=audit 6=settings)`,
791
+ ` ${C.yellow("Ctrl+C")} Quit`,
792
+ ` ${C.yellow("Ctrl+L")} Clear chat`,
793
+ ` ${C.yellow("PgUp/PgDn")} Scroll content`,
794
+ "",
795
+ ` ${C.yellow("/help")} Show this help`,
796
+ ` ${C.yellow("/model <n>")} Change model`,
797
+ ` ${C.yellow("/trust <lvl>")} Change security level`,
798
+ ` ${C.yellow("/agent <n>")} Switch agent`,
799
+ ` ${C.yellow("/cost")} Show budget`,
800
+ ` ${C.yellow("/clear")} Clear conversation`,
801
+ ` ${C.yellow("/ws <name>")} Switch workstream`,
802
+ ` ${C.yellow("/quit")} Quit`,
803
+ "",
804
+ ` ${C.dim("Press Esc or type /help again to close")}`,
805
+ ];
806
+ }
807
+
808
+ _updateMainContent() {
809
+ // Signal to ContentArea that content needs rebuild on next render
810
+ this.contentArea?.invalidate();
811
+ }
812
+
813
+ // ─── Submit / command handling ─────────────────────────────────────────────
814
+
815
+ async handleSubmit(text) {
816
+ const trimmed = text.trim();
817
+ if (!trimmed) return;
818
+
819
+ // Handle pending approval
820
+ if (this._pendingApproval) {
821
+ const ch = trimmed.toLowerCase();
822
+ if (ch === "y" || ch === "yes") {
823
+ this._approvalResolver?.(true);
824
+ this._approvalResolver = null;
825
+ this._pendingApproval = null;
826
+ this._updateMainContent();
827
+ this.tui.requestRender();
828
+ } else if (ch === "n" || ch === "no") {
829
+ this._approvalResolver?.(false);
830
+ this._approvalResolver = null;
831
+ this._pendingApproval = null;
832
+ this._updateMainContent();
833
+ this.tui.requestRender();
834
+ } else if (ch === "d") {
835
+ // dry run
836
+ const action = this._pendingApproval;
837
+ this._approvalResolver?.(false);
838
+ this._approvalResolver = null;
839
+ this._pendingApproval = null;
840
+ this.messages.push({
841
+ role: "assistant",
842
+ content: `**Dry-run preview**\n\nWould execute: \`${action.toolName}\`\n\`\`\`\n${JSON.stringify(action.args, null, 2).slice(0, 300)}\n\`\`\`\n*(Approve to run.)*`,
843
+ });
844
+ this._updateMainContent();
845
+ this.tui.requestRender();
846
+ }
847
+ return;
848
+ }
849
+
850
+ // Slash commands
851
+ if (trimmed.startsWith("/")) {
852
+ await this._handleSlashCommand(trimmed);
853
+ return;
854
+ }
855
+
856
+ if (this.loading) return;
857
+
858
+ // Switch to chat view
859
+ if (this.view !== "chat") {
860
+ this.view = "chat";
861
+ this._updateStatusBar();
862
+ }
863
+
864
+ // Add user message
865
+ this.messages.push({ role: "user", content: trimmed });
866
+ this._conversation.push({ role: "user", content: trimmed });
867
+ this.loading = true;
868
+ this._setLoaderActive(true);
869
+ this._updateMainContent();
870
+ this.tui.requestRender();
871
+
872
+ try {
873
+ const result = await this.engine.processMessage(null, trimmed, {
874
+ onToolCall: (name, args) => {
875
+ this.messages.push({ role: "tool_call", name, args, receipt: null });
876
+ this._updateMainContent();
877
+ this.tui.requestRender();
878
+ },
879
+ onToolResult: (name, toolResult) => {
880
+ // Find and update the last tool_call for this name
881
+ for (let i = this.messages.length - 1; i >= 0; i--) {
882
+ if (this.messages[i].role === "tool_call" && this.messages[i].name === name) {
883
+ this.messages[i] = { ...this.messages[i], result: toolResult };
884
+ break;
885
+ }
886
+ }
887
+ this._updateMainContent();
888
+ this.tui.requestRender();
889
+ },
890
+ onReceipt: (receipt) => {
891
+ if (!receipt?.toolName) return;
892
+ for (let i = this.messages.length - 1; i >= 0; i--) {
893
+ if (this.messages[i].role === "tool_call" && this.messages[i].name === receipt.toolName) {
894
+ this.messages[i] = { ...this.messages[i], receipt };
895
+ break;
896
+ }
897
+ }
898
+ this._updateMainContent();
899
+ this.tui.requestRender();
900
+ },
901
+ noSave: true,
902
+ });
903
+
904
+ const responseText = result.content;
905
+ this.messages.push({ role: "assistant", content: responseText });
906
+ this._conversation.push({ role: "assistant", content: responseText });
907
+
908
+ // Update budget
909
+ try {
910
+ const bud = this.engine?.budget;
911
+ if (bud) {
912
+ this.budgetSpent = bud.sessionSpend ?? 0;
913
+ this.maxBudget = bud.maxBudgetUsd ?? null;
914
+ }
915
+ } catch {}
916
+
917
+ await saveConversation(this.activeWorkstream, this._conversation.filter(m => m.role !== "system"));
918
+ } catch (err) {
919
+ const errMsg = `Error: ${err.message.slice(0, 200)}`;
920
+ this.messages.push({ role: "assistant", content: errMsg });
921
+ } finally {
922
+ this.loading = false;
923
+ this._setLoaderActive(false);
924
+ }
925
+
926
+ this._updateAll();
927
+ this.tui.requestRender();
928
+ }
929
+
930
+ async _handleSlashCommand(input) {
931
+ const parts = input.split(/\s+/);
932
+ const cmd = parts[0].toLowerCase();
933
+
934
+ const sysMsg = (text) => {
935
+ this.messages.push({ role: "system_info", content: text });
936
+ if (this.view !== "chat") { this.view = "chat"; this._updateStatusBar(); }
937
+ this._updateMainContent();
938
+ this.tui.requestRender();
939
+ };
940
+
941
+ if (cmd === "/quit" || cmd === "/exit") {
942
+ await this.shutdown();
943
+ return;
944
+ }
945
+
946
+ if (cmd === "/clear") {
947
+ this._conversation = [];
948
+ this.messages = [{ role: "system_info", content: "Conversation cleared." }];
949
+ await saveConversation(this.activeWorkstream, []);
950
+ this._updateMainContent();
951
+ this.tui.requestRender();
952
+ return;
953
+ }
954
+
955
+ if (cmd === "/cost") {
956
+ const bud = this.engine?.budget;
957
+ const spent = bud?.sessionSpend ?? this.budgetSpent;
958
+ const max = bud?.maxBudgetUsd;
959
+ sysMsg(`Budget: $${spent.toFixed(4)}${max ? ` / $${max.toFixed(2)}` : ""}`);
960
+ return;
961
+ }
962
+
963
+ if (cmd === "/model" && parts[1]) {
964
+ this.engine.providers?.setModel?.(parts[1]);
965
+ sysMsg(`Model → ${parts[1]}`);
966
+ return;
967
+ }
968
+
969
+ if ((cmd === "/ws" || cmd === "/workstream") && parts[1]) {
970
+ await this._switchWorkstream(parts[1]);
971
+ return;
972
+ }
973
+
974
+ if (cmd === "/trust" && parts[1]) {
975
+ try {
976
+ this.engine.permissions.setPolicy("run_command", parts[1]);
977
+ sysMsg(`Security level → ${parts[1]}`);
978
+ } catch (e) {
979
+ sysMsg(`Error: ${e.message}`);
980
+ }
981
+ return;
982
+ }
983
+
984
+ if (cmd === "/agent" && parts[1]) {
985
+ sysMsg(`Agent → ${parts[1]} (restart TUI to apply)`);
986
+ return;
987
+ }
988
+
989
+ if (cmd === "/help") {
990
+ // Toggle to help view
991
+ this.view = "help";
992
+ this._updateStatusBar();
993
+ this._updateMainContent();
994
+ this.tui.requestRender();
995
+ return;
996
+ }
997
+
998
+ if (cmd === "/overview" || cmd === "/o") { this.view = "overview"; this._updateAll(); this.tui.requestRender(); return; }
999
+ if (cmd === "/agents" || cmd === "/a") { this.view = "agents"; this._updateAll(); this.tui.requestRender(); return; }
1000
+ if (cmd === "/memory" || cmd === "/m") { this.view = "memory"; this._updateAll(); this.tui.requestRender(); return; }
1001
+ if (cmd === "/audit" || cmd === "/u") { this.view = "audit"; this._updateAll(); this.tui.requestRender(); return; }
1002
+ if (cmd === "/settings"|| cmd === "/s") { this.view = "settings"; this._updateAll(); this.tui.requestRender(); return; }
1003
+ if (cmd === "/chat") { this.view = "chat"; this._updateAll(); this.tui.requestRender(); return; }
1004
+
1005
+ // Unknown slash command — send to engine as chat
1006
+ await this.handleSubmit(input.slice(1)); // strip /
1007
+ }
1008
+
1009
+ async _switchWorkstream(ws) {
1010
+ if (ws === this.activeWorkstream) return;
1011
+ this.activeWorkstream = ws;
1012
+ this.messages = [];
1013
+ this._conversation = await loadConversation(ws);
1014
+ this.messages = this._conversation.map(m => ({ ...m }));
1015
+ this.view = "chat";
1016
+ if (this.engine) {
1017
+ this.engine._activeWorkstream = ws;
1018
+ this.engine._workMdLoaded = false;
1019
+ this.engine._workMdContent = null;
1020
+ }
1021
+ this._updateAll();
1022
+ this.tui.requestRender();
1023
+ }
1024
+
1025
+ // ─── Global key handling (called from WispyEditor) ─────────────────────────
1026
+
1027
+ handleGlobalKey(data) {
1028
+ if (isKeyRelease(data)) return false;
1029
+
1030
+ // Approval mode: handle y/n/d via submit only
1031
+
1032
+ if (this._pendingApproval) return false;
1033
+
1034
+ // Tab — cycle view
1035
+ if (matchesKey(data, Key.tab)) {
1036
+ const idx = VIEWS.indexOf(this.view);
1037
+ this.view = VIEWS[(idx + 1) % VIEWS.length];
1038
+ if (this.view === "overview") {
1039
+ loadOverviewData(this.workstreams).then(d => { this.overviewData = d; this._updateMainContent(); this.tui.requestRender(); });
1040
+ }
1041
+ this._updateAll();
1042
+ this.tui.requestRender();
1043
+ return true;
1044
+ }
1045
+
1046
+ // Number keys 1-6 — jump to view
1047
+ if (!this.loading && /^[1-6]$/.test(data)) {
1048
+ const idx = parseInt(data) - 1;
1049
+ this.view = VIEWS[idx] ?? this.view;
1050
+ if (this.view === "overview") {
1051
+ loadOverviewData(this.workstreams).then(d => { this.overviewData = d; this._updateMainContent(); this.tui.requestRender(); });
1052
+ }
1053
+ this._updateAll();
1054
+ this.tui.requestRender();
1055
+ return true;
1056
+ }
1057
+
1058
+ // ? — help
1059
+ if (data === "?" && !this.loading) {
1060
+ this.view = this.view === "help" ? "chat" : "help";
1061
+ this._updateAll();
1062
+ this.tui.requestRender();
1063
+ return true;
1064
+ }
1065
+
1066
+ // Ctrl+C — quit
1067
+ if (matchesKey(data, Key.ctrl("c"))) {
1068
+ this.shutdown();
1069
+ return true;
1070
+ }
1071
+
1072
+ // Ctrl+L — clear
1073
+ if (matchesKey(data, Key.ctrl("l"))) {
1074
+ this._conversation = [];
1075
+ this.messages = [{ role: "system_info", content: "Conversation cleared." }];
1076
+ saveConversation(this.activeWorkstream, []).catch(() => {});
1077
+ this._updateMainContent();
1078
+ this.tui.requestRender();
1079
+ return true;
1080
+ }
1081
+
1082
+ return false;
1083
+ }
1084
+
1085
+ // ─── Shutdown ──────────────────────────────────────────────────────────────
1086
+
1087
+ async shutdown() {
1088
+ for (const id of this._intervals) clearInterval(id);
1089
+ this.loaderComp?.stop();
1090
+ this.tui.stop();
1091
+ this.engine?.destroy?.();
1092
+ process.exit(0);
1093
+ }
1094
+ }
1095
+
1096
+ // ─── WispyEditor — custom Editor that intercepts global keys ─────────────────
1097
+
1098
+ class WispyEditor extends Editor {
1099
+ constructor(tui, theme, wispyTUI) {
1100
+ super(tui, theme);
1101
+ this._wispy = wispyTUI;
1102
+
1103
+ // Handle submit via Editor's onSubmit
1104
+ this.onSubmit = (text) => {
1105
+ this._wispy.handleSubmit(text).catch(err => {
1106
+ console.error("Submit error:", err.message);
1107
+ });
1108
+ };
1109
+ }
1110
+
1111
+ handleInput(data) {
1112
+ if (isKeyRelease(data)) return;
1113
+
1114
+ // Let WispyTUI handle global keys first (Tab, Ctrl+C, 1-6, ?, Ctrl+L)
1115
+ // But only if editor is empty (to allow typing numbers normally)
1116
+ const currentText = this.getText();
1117
+ if (currentText.length === 0 || matchesKey(data, Key.tab) || matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.ctrl("l"))) {
1118
+ if (this._wispy.handleGlobalKey(data)) return;
1119
+ }
1120
+
1121
+ // Enter to submit (when no shift)
1122
+ if (matchesKey(data, Key.enter) && !matchesKey(data, Key.shift("enter"))) {
1123
+ const text = this.getText().trim();
1124
+ if (text) {
1125
+ this.onSubmit(text);
1126
+ this.setText("");
1127
+ }
1128
+ return;
1129
+ }
1130
+
1131
+ // Default: pass to Editor (handles typing, backspace, CJK, undo, etc.)
1132
+ super.handleInput(data);
1133
+ }
1134
+ }
1135
+
1136
+ // ─── ContentArea — custom component for sidebar + main layout ─────────────────
1137
+
1138
+ class ContentArea {
1139
+ constructor(wispyTUI) {
1140
+ this._wispy = wispyTUI;
1141
+ this._cachedLines = null;
1142
+ this._dirty = true;
1143
+ }
1144
+
1145
+ invalidate() {
1146
+ this._dirty = true;
1147
+ this._cachedLines = null;
1148
+ }
1149
+
1150
+ render(width) {
1151
+ const wispy = this._wispy;
1152
+ const terminal = wispy.terminal;
1153
+ const height = (terminal.rows ?? 24) - 3; // reserve status bar (1) + input (1) + border (1)
1154
+ const contentHeight = Math.max(5, height);
1155
+
1156
+ const useSidebar = width >= 80;
1157
+ const sidebarWidth = useSidebar ? SIDEBAR_WIDTH : 0;
1158
+ const mainWidth = useSidebar ? width - sidebarWidth - 1 : width;
1159
+
1160
+ // Build sidebar
1161
+ let sidebarLines = [];
1162
+ if (useSidebar) {
1163
+ sidebarLines = wispy._buildSidebarLines(sidebarWidth);
1164
+ }
1165
+
1166
+ // Build main content
1167
+ let mainLines;
1168
+ if (wispy.view === "help") {
1169
+ mainLines = wispy._buildHelpLines();
1170
+ } else {
1171
+ mainLines = wispy._buildMainContentLines(mainWidth);
1172
+ }
1173
+
1174
+ // Truncate/pad to contentHeight
1175
+ const sidebarPadded = [];
1176
+ for (let i = 0; i < contentHeight; i++) {
1177
+ const line = sidebarLines[i] ?? "";
1178
+ // Visible width pad
1179
+ const visLen = line.replace(/\x1b\[[^m]*m/g, "").replace(/\x1b_pi:c\x07/g, "").length;
1180
+ const pad = " ".repeat(Math.max(0, sidebarWidth - visLen));
1181
+ sidebarPadded.push(line + pad);
1182
+ }
1183
+
1184
+ // Scroll main lines to show bottom
1185
+ const startIdx = Math.max(0, mainLines.length - contentHeight);
1186
+ const mainPadded = [];
1187
+ for (let i = 0; i < contentHeight; i++) {
1188
+ const line = mainLines[startIdx + i] ?? "";
1189
+ // Truncate to mainWidth
1190
+ const visLen = line.replace(/\x1b\[[^m]*m/g, "").replace(/\x1b_pi:c\x07/g, "").length;
1191
+ if (visLen > mainWidth) {
1192
+ // Naive truncate
1193
+ mainPadded.push(line.slice(0, mainWidth));
1194
+ } else {
1195
+ mainPadded.push(line);
1196
+ }
1197
+ }
1198
+
1199
+ // Combine sidebar + divider + main into rows
1200
+ const result = [];
1201
+ if (useSidebar) {
1202
+ const divider = C.dim("│");
1203
+ for (let i = 0; i < contentHeight; i++) {
1204
+ result.push(sidebarPadded[i] + divider + (mainPadded[i] ?? ""));
1205
+ }
1206
+ } else {
1207
+ for (let i = 0; i < contentHeight; i++) {
1208
+ result.push(mainPadded[i] ?? "");
1209
+ }
1210
+ }
1211
+
1212
+ this._dirty = false;
1213
+ return result;
1214
+ }
1215
+ }
1216
+
1217
+ // ─── Entry point ──────────────────────────────────────────────────────────────
1218
+
1219
+ async function main() {
1220
+ if (!process.stdin.isTTY) {
1221
+ console.error("Error: wispy tui requires a TTY terminal");
1222
+ process.exit(1);
1223
+ }
1224
+
1225
+ const wispyTUI = new WispyTUI();
1226
+ await wispyTUI.init();
1227
+
1228
+ // Keep process alive — TUI manages exit
1229
+ await new Promise(() => {});
1230
+ }
1231
+
1232
+ main().catch(err => {
1233
+ console.error("TUI error:", err.message);
1234
+ if (process.env.WISPY_DEBUG) console.error(err.stack);
1235
+ process.exit(1);
1236
+ });