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,1643 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wispy-tui.mjs — Workspace OS TUI for Wispy v2.7+
4
+ *
5
+ * Multi-panel workspace interface:
6
+ * - Left sidebar: Workstreams, Agent, Sub-agents, Memory, Browser, Budget
7
+ * - Main area: Chat / Overview / Agents / Memory / Audit / Settings
8
+ * - Bottom: Action Timeline bar + Input
9
+ * - Overlays: Approval dialogs, Help, Command Palette
10
+ */
11
+
12
+ import React, { useState, useEffect, useRef, useCallback } from "react";
13
+ import { render, Box, Text, useApp, useInput, useStdout } from "ink";
14
+ import { PassThrough } from "node:stream";
15
+ import Spinner from "ink-spinner";
16
+ import { ReadlineInput } from "./readline-input.mjs";
17
+ // TextInput is used only for the Memory search box (non-critical, no IME needed)
18
+ import CJKTextInput from "./cjk-text-input.mjs";
19
+ const TextInput = CJKTextInput;
20
+
21
+ import { COMMANDS, filterCommands } from "./command-registry.mjs";
22
+
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { readFile, writeFile, readdir, stat, mkdir } from "node:fs/promises";
26
+ import { createRequire } from "node:module";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ import { WispyEngine, CONVERSATIONS_DIR, PROVIDERS, WISPY_DIR, MEMORY_DIR } from "../core/index.mjs";
30
+
31
+ // ─── Version ──────────────────────────────────────────────────────────────────
32
+
33
+ let PKG_VERSION = "?";
34
+ try {
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+ const pkgPath = path.join(__dirname, "..", "package.json");
37
+ const pkgRaw = await readFile(pkgPath, "utf8");
38
+ PKG_VERSION = JSON.parse(pkgRaw).version ?? "?";
39
+ } catch {}
40
+
41
+ // ─── Parse CLI args ──────────────────────────────────────────────────────────
42
+
43
+ const rawArgs = process.argv.slice(2);
44
+ const wsIdx = rawArgs.findIndex((a) => a === "-w" || a === "--workstream");
45
+ const INITIAL_WORKSTREAM =
46
+ process.env.WISPY_WORKSTREAM ??
47
+ (wsIdx !== -1 ? rawArgs[wsIdx + 1] : null) ??
48
+ "default";
49
+
50
+ // ─── Constants ───────────────────────────────────────────────────────────────
51
+
52
+ const VIEWS = ["chat", "overview", "agents", "memory", "audit", "settings"];
53
+ const SIDEBAR_WIDTH = 18;
54
+ const TIMELINE_LINES = 3;
55
+
56
+ const TOOL_ICONS = {
57
+ read_file: "[file]", write_file: "[edit]", file_edit: "[edit]", run_command: "[exec]",
58
+ git: "[git]", web_search: "[search]", web_fetch: "[web]", list_directory: "[dir]",
59
+ spawn_subagent: "[sub]", spawn_agent: "[agent]", memory_save: "[save]",
60
+ memory_search: "[find]", memory_list: "[list]", delete_file: "[delete]",
61
+ node_execute: "[run]", update_work_context: "[update]",
62
+ };
63
+
64
+ // ─── Utilities ───────────────────────────────────────────────────────────────
65
+
66
+ function fmtTime(iso) {
67
+ if (!iso) return "";
68
+ try { return new Date(iso).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" }); }
69
+ catch { return ""; }
70
+ }
71
+
72
+ function fmtRelTime(iso) {
73
+ if (!iso) return "";
74
+ try {
75
+ const diff = Date.now() - new Date(iso).getTime();
76
+ if (diff < 60_000) return "just now";
77
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}min ago`;
78
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}hr ago`;
79
+ return "yesterday";
80
+ } catch { return ""; }
81
+ }
82
+
83
+ function fmtDuration(ms) {
84
+ if (!ms || ms < 0) return "";
85
+ if (ms < 1000) return `${ms}ms`;
86
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
87
+ return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`;
88
+ }
89
+
90
+ function truncate(str, n) {
91
+ if (!str) return "";
92
+ return str.length > n ? str.slice(0, n - 1) + "…" : str;
93
+ }
94
+
95
+ // ─── Markdown renderer ───────────────────────────────────────────────────────
96
+
97
+ function renderMarkdown(text, maxWidth = 60) {
98
+ if (!text) return [];
99
+ const lines = text.split("\n");
100
+ return lines.map((line, i) => {
101
+ if (line.startsWith("```")) return React.createElement(Text, { key: i, dimColor: true }, line);
102
+ if (line.startsWith("### ")) return React.createElement(Text, { key: i, bold: true, color: "cyan" }, line.slice(4));
103
+ if (line.startsWith("## ")) return React.createElement(Text, { key: i, bold: true, color: "blue" }, line.slice(3));
104
+ if (line.startsWith("# ")) return React.createElement(Text, { key: i, bold: true, color: "magenta" }, line.slice(2));
105
+ if (line.startsWith("- ") || line.startsWith("* ")) {
106
+ return React.createElement(Box, { key: i },
107
+ React.createElement(Text, { color: "green" }, "• "),
108
+ React.createElement(Text, { wrap: "wrap" }, line.slice(2)));
109
+ }
110
+ if (/^\d+\.\s/.test(line)) {
111
+ const match = line.match(/^(\d+\.\s)(.*)/);
112
+ return React.createElement(Box, { key: i },
113
+ React.createElement(Text, { color: "yellow" }, match[1]),
114
+ React.createElement(Text, { wrap: "wrap" }, match[2]));
115
+ }
116
+ if (line.includes("**")) {
117
+ const parts = line.split(/(\*\*[^*]+\*\*)/g);
118
+ const children = parts.map((p, j) =>
119
+ p.startsWith("**") && p.endsWith("**")
120
+ ? React.createElement(Text, { key: j, bold: true }, p.slice(2, -2))
121
+ : React.createElement(Text, { key: j }, p));
122
+ return React.createElement(Box, { key: i }, ...children);
123
+ }
124
+ if (line.startsWith("---") || line.startsWith("===")) {
125
+ return React.createElement(Text, { key: i, dimColor: true }, "─".repeat(Math.min(50, maxWidth)));
126
+ }
127
+ if (line === "") return React.createElement(Box, { key: i, height: 1 });
128
+ return React.createElement(Text, { key: i, wrap: "wrap" }, line);
129
+ });
130
+ }
131
+
132
+ // ─── Status Bar ──────────────────────────────────────────────────────────────
133
+
134
+ function StatusBar({ workstream, model, provider, permMode, syncStatus, tokens, cost, pendingApprovals, view, termWidth }) {
135
+ const wide = termWidth >= 100;
136
+
137
+ return React.createElement(
138
+ Box, { paddingX: 1, backgroundColor: "green", width: "100%" },
139
+ React.createElement(Text, { color: "black", bold: true }, "Wispy"),
140
+ React.createElement(Text, { color: "black" }, " ─ "),
141
+ React.createElement(Text, { color: "black", bold: true }, workstream),
142
+ React.createElement(Text, { color: "black" }, " ─ "),
143
+ React.createElement(Text, { color: "black" }, truncate(model ?? "?", 20)),
144
+ wide ? React.createElement(Text, { color: "black" }, ` ─ ${permMode ?? "approve"}`) : null,
145
+ wide && cost > 0 ? React.createElement(Text, { color: "black", dimColor: true }, ` ─ $${cost.toFixed(4)}`) : null,
146
+ pendingApprovals > 0
147
+ ? React.createElement(Text, { color: "black", bold: true }, ` ─ ! ${pendingApprovals} pending`)
148
+ : null,
149
+ React.createElement(Text, { color: "black", dimColor: true }, ` [${view.toUpperCase()}] ? for help`),
150
+ );
151
+ }
152
+
153
+ // ─── Left Sidebar ─────────────────────────────────────────────────────────────
154
+
155
+ function Sidebar({ workstreams, activeWorkstream, activeAgent, agents, memoryCount, userModelLoaded, cronCount, syncStatus, browserStatus, budgetSpent, maxBudget, onSelectWorkstream }) {
156
+ const rows = [];
157
+
158
+ const divider = (key) => React.createElement(Box, {
159
+ key, borderStyle: "single",
160
+ borderTop: false, borderRight: false, borderLeft: false, borderBottom: true,
161
+ borderColor: "gray", width: SIDEBAR_WIDTH,
162
+ });
163
+
164
+ // ── WORKSTREAMS ──
165
+ rows.push(React.createElement(Box, { key: "ws-h", paddingLeft: 1 },
166
+ React.createElement(Text, { bold: true, dimColor: true }, "WORKSTREAMS"),
167
+ ));
168
+ workstreams.forEach((ws) => {
169
+ const isActive = ws === activeWorkstream;
170
+ rows.push(React.createElement(Box, { key: `ws-${ws}`, paddingLeft: 1 },
171
+ React.createElement(Text, { color: isActive ? "green" : undefined, bold: isActive },
172
+ `${isActive ? "●" : "◯"} ${truncate(ws, SIDEBAR_WIDTH - 3)}`),
173
+ ));
174
+ });
175
+
176
+ // ── ACTIVE AGENT ──
177
+ if (activeAgent && activeAgent !== "default") {
178
+ rows.push(divider("d-agent"));
179
+ rows.push(React.createElement(Box, { key: "agent-h", paddingLeft: 1 },
180
+ React.createElement(Text, { bold: true, dimColor: true }, "AGENT"),
181
+ ));
182
+ rows.push(React.createElement(Box, { key: "agent-v", paddingLeft: 1 },
183
+ React.createElement(Text, { color: "cyan" }, truncate(activeAgent, SIDEBAR_WIDTH - 2)),
184
+ ));
185
+ }
186
+
187
+ // ── SUB-AGENTS ──
188
+ rows.push(divider("d-sub"));
189
+ rows.push(React.createElement(Box, { key: "sub-h", paddingLeft: 1 },
190
+ React.createElement(Text, { bold: true, dimColor: true }, `AGENTS `),
191
+ React.createElement(Text, { color: agents.length > 0 ? "yellow" : "gray" }, `${agents.length}`),
192
+ ));
193
+ if (agents.length === 0) {
194
+ rows.push(React.createElement(Box, { key: "sub-none", paddingLeft: 1 },
195
+ React.createElement(Text, { dimColor: true }, " (none)"),
196
+ ));
197
+ } else {
198
+ agents.slice(0, 4).forEach((a, i) => {
199
+ const isRunning = a.status === "running";
200
+ rows.push(React.createElement(Box, { key: `sub-${i}`, paddingLeft: 1 },
201
+ React.createElement(Text, { color: isRunning ? "green" : "gray" }, isRunning ? "● " : "◯ "),
202
+ React.createElement(Text, { dimColor: !isRunning }, truncate(a.label ?? a.role ?? "agent", SIDEBAR_WIDTH - 4)),
203
+ ));
204
+ });
205
+ }
206
+
207
+ // ── MEMORY ──
208
+ rows.push(divider("d-mem"));
209
+ rows.push(React.createElement(Box, { key: "mem-h", paddingLeft: 1 },
210
+ React.createElement(Text, { bold: true, dimColor: true }, "MEMORY "),
211
+ React.createElement(Text, { color: "cyan" }, `${memoryCount}`),
212
+ ));
213
+
214
+ // ── BROWSER ──
215
+ rows.push(divider("d-br"));
216
+ rows.push(React.createElement(Box, { key: "br-h", paddingLeft: 1 },
217
+ React.createElement(Text, { bold: true, dimColor: true }, "BROWSER"),
218
+ ));
219
+ if (browserStatus?.session) {
220
+ rows.push(React.createElement(Box, { key: "br-v", paddingLeft: 1 },
221
+ React.createElement(Text, { color: "green" }, "● "),
222
+ React.createElement(Text, {}, truncate(browserStatus.session.browser ?? "connected", SIDEBAR_WIDTH - 4)),
223
+ ));
224
+ } else {
225
+ rows.push(React.createElement(Box, { key: "br-v", paddingLeft: 1 },
226
+ React.createElement(Text, { dimColor: true }, "◯ off"),
227
+ ));
228
+ }
229
+
230
+ // ── BUDGET ──
231
+ rows.push(divider("d-bud"));
232
+ rows.push(React.createElement(Box, { key: "bud-h", paddingLeft: 1 },
233
+ React.createElement(Text, { bold: true, dimColor: true }, "BUDGET"),
234
+ ));
235
+ rows.push(React.createElement(Box, { key: "bud-v", paddingLeft: 1 },
236
+ React.createElement(Text, { color: budgetSpent > 0 ? "yellow" : "gray" },
237
+ budgetSpent > 0
238
+ ? `$${budgetSpent.toFixed(3)}${maxBudget ? `/$${maxBudget.toFixed(2)}` : ""}`
239
+ : "$0.000"),
240
+ ));
241
+
242
+ return React.createElement(
243
+ Box, {
244
+ flexDirection: "column",
245
+ width: SIDEBAR_WIDTH,
246
+ borderStyle: "single",
247
+ borderColor: "gray",
248
+ borderTop: false,
249
+ borderBottom: false,
250
+ borderLeft: false,
251
+ },
252
+ ...rows,
253
+ );
254
+ }
255
+
256
+ // ─── Chat View ───────────────────────────────────────────────────────────────
257
+
258
+ function ToolLine({ name, args, receipt }) {
259
+ const icon = TOOL_ICONS[name] ?? "[tool]";
260
+ const primaryArg = args?.path ?? args?.command ?? args?.query ?? args?.task ?? args?.key ?? "";
261
+ const statusIcon = receipt
262
+ ? (receipt.success ? "✓" : "✗")
263
+ : "●";
264
+ const statusColor = receipt ? (receipt.success ? "green" : "red") : "yellow";
265
+
266
+ return React.createElement(
267
+ Box, { flexDirection: "column", paddingLeft: 3 },
268
+ React.createElement(
269
+ Box, {},
270
+ React.createElement(Text, { color: statusColor }, statusIcon + " "),
271
+ React.createElement(Text, { color: "cyan", dimColor: true }, `${icon} ${name}`),
272
+ primaryArg ? React.createElement(Text, { dimColor: true }, ` → ${truncate(primaryArg, 35)}`) : null,
273
+ ),
274
+ receipt?.diff?.unified ? React.createElement(MiniDiff, { unified: receipt.diff.unified, filePath: args?.path }) : null,
275
+ );
276
+ }
277
+
278
+ function MiniDiff({ unified, filePath }) {
279
+ const lines = unified.split("\n").filter(l => l);
280
+ let added = 0; let removed = 0;
281
+ for (const l of lines) {
282
+ if (l.startsWith("+") && !l.startsWith("+++")) added++;
283
+ else if (l.startsWith("-") && !l.startsWith("---")) removed++;
284
+ }
285
+ return React.createElement(
286
+ Box, { paddingLeft: 2 },
287
+ React.createElement(Text, { color: "green" }, `+${added}`),
288
+ React.createElement(Text, { dimColor: true }, "/"),
289
+ React.createElement(Text, { color: "red" }, `-${removed}`),
290
+ filePath ? React.createElement(Text, { dimColor: true }, ` ${path.basename(filePath)}`) : null,
291
+ );
292
+ }
293
+
294
+ function ChatMessage({ msg, mainWidth }) {
295
+ if (msg.role === "user") {
296
+ return React.createElement(
297
+ Box, { flexDirection: "column", paddingLeft: 1, marginTop: 1 },
298
+ React.createElement(Box, {},
299
+ React.createElement(Text, { color: "green", bold: true }, "you › "),
300
+ React.createElement(Text, { color: "white", wrap: "wrap" }, msg.content)));
301
+ }
302
+ if (msg.role === "tool_call") {
303
+ return React.createElement(ToolLine, { name: msg.name, args: msg.args, receipt: msg.receipt });
304
+ }
305
+ if (msg.role === "assistant") {
306
+ return React.createElement(
307
+ Box, { flexDirection: "column", paddingLeft: 1, marginTop: 1 },
308
+ React.createElement(Text, { color: "cyan", bold: true }, "wispy ›"),
309
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2 },
310
+ ...renderMarkdown(msg.content, mainWidth - 4)),
311
+ );
312
+ }
313
+ if (msg.role === "system_info") {
314
+ return React.createElement(
315
+ Box, { paddingLeft: 2 },
316
+ React.createElement(Text, { dimColor: true, italic: true }, msg.content));
317
+ }
318
+ return null;
319
+ }
320
+
321
+ function ChatView({ messages, loading, pendingApproval, onApprove, onDeny, onDryRun, mainWidth }) {
322
+ const displayMessages = messages.slice(-25);
323
+
324
+ return React.createElement(
325
+ Box, { flexDirection: "column", flexGrow: 1, overflowY: "hidden" },
326
+ displayMessages.length === 0
327
+ ? React.createElement(
328
+ Box, { flexGrow: 1, alignItems: "center", justifyContent: "center", flexDirection: "column" },
329
+ React.createElement(Text, { dimColor: true }, "Wispy — AI workspace assistant"),
330
+ React.createElement(Text, { dimColor: true }, "Type a message to start. ? for help."),
331
+ )
332
+ : React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
333
+ ...displayMessages.map((msg, i) =>
334
+ React.createElement(ChatMessage, { key: i, msg, mainWidth }))),
335
+
336
+ loading
337
+ ? React.createElement(
338
+ Box, { paddingLeft: 2, marginTop: 1 },
339
+ React.createElement(Spinner, { type: "dots" }),
340
+ React.createElement(Text, { color: "yellow" }, " thinking..."),
341
+ )
342
+ : null,
343
+
344
+ pendingApproval
345
+ ? React.createElement(ApprovalDialog, {
346
+ action: pendingApproval.action,
347
+ onApprove, onDeny, onDryRun,
348
+ })
349
+ : null,
350
+ );
351
+ }
352
+
353
+ // ─── Overview View ────────────────────────────────────────────────────────────
354
+
355
+ function OverviewView({ workstreams, activeWorkstream, overviewData, browserStatus, mainWidth }) {
356
+ return React.createElement(
357
+ Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
358
+ React.createElement(Text, { bold: true, color: "green" }, "Workstream Overview"),
359
+ React.createElement(Box, { height: 1 }),
360
+ // Browser status section
361
+ React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
362
+ React.createElement(Text, { bold: true, color: "cyan" }, "Browser"),
363
+ browserStatus?.session
364
+ ? React.createElement(Box, { paddingLeft: 2 },
365
+ React.createElement(Text, { color: "green" }, "● "),
366
+ React.createElement(Text, {}, `Connected — ${browserStatus.session.browser ?? "unknown"}`))
367
+ : React.createElement(Box, { paddingLeft: 2 },
368
+ React.createElement(Text, { dimColor: true }, "◯ Not connected")),
369
+ ),
370
+ React.createElement(Text, { dimColor: true }, "─".repeat(Math.min(40, mainWidth - 4))),
371
+ React.createElement(Box, { height: 1 }),
372
+ ...workstreams.map((ws) => {
373
+ const data = overviewData[ws] ?? {};
374
+ const isActive = ws === activeWorkstream;
375
+ return React.createElement(
376
+ Box, { key: ws, flexDirection: "column", marginBottom: 1 },
377
+ React.createElement(Box, {},
378
+ React.createElement(Text, { bold: true, color: isActive ? "green" : "white" },
379
+ `${isActive ? "●" : "◯"} ${ws}`),
380
+ data.lastActivity
381
+ ? React.createElement(Text, { dimColor: true }, ` ${fmtRelTime(data.lastActivity)}`)
382
+ : null,
383
+ data.agents > 0
384
+ ? React.createElement(Text, { color: "yellow" }, ` ${data.agents} agents`)
385
+ : null,
386
+ ),
387
+ data.lastMessage
388
+ ? React.createElement(
389
+ Box, { paddingLeft: 2 },
390
+ React.createElement(Text, { dimColor: true }, "└── "),
391
+ React.createElement(Text, { wrap: "wrap" }, truncate(data.lastMessage, mainWidth - 10)),
392
+ )
393
+ : null,
394
+ data.workMd
395
+ ? React.createElement(
396
+ Box, { paddingLeft: 2 },
397
+ React.createElement(Text, { dimColor: true }, "└── "),
398
+ React.createElement(Text, { dimColor: true, italic: true }, `"${truncate(data.workMd, mainWidth - 20)}"`),
399
+ )
400
+ : null,
401
+ );
402
+ }),
403
+ );
404
+ }
405
+
406
+ // ─── Agents View ─────────────────────────────────────────────────────────────
407
+
408
+ function AgentsView({ agents, agentManager }) {
409
+ const [builtinAgents, setBuiltinAgents] = useState([]);
410
+ const [progressMap, setProgressMap] = useState({});
411
+
412
+ useEffect(() => {
413
+ if (agentManager) {
414
+ try {
415
+ const list = agentManager.list();
416
+ setBuiltinAgents(list);
417
+ } catch {}
418
+ }
419
+ }, [agentManager]);
420
+
421
+ const statusColor = (s) => {
422
+ if (s === "running") return "green";
423
+ if (s === "pending") return "yellow";
424
+ if (s === "completed" || s === "done") return "cyan";
425
+ return "red";
426
+ };
427
+ const statusIcon = (s) => {
428
+ if (s === "running") return "●";
429
+ if (s === "pending") return "◯";
430
+ if (s === "completed" || s === "done") return "✓";
431
+ return "✗";
432
+ };
433
+
434
+ const running = agents.filter(a => a.status === "running");
435
+ const completed = agents.filter(a => a.status === "completed" || a.status === "done");
436
+
437
+ return React.createElement(
438
+ Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
439
+ // ── Custom / Built-in Agents ──
440
+ React.createElement(Text, { bold: true, color: "green" }, "Agent Profiles"),
441
+ React.createElement(Box, { height: 1 }),
442
+ builtinAgents.length === 0
443
+ ? React.createElement(Text, { dimColor: true }, "No agents loaded.")
444
+ : React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
445
+ ...builtinAgents.slice(0, 10).map((a, i) => React.createElement(
446
+ Box, { key: i },
447
+ React.createElement(Text, { color: a.builtin ? "cyan" : "magenta" },
448
+ a.builtin ? " [built-in] " : " [custom] "),
449
+ React.createElement(Text, { bold: true }, a.name),
450
+ React.createElement(Text, { dimColor: true }, ` — ${truncate(a.description, 45)}`),
451
+ ))),
452
+
453
+ React.createElement(Text, { dimColor: true }, "─".repeat(40)),
454
+ React.createElement(Box, { height: 1 }),
455
+
456
+ // ── Running Sub-Agents ──
457
+ React.createElement(Text, { bold: true, color: "green" },
458
+ `Sub-Agents (${running.length} running, ${completed.length} completed)`),
459
+ React.createElement(Box, { height: 1 }),
460
+ agents.length === 0
461
+ ? React.createElement(Text, { dimColor: true }, "No sub-agents recorded.")
462
+ : React.createElement(Box, { flexDirection: "column" },
463
+ ...agents.map((a, i) => {
464
+ const runtime = a.createdAt && a.completedAt
465
+ ? fmtDuration(new Date(a.completedAt) - new Date(a.createdAt))
466
+ : a.createdAt ? `${fmtRelTime(a.createdAt)}` : "";
467
+ return React.createElement(
468
+ Box, { key: i, flexDirection: "column", marginBottom: 1 },
469
+ React.createElement(Box, {},
470
+ React.createElement(Text, { color: statusColor(a.status) }, `${statusIcon(a.status)} `),
471
+ React.createElement(Text, { bold: true, color: statusColor(a.status) }, truncate(a.label ?? a.id ?? "agent", 20)),
472
+ React.createElement(Text, { dimColor: true }, ` [${a.model ?? "?"}]`),
473
+ runtime ? React.createElement(Text, { dimColor: true }, ` ${runtime}`) : null,
474
+ ),
475
+ a.task
476
+ ? React.createElement(
477
+ Box, { paddingLeft: 3 },
478
+ React.createElement(Text, { dimColor: true, wrap: "wrap" }, truncate(a.task, 60)),
479
+ )
480
+ : null,
481
+ progressMap[a.id]
482
+ ? React.createElement(Box, { paddingLeft: 3 },
483
+ React.createElement(Text, { color: "yellow" }, "… "),
484
+ React.createElement(Text, { dimColor: true, wrap: "wrap" }, truncate(progressMap[a.id], 55)),
485
+ )
486
+ : null,
487
+ );
488
+ })),
489
+ );
490
+ }
491
+
492
+ // ─── Memory View ──────────────────────────────────────────────────────────────
493
+
494
+ function MemoryView({ memoryFiles, memoryQuery, onQueryChange }) {
495
+ const filtered = memoryQuery
496
+ ? memoryFiles.filter(f =>
497
+ f.key.toLowerCase().includes(memoryQuery.toLowerCase()) ||
498
+ (f.preview ?? "").toLowerCase().includes(memoryQuery.toLowerCase()))
499
+ : memoryFiles;
500
+
501
+ return React.createElement(
502
+ Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
503
+ React.createElement(Text, { bold: true, color: "green" }, "Memory Files"),
504
+ React.createElement(Box, { marginTop: 1 },
505
+ React.createElement(Text, { dimColor: true }, "Search: "),
506
+ React.createElement(TextInput, {
507
+ value: memoryQuery,
508
+ onChange: onQueryChange,
509
+ placeholder: "filter files...",
510
+ }),
511
+ ),
512
+ React.createElement(Box, { height: 1 }),
513
+ filtered.length === 0
514
+ ? React.createElement(Text, { dimColor: true }, "No memory files found.")
515
+ : React.createElement(Box, { flexDirection: "column" },
516
+ ...filtered.slice(0, 20).map((f, i) => React.createElement(
517
+ Box, { key: i, flexDirection: "column", marginBottom: 0 },
518
+ React.createElement(Box, {},
519
+ React.createElement(Text, { color: "cyan" }, f.key),
520
+ f.size
521
+ ? React.createElement(Text, { dimColor: true }, ` (${(f.size / 1024).toFixed(1)}KB)`)
522
+ : null,
523
+ f.updatedAt
524
+ ? React.createElement(Text, { dimColor: true }, ` ${fmtRelTime(f.updatedAt)}`)
525
+ : null,
526
+ ),
527
+ f.preview
528
+ ? React.createElement(
529
+ Box, { paddingLeft: 2 },
530
+ React.createElement(Text, { dimColor: true, wrap: "wrap" }, truncate(f.preview, 70)),
531
+ )
532
+ : null,
533
+ ))),
534
+ );
535
+ }
536
+
537
+ // ─── Audit View ───────────────────────────────────────────────────────────────
538
+
539
+ function AuditView({ timeline, mainWidth }) {
540
+ return React.createElement(
541
+ Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
542
+ React.createElement(Text, { bold: true, color: "green" }, "Action Timeline"),
543
+ React.createElement(Box, { height: 1 }),
544
+ timeline.length === 0
545
+ ? React.createElement(Text, { dimColor: true }, "No actions recorded yet.")
546
+ : React.createElement(Box, { flexDirection: "column" },
547
+ ...timeline.slice().reverse().slice(0, 30).map((evt, i) => {
548
+ const icon = TOOL_ICONS[evt.toolName] ?? "[tool]";
549
+ const statusIcon = evt.denied ? "✗" : evt.dryRun ? "◯" : evt.success ? "✓" : "●";
550
+ const color = evt.denied ? "red" : evt.dryRun ? "blue" : evt.success ? "green" : "yellow";
551
+ const ts = fmtTime(evt.timestamp);
552
+ const arg = evt.primaryArg ? ` → ${truncate(evt.primaryArg, 28)}` : "";
553
+ return React.createElement(
554
+ Box, { key: i },
555
+ React.createElement(Text, { dimColor: true }, `${ts} `),
556
+ React.createElement(Text, { dimColor: true }, `${icon} `),
557
+ React.createElement(Text, { color: "cyan" }, evt.toolName),
558
+ React.createElement(Text, { dimColor: true }, arg),
559
+ React.createElement(Text, { color }, ` ${statusIcon}`),
560
+ );
561
+ })),
562
+ );
563
+ }
564
+
565
+ // ─── Settings View ────────────────────────────────────────────────────────────
566
+
567
+ function SettingsView({ engine }) {
568
+ const [config, setConfig] = useState(null);
569
+ const [features, setFeatures] = useState([]);
570
+ const [browserSt, setBrowserSt] = useState(null);
571
+ const [refreshKey, setRefreshKey] = useState(0);
572
+
573
+ useEffect(() => {
574
+ let mounted = true;
575
+ (async () => {
576
+ try {
577
+ const { loadConfig } = await import("../core/config.mjs");
578
+ const cfg = await loadConfig();
579
+ if (mounted) setConfig(cfg);
580
+ } catch {}
581
+
582
+ // Load features
583
+ try {
584
+ const { getFeatureManager } = await import("../core/features.mjs");
585
+ const fm = getFeatureManager();
586
+ const list = await fm.list();
587
+ if (mounted) setFeatures(list);
588
+ } catch {}
589
+
590
+ // Load browser status
591
+ try {
592
+ const bs = engine?.browser?.status?.() ?? null;
593
+ if (mounted) setBrowserSt(bs);
594
+ } catch {}
595
+ })();
596
+ return () => { mounted = false; };
597
+ }, [refreshKey]);
598
+
599
+ const budget = engine?.budget;
600
+ const budgetSpent = budget?.sessionSpend ?? 0;
601
+ const maxBudget = budget?.maxBudgetUsd ?? null;
602
+
603
+ const personality = engine?._personality ?? config?.personality ?? "default";
604
+ const effort = engine?._effort ?? config?.effort ?? "medium";
605
+ const permMode = engine?.permissions?.getPolicy?.("run_command") ?? "approve";
606
+ const activeWorkstream = engine?.activeWorkstream ?? engine?._activeWorkstream ?? "default";
607
+ const activeAgent = engine?._activeAgent ?? config?.defaultAgent ?? "default";
608
+
609
+ const configDir = path.join(os.homedir(), ".wispy");
610
+ const providerLabel = config?.provider ?? engine?.provider ?? "?";
611
+ const modelLabel = config?.model ?? engine?.model ?? "?";
612
+
613
+ return React.createElement(
614
+ Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
615
+ React.createElement(Text, { bold: true, color: "green" }, "Settings"),
616
+ React.createElement(Box, { height: 1 }),
617
+
618
+ // ── General ──
619
+ React.createElement(Text, { bold: true, color: "cyan" }, "General"),
620
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1 },
621
+ ...([
622
+ ["Version", PKG_VERSION],
623
+ ["Config dir", configDir],
624
+ ["Provider", providerLabel],
625
+ ["Model", modelLabel],
626
+ ["Personality", personality],
627
+ ["Effort", effort],
628
+ ["Security", permMode],
629
+ ]).map(([k, v], i) => React.createElement(
630
+ Box, { key: i },
631
+ React.createElement(Text, { color: "white", dimColor: true }, k.padEnd(14)),
632
+ React.createElement(Text, { color: "white" }, v ?? "?"),
633
+ )),
634
+ ),
635
+
636
+ // ── Active Session ──
637
+ React.createElement(Text, { bold: true, color: "cyan" }, "Active Session"),
638
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1 },
639
+ React.createElement(Box, {},
640
+ React.createElement(Text, { dimColor: true }, "Agent".padEnd(14)),
641
+ React.createElement(Text, {}, activeAgent ?? "default"),
642
+ ),
643
+ React.createElement(Box, {},
644
+ React.createElement(Text, { dimColor: true }, "Workstream".padEnd(14)),
645
+ React.createElement(Text, {}, activeWorkstream),
646
+ ),
647
+ React.createElement(Box, {},
648
+ React.createElement(Text, { dimColor: true }, "Budget".padEnd(14)),
649
+ React.createElement(Text, { color: budgetSpent > 0 ? "yellow" : "white" },
650
+ `$${budgetSpent.toFixed(4)}${maxBudget != null ? ` / $${maxBudget.toFixed(2)}` : " (no limit)"}`),
651
+ ),
652
+ ),
653
+
654
+ // ── Features ──
655
+ features.length > 0 ? React.createElement(React.Fragment, null,
656
+ React.createElement(Text, { bold: true, color: "cyan" }, "Features"),
657
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1 },
658
+ ...features.map((f, i) => React.createElement(
659
+ Box, { key: i },
660
+ React.createElement(Text, { color: f.enabled ? "green" : "red" }, f.enabled ? "✓ " : "✗ "),
661
+ React.createElement(Text, { color: f.enabled ? "white" : "gray" }, f.name),
662
+ React.createElement(Text, { dimColor: true }, ` [${f.stage}]`),
663
+ )),
664
+ ),
665
+ ) : null,
666
+
667
+ // ── Browser ──
668
+ React.createElement(Text, { bold: true, color: "cyan" }, "Browser"),
669
+ React.createElement(Box, { paddingLeft: 2, marginBottom: 1 },
670
+ React.createElement(Text, { dimColor: true }, "Status".padEnd(14)),
671
+ browserSt?.session
672
+ ? React.createElement(Text, { color: "green" }, `connected (${browserSt.session.browser ?? "unknown"})`)
673
+ : React.createElement(Text, { dimColor: true }, "disconnected"),
674
+ ),
675
+
676
+ // ── Keys ──
677
+ React.createElement(Box, { height: 1 }),
678
+ React.createElement(Text, { dimColor: true },
679
+ "Keys: /model <n> /trust <level> /agent <n> /features enable <n> /cost"),
680
+ );
681
+ }
682
+
683
+ // ─── Action Timeline Bar ─────────────────────────────────────────────────────
684
+
685
+ function TimelineBar({ events }) {
686
+ const last = events.slice(-TIMELINE_LINES);
687
+ if (last.length === 0) return null;
688
+
689
+ return React.createElement(
690
+ Box, {
691
+ flexDirection: "column",
692
+ borderStyle: "single",
693
+ borderColor: "gray",
694
+ borderBottom: false,
695
+ borderLeft: false,
696
+ borderRight: false,
697
+ paddingX: 1,
698
+ },
699
+ ...last.map((evt, i) => {
700
+ const icon = TOOL_ICONS[evt.toolName] ?? "[tool]";
701
+ const statusIcon = evt.denied ? "✗" : evt.dryRun ? "◯" : evt.success === null ? "●" : evt.success ? "✓" : "✗";
702
+ const color = evt.denied ? "red" : evt.success === null ? "yellow" : evt.success ? "green" : "red";
703
+ const ts = fmtTime(evt.timestamp);
704
+ const arg = evt.primaryArg ? ` ${truncate(evt.primaryArg, 28)}` : "";
705
+ return React.createElement(
706
+ Box, { key: i },
707
+ React.createElement(Text, { dimColor: true }, `${ts} `),
708
+ React.createElement(Text, { dimColor: true }, `${icon} `),
709
+ React.createElement(Text, { color: "cyan" }, evt.toolName),
710
+ React.createElement(Text, { dimColor: true }, arg),
711
+ React.createElement(Text, { color }, ` ${statusIcon}`),
712
+ );
713
+ }),
714
+ );
715
+ }
716
+
717
+ // ─── Approval Dialog ─────────────────────────────────────────────────────────
718
+
719
+ function ApprovalDialog({ action, onApprove, onDeny, onDryRun }) {
720
+ const riskMap = {
721
+ run_command: "HIGH", git: "HIGH", keychain: "HIGH", delete_file: "HIGH",
722
+ write_file: "MEDIUM", file_edit: "MEDIUM", node_execute: "HIGH",
723
+ };
724
+ const risk = riskMap[action?.toolName] ?? "MEDIUM";
725
+ const riskColor = risk === "HIGH" ? "red" : "yellow";
726
+
727
+ const args = action?.args ?? {};
728
+ let argSummary = "";
729
+ if (args.command) argSummary = truncate(args.command, 50);
730
+ else if (args.path) argSummary = truncate(args.path, 50);
731
+ else argSummary = truncate(JSON.stringify(args), 50);
732
+
733
+ useInput((input, key) => {
734
+ const ch = input.toLowerCase();
735
+ if (ch === "y") onApprove?.();
736
+ else if (ch === "n" || key.escape) onDeny?.();
737
+ else if (ch === "d") onDryRun?.();
738
+ });
739
+
740
+ return React.createElement(
741
+ Box, {
742
+ flexDirection: "column",
743
+ borderStyle: "round",
744
+ borderColor: "yellow",
745
+ paddingX: 2,
746
+ paddingY: 1,
747
+ marginY: 1,
748
+ marginX: 2,
749
+ },
750
+ React.createElement(Text, { bold: true, color: "yellow" }, "! Permission Required"),
751
+ React.createElement(Box, { height: 1 }),
752
+ React.createElement(Box, {},
753
+ React.createElement(Text, { dimColor: true }, "Tool: "),
754
+ React.createElement(Text, { bold: true }, action?.toolName ?? "?")),
755
+ argSummary ? React.createElement(Box, {},
756
+ React.createElement(Text, { dimColor: true }, "Action: "),
757
+ React.createElement(Text, { wrap: "wrap" }, argSummary)) : null,
758
+ React.createElement(Box, {},
759
+ React.createElement(Text, { dimColor: true }, "Risk: "),
760
+ React.createElement(Text, { bold: true, color: riskColor }, risk)),
761
+ React.createElement(Box, { height: 1 }),
762
+ React.createElement(Box, { gap: 2 },
763
+ React.createElement(Text, { color: "green", bold: true }, "[Y] Approve"),
764
+ React.createElement(Text, { color: "red", bold: true }, "[N] Deny"),
765
+ React.createElement(Text, { color: "cyan", bold: true }, "[D] Dry-run")),
766
+ );
767
+ }
768
+
769
+ // ─── Help Overlay ─────────────────────────────────────────────────────────────
770
+
771
+ function HelpOverlay({ onClose }) {
772
+ useInput((input, key) => {
773
+ if (input === "?" || input === "q" || key.escape) onClose?.();
774
+ });
775
+
776
+ const shortcuts = [
777
+ ["Tab", "Switch view"],
778
+ ["1-6", "Jump to view (1=chat 2=overview 3=agents 4=memory 5=audit 6=settings)"],
779
+ ["o", "Overview view"],
780
+ ["a", "Agents view"],
781
+ ["m", "Memory view"],
782
+ ["u", "Audit view"],
783
+ ["s", "Settings view"],
784
+ ["t", "Toggle timeline"],
785
+ ["?", "Toggle this help"],
786
+ ["Ctrl+L", "Clear chat"],
787
+ ["Ctrl+C / q", "Quit"],
788
+ ["/help", "Show all slash commands"],
789
+ ["/model <n>", "Change model"],
790
+ ["/trust <level>", "Change security level"],
791
+ ["/agent <n>", "Switch agent"],
792
+ ["/features enable <n>", "Toggle feature"],
793
+ ["/cost", "Show budget"],
794
+ ["/clear", "Clear conversation"],
795
+ ["/ws <name>", "Switch workstream"],
796
+ ];
797
+
798
+ return React.createElement(
799
+ Box, {
800
+ flexDirection: "column",
801
+ borderStyle: "round",
802
+ borderColor: "cyan",
803
+ paddingX: 2,
804
+ paddingY: 1,
805
+ marginY: 1,
806
+ marginX: 2,
807
+ },
808
+ React.createElement(Text, { bold: true, color: "cyan" }, "Wispy — Keyboard Shortcuts"),
809
+ React.createElement(Box, { height: 1 }),
810
+ ...shortcuts.map(([key, desc], i) => React.createElement(
811
+ Box, { key: i },
812
+ React.createElement(Text, { color: "yellow", bold: true }, key.padEnd(22)),
813
+ React.createElement(Text, { dimColor: true, wrap: "wrap" }, desc),
814
+ )),
815
+ React.createElement(Box, { height: 1 }),
816
+ React.createElement(Text, { dimColor: true }, "Press ? or q to close"),
817
+ );
818
+ }
819
+
820
+ // ─── Command Palette (autocomplete dropdown) ──────────────────────────────────
821
+
822
+ function CommandPalette({ query, onSelect, onDismiss }) {
823
+ const [selectedIdx, setSelectedIdx] = useState(0);
824
+
825
+ const matches = filterCommands(query, COMMANDS, 8);
826
+
827
+ useEffect(() => { setSelectedIdx(0); }, [query]);
828
+
829
+ useInput((input, key) => {
830
+ if (matches.length === 0) return;
831
+ if (key.upArrow) { setSelectedIdx(i => Math.max(0, i - 1)); return; }
832
+ if (key.downArrow) { setSelectedIdx(i => Math.min(matches.length - 1, i + 1)); return; }
833
+ if (key.return) { onSelect?.(matches[selectedIdx]?.cmd ?? query); return; }
834
+ if (key.escape || (key.ctrl && input === "c")) { onDismiss?.(); return; }
835
+ });
836
+
837
+ if (matches.length === 0) return null;
838
+
839
+ const maxCmdLen = Math.max(...matches.map(m => m.cmd.length));
840
+
841
+ return React.createElement(
842
+ Box, {
843
+ flexDirection: "column",
844
+ borderStyle: "single",
845
+ borderColor: "cyan",
846
+ paddingX: 1,
847
+ marginBottom: 0,
848
+ },
849
+ React.createElement(
850
+ Box, { paddingX: 1 },
851
+ React.createElement(Text, { bold: true, color: "cyan" }, "Commands "),
852
+ React.createElement(Text, { dimColor: true }, "↑↓ navigate Enter select Esc dismiss"),
853
+ ),
854
+ ...matches.map((cmd, i) => {
855
+ const isActive = i === selectedIdx;
856
+ const cmdStr = cmd.cmd.padEnd(maxCmdLen);
857
+ return React.createElement(
858
+ Box, { key: cmd.cmd },
859
+ isActive
860
+ ? React.createElement(Text, { color: "black", backgroundColor: "cyan" },
861
+ ` ${cmdStr} ${cmd.desc} `)
862
+ : React.createElement(Box, {},
863
+ React.createElement(Text, { color: "cyan" }, ` ${cmd.cmd}`),
864
+ React.createElement(Text, { dimColor: true },
865
+ " ".repeat(Math.max(1, maxCmdLen - cmd.cmd.length + 2)) + cmd.desc),
866
+ ),
867
+ );
868
+ }),
869
+ );
870
+ }
871
+
872
+ // ─── Input Area ───────────────────────────────────────────────────────────────
873
+ // NOTE: This area shows the current input value as static text.
874
+ // The REAL cursor and input are managed by ReadlineInput (readline-based) for
875
+ // proper Korean/CJK IME support. Ink rendering is decoupled from input.
876
+
877
+ function InputArea({ value, loading, workstream, view }) {
878
+ const prompt = `${workstream} › `;
879
+ const placeholder = loading
880
+ ? "waiting for response..."
881
+ : view !== "chat"
882
+ ? `${view} mode — Tab to switch to chat`
883
+ : "Type a message… (/help for commands | Tab to switch views)";
884
+
885
+ return React.createElement(
886
+ Box, {
887
+ borderStyle: "single",
888
+ borderColor: loading ? "yellow" : view === "chat" ? "green" : "gray",
889
+ paddingX: 1,
890
+ },
891
+ loading
892
+ ? React.createElement(Box, {},
893
+ React.createElement(Spinner, { type: "dots" }),
894
+ React.createElement(Text, { color: "yellow" }, ` ${prompt}thinking...`))
895
+ : React.createElement(Box, {},
896
+ React.createElement(Text, { color: view === "chat" ? "green" : "gray" }, prompt),
897
+ value
898
+ ? React.createElement(Text, {}, value)
899
+ : React.createElement(Text, { dimColor: true }, placeholder)),
900
+ );
901
+ }
902
+
903
+ // ─── Persistence helpers ──────────────────────────────────────────────────────
904
+
905
+ async function loadConversation(workstream) {
906
+ const file = path.join(CONVERSATIONS_DIR, `${workstream}.json`);
907
+ try {
908
+ const raw = await readFile(file, "utf8");
909
+ return JSON.parse(raw);
910
+ } catch { return []; }
911
+ }
912
+
913
+ async function saveConversation(workstream, messages) {
914
+ await mkdir(CONVERSATIONS_DIR, { recursive: true });
915
+ const file = path.join(CONVERSATIONS_DIR, `${workstream}.json`);
916
+ await writeFile(file, JSON.stringify(messages.slice(-50), null, 2) + "\n", "utf8");
917
+ }
918
+
919
+ // ─── Sidebar Data Loaders ─────────────────────────────────────────────────────
920
+
921
+ async function loadWorkstreams() {
922
+ try {
923
+ const wsDir = path.join(WISPY_DIR, "workstreams");
924
+ const dirs = await readdir(wsDir);
925
+ const result = [];
926
+ for (const d of dirs) {
927
+ try {
928
+ const s = await stat(path.join(wsDir, d));
929
+ if (s.isDirectory()) result.push(d);
930
+ } catch {}
931
+ }
932
+ return result.length ? result : ["default"];
933
+ } catch { return ["default"]; }
934
+ }
935
+
936
+ async function loadMemoryFiles() {
937
+ try {
938
+ const files = await readdir(MEMORY_DIR);
939
+ const result = [];
940
+ for (const f of files) {
941
+ if (!f.endsWith(".md")) continue;
942
+ const fp = path.join(MEMORY_DIR, f);
943
+ try {
944
+ const s = await stat(fp);
945
+ const raw = await readFile(fp, "utf8");
946
+ result.push({
947
+ key: f.replace(".md", ""),
948
+ preview: raw.split("\n").find(l => l.trim() && !l.startsWith("_")) ?? "",
949
+ size: s.size,
950
+ updatedAt: s.mtime.toISOString(),
951
+ });
952
+ } catch {}
953
+ }
954
+ return result;
955
+ } catch { return []; }
956
+ }
957
+
958
+ async function loadCronCount() {
959
+ try {
960
+ const jobsFile = path.join(WISPY_DIR, "cron", "jobs.json");
961
+ const raw = await readFile(jobsFile, "utf8");
962
+ const jobs = JSON.parse(raw);
963
+ return Array.isArray(jobs) ? jobs.filter(j => j.enabled !== false).length : 0;
964
+ } catch { return 0; }
965
+ }
966
+
967
+ async function loadSyncStatus() {
968
+ try {
969
+ const syncFile = path.join(WISPY_DIR, "sync.json");
970
+ const raw = await readFile(syncFile, "utf8");
971
+ const cfg = JSON.parse(raw);
972
+ return cfg.auto ? "auto" : "manual";
973
+ } catch { return "off"; }
974
+ }
975
+
976
+ async function loadOverviewData(workstreams) {
977
+ const result = {};
978
+ for (const ws of workstreams) {
979
+ try {
980
+ const convFile = path.join(CONVERSATIONS_DIR, `${ws}.json`);
981
+ const raw = await readFile(convFile, "utf8");
982
+ const conv = JSON.parse(raw);
983
+ const lastMsg = conv.filter(m => m.role === "assistant").pop();
984
+ const lastUser = conv.filter(m => m.role === "user").pop();
985
+ const lastActivityFile = await stat(convFile).catch(() => null);
986
+ result[ws] = {
987
+ lastActivity: lastActivityFile?.mtime?.toISOString() ?? null,
988
+ lastMessage: lastMsg?.content?.slice(0, 80) ?? lastUser?.content?.slice(0, 80) ?? null,
989
+ agents: 0,
990
+ };
991
+ } catch {
992
+ result[ws] = { lastActivity: null, lastMessage: null, agents: 0 };
993
+ }
994
+
995
+ try {
996
+ const workMdPath = path.join(WISPY_DIR, "workstreams", ws, "work.md");
997
+ const wmd = await readFile(workMdPath, "utf8");
998
+ const firstLine = wmd.split("\n").find(l => l.trim());
999
+ result[ws].workMd = firstLine?.slice(0, 60);
1000
+ } catch {}
1001
+ }
1002
+ return result;
1003
+ }
1004
+
1005
+ // ─── Main App ─────────────────────────────────────────────────────────────────
1006
+
1007
+ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1008
+ const { exit } = useApp();
1009
+ const { stdout } = useStdout();
1010
+
1011
+ // Terminal dimensions
1012
+ const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80);
1013
+ const [termHeight, setTermHeight] = useState(stdout?.rows ?? 24);
1014
+
1015
+ // View state
1016
+ const [view, setView] = useState("chat");
1017
+ const [showHelp, setShowHelp] = useState(false);
1018
+ const [showTimeline, setShowTimeline] = useState(false);
1019
+
1020
+ // Workstream state
1021
+ const [activeWorkstream, setActiveWorkstream] = useState(initialWorkstream);
1022
+ const [workstreams, setWorkstreams] = useState([initialWorkstream]);
1023
+
1024
+ // Chat state
1025
+ const [messages, setMessages] = useState([]);
1026
+ const [inputValue, setInputValue] = useState("");
1027
+ const [loading, setLoading] = useState(false);
1028
+
1029
+ // Command palette
1030
+ const [showPalette, setShowPalette] = useState(false);
1031
+
1032
+ // Engine stats
1033
+ const [model, setModel] = useState(engine.model ?? "?");
1034
+ const [provider, setProvider] = useState(engine.provider ?? "?");
1035
+ const [totalTokens, setTotalTokens] = useState(0);
1036
+ const [totalCost, setTotalCost] = useState(0);
1037
+ const [syncStatus, setSyncStatus] = useState("off");
1038
+
1039
+ // Trust UX
1040
+ const [pendingApproval, setPendingApproval] = useState(null);
1041
+ const [timeline, setTimeline] = useState([]);
1042
+
1043
+ // Sidebar data
1044
+ const [agents, setAgents] = useState([]);
1045
+ const [memoryFiles, setMemoryFiles] = useState([]);
1046
+ const [memoryQuery, setMemoryQuery] = useState("");
1047
+ const [cronCount, setCronCount] = useState(0);
1048
+ const [userModelLoaded, setUserModelLoaded] = useState(false);
1049
+ const [browserStatus, setBrowserStatus] = useState(null);
1050
+ const [budgetSpent, setBudgetSpent] = useState(0);
1051
+ const [maxBudget, setMaxBudget] = useState(null);
1052
+
1053
+ // Overview data
1054
+ const [overviewData, setOverviewData] = useState({});
1055
+
1056
+ // Refs
1057
+ const approvalResolverRef = useRef(null);
1058
+ const conversationRef = useRef([]);
1059
+
1060
+ // ── Terminal resize ──
1061
+ useEffect(() => {
1062
+ const onResize = () => {
1063
+ setTermWidth(stdout?.columns ?? 80);
1064
+ setTermHeight(stdout?.rows ?? 24);
1065
+ };
1066
+ stdout?.on?.("resize", onResize);
1067
+ return () => stdout?.off?.("resize", onResize);
1068
+ }, [stdout]);
1069
+
1070
+ // ── Load initial conversation ──
1071
+ useEffect(() => {
1072
+ loadConversation(initialWorkstream).then(msgs => {
1073
+ conversationRef.current = msgs;
1074
+ setMessages(msgs.map(m => ({ ...m, _loaded: true })));
1075
+ });
1076
+ }, [initialWorkstream]);
1077
+
1078
+ // ── Load sidebar data ──
1079
+ useEffect(() => {
1080
+ const loadAll = async () => {
1081
+ const [wsList, memFiles, cron, sync] = await Promise.all([
1082
+ loadWorkstreams(),
1083
+ loadMemoryFiles(),
1084
+ loadCronCount(),
1085
+ loadSyncStatus(),
1086
+ ]);
1087
+
1088
+ const wsSet = new Set(wsList);
1089
+ wsSet.add(initialWorkstream);
1090
+ const wsFinal = Array.from(wsSet);
1091
+
1092
+ setWorkstreams(wsFinal);
1093
+ setMemoryFiles(memFiles);
1094
+ setCronCount(cron);
1095
+ setSyncStatus(sync);
1096
+ setUserModelLoaded(memFiles.some(f => f.key === "user"));
1097
+
1098
+ // Browser status
1099
+ try {
1100
+ const bs = engine?.browser?.status?.() ?? null;
1101
+ setBrowserStatus(bs);
1102
+ } catch {}
1103
+
1104
+ // Budget
1105
+ try {
1106
+ const bud = engine?.budget;
1107
+ if (bud) {
1108
+ setBudgetSpent(bud.sessionSpend ?? 0);
1109
+ setMaxBudget(bud.maxBudgetUsd ?? null);
1110
+ }
1111
+ } catch {}
1112
+ };
1113
+ loadAll();
1114
+ const interval = setInterval(loadAll, 15_000);
1115
+ return () => clearInterval(interval);
1116
+ }, [initialWorkstream, engine]);
1117
+
1118
+ // ── Load overview data — refresh when view switches to overview ──
1119
+ useEffect(() => {
1120
+ const refresh = async () => {
1121
+ const data = await loadOverviewData(workstreams);
1122
+ setOverviewData(data);
1123
+ };
1124
+ refresh();
1125
+ const interval = setInterval(refresh, 20_000);
1126
+ return () => clearInterval(interval);
1127
+ }, [workstreams]);
1128
+
1129
+ // ── Refresh view data on view switch ──
1130
+ useEffect(() => {
1131
+ if (view === "overview") {
1132
+ loadOverviewData(workstreams).then(setOverviewData);
1133
+ try { setBrowserStatus(engine?.browser?.status?.() ?? null); } catch {}
1134
+ }
1135
+ if (view === "memory") {
1136
+ loadMemoryFiles().then(setMemoryFiles);
1137
+ }
1138
+ if (view === "settings") {
1139
+ try {
1140
+ const bud = engine?.budget;
1141
+ if (bud) {
1142
+ setBudgetSpent(bud.sessionSpend ?? 0);
1143
+ setMaxBudget(bud.maxBudgetUsd ?? null);
1144
+ }
1145
+ setBrowserStatus(engine?.browser?.status?.() ?? null);
1146
+ } catch {}
1147
+ }
1148
+ }, [view]);
1149
+
1150
+ // ── Load agents ──
1151
+ useEffect(() => {
1152
+ const loadAgents = async () => {
1153
+ try {
1154
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
1155
+ const raw = await readFile(agentsFile, "utf8");
1156
+ const all = JSON.parse(raw);
1157
+ setAgents(all.filter(a =>
1158
+ a.status === "running" || a.status === "pending" || a.status === "completed" || a.status === "done"
1159
+ ).slice(-10));
1160
+ } catch { setAgents([]); }
1161
+ };
1162
+ loadAgents();
1163
+ const interval = setInterval(loadAgents, 10_000);
1164
+ return () => clearInterval(interval);
1165
+ }, []);
1166
+
1167
+ // ── Wire harness events ──
1168
+ useEffect(() => {
1169
+ const harness = engine.harness;
1170
+ if (!harness) return;
1171
+
1172
+ const onStart = ({ toolName, args }) => {
1173
+ const primaryArg = args?.path ?? args?.command ?? args?.query ?? args?.task ?? args?.key ?? "";
1174
+ setTimeline(prev => [...prev.slice(-50), {
1175
+ toolName, args, primaryArg,
1176
+ timestamp: new Date().toISOString(),
1177
+ success: null, denied: false, dryRun: false,
1178
+ }]);
1179
+ };
1180
+
1181
+ const onComplete = ({ toolName, receipt }) => {
1182
+ setTimeline(prev => {
1183
+ const updated = [...prev];
1184
+ for (let i = updated.length - 1; i >= 0; i--) {
1185
+ if (updated[i].toolName === toolName && updated[i].success === null) {
1186
+ updated[i] = { ...updated[i], success: receipt?.success ?? true, dryRun: receipt?.dryRun ?? false };
1187
+ break;
1188
+ }
1189
+ }
1190
+ return updated;
1191
+ });
1192
+ };
1193
+
1194
+ const onDenied = ({ toolName }) => {
1195
+ setTimeline(prev => {
1196
+ const updated = [...prev];
1197
+ for (let i = updated.length - 1; i >= 0; i--) {
1198
+ if (updated[i].toolName === toolName && updated[i].success === null) {
1199
+ updated[i] = { ...updated[i], success: false, denied: true };
1200
+ break;
1201
+ }
1202
+ }
1203
+ return updated;
1204
+ });
1205
+ };
1206
+
1207
+ harness.on("tool:start", onStart);
1208
+ harness.on("tool:complete", onComplete);
1209
+ harness.on("tool:denied", onDenied);
1210
+ return () => {
1211
+ harness.off("tool:start", onStart);
1212
+ harness.off("tool:complete", onComplete);
1213
+ harness.off("tool:denied", onDenied);
1214
+ };
1215
+ }, [engine]);
1216
+
1217
+ // ── Wire approval handler ──
1218
+ useEffect(() => {
1219
+ engine.permissions.setApprovalHandler(async (action) => {
1220
+ return new Promise((resolve) => {
1221
+ approvalResolverRef.current = resolve;
1222
+ setPendingApproval({ action });
1223
+ });
1224
+ });
1225
+ return () => { engine.permissions.setApprovalHandler(null); };
1226
+ }, [engine]);
1227
+
1228
+ // ── Keyboard shortcut handler (called from ReadlineInput keypress) ──
1229
+ const handleKeypress = useCallback((str, key) => {
1230
+ if (pendingApproval || showHelp) return;
1231
+
1232
+ if (key.name === "tab" && !loading) {
1233
+ const idx = VIEWS.indexOf(view);
1234
+ setView(VIEWS[(idx + 1) % VIEWS.length]);
1235
+ return;
1236
+ }
1237
+
1238
+ // Only handle single-char shortcuts when no text is being typed
1239
+ const input = str ?? "";
1240
+ if (input === "?" && !inputValue) { setShowHelp(true); return; }
1241
+ if (input === "1" && !inputValue && !loading) { setView("chat"); return; }
1242
+ if (input === "2" && !inputValue && !loading) { setView("overview"); return; }
1243
+ if (input === "3" && !inputValue && !loading) { setView("agents"); return; }
1244
+ if (input === "4" && !inputValue && !loading) { setView("memory"); return; }
1245
+ if (input === "5" && !inputValue && !loading) { setView("audit"); return; }
1246
+ if (input === "6" && !inputValue && !loading) { setView("settings"); return; }
1247
+ if (input === "o" && !inputValue && !loading) { setView("overview"); return; }
1248
+ if (input === "a" && !inputValue && !loading) { setView("agents"); return; }
1249
+ if (input === "m" && !inputValue && !loading) { setView("memory"); return; }
1250
+ if (input === "u" && !inputValue && !loading) { setView("audit"); return; }
1251
+ if (input === "s" && !inputValue && !loading) { setView("settings"); return; }
1252
+ if (input === "t" && !inputValue) { setShowTimeline(prev => !prev); return; }
1253
+
1254
+ // Workstream number switch (7-9 and beyond 6)
1255
+ if (/^[7-9]$/.test(input) && !inputValue && !loading) {
1256
+ const idx = parseInt(input) - 1;
1257
+ if (idx < workstreams.length) {
1258
+ switchWorkstream(workstreams[idx]);
1259
+ }
1260
+ return;
1261
+ }
1262
+
1263
+ if (input === "q" && !inputValue && !loading) {
1264
+ exit();
1265
+ process.exit(0);
1266
+ }
1267
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1268
+ }, [pendingApproval, showHelp, loading, view, inputValue, workstreams, exit]);
1269
+
1270
+ // ── Wire ReadlineInput — the source of all keyboard input ──
1271
+ const rlRef = useRef(null);
1272
+
1273
+ // Keep stable refs so the ReadlineInput closure always calls current callbacks
1274
+ // without needing to restart readline.
1275
+ const handleKeypressRef = useRef(null);
1276
+ const handleSubmitRef = useRef(null);
1277
+ const handleInputChangeRef = useRef(null);
1278
+ useEffect(() => { handleKeypressRef.current = handleKeypress; }, [handleKeypress]);
1279
+
1280
+ useEffect(() => {
1281
+ const rl = new ReadlineInput({
1282
+ onSubmit: (line) => { handleSubmitRef.current?.(line); },
1283
+ onUpdate: (text) => { handleInputChangeRef.current?.(text); },
1284
+ onKeypress: (str, key) => { handleKeypressRef.current?.(str, key); },
1285
+ onCtrlC: () => { exit(); process.exit(0); },
1286
+ onCtrlL: () => {
1287
+ conversationRef.current = [];
1288
+ setMessages([]);
1289
+ saveConversation(initialWorkstream, []).catch(() => {});
1290
+ },
1291
+ prompt: `${initialWorkstream} › `,
1292
+ });
1293
+ rl.start();
1294
+ rlRef.current = rl;
1295
+ return () => rl.stop();
1296
+ // Only run once on mount — refs keep callbacks up to date.
1297
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1298
+ }, []);
1299
+
1300
+ // ── Switch workstream ──
1301
+ const switchWorkstream = useCallback(async (ws) => {
1302
+ if (ws === activeWorkstream) return;
1303
+ setActiveWorkstream(ws);
1304
+ setMessages([]);
1305
+ setInputValue("");
1306
+ conversationRef.current = [];
1307
+ const msgs = await loadConversation(ws);
1308
+ conversationRef.current = msgs;
1309
+ setMessages(msgs);
1310
+ setView("chat");
1311
+ engine._activeWorkstream = ws;
1312
+ engine._workMdLoaded = false;
1313
+ engine._workMdContent = null;
1314
+ }, [activeWorkstream, engine]);
1315
+
1316
+ // ── Approval handlers ──
1317
+ const handleApprove = useCallback(() => {
1318
+ approvalResolverRef.current?.(true);
1319
+ approvalResolverRef.current = null;
1320
+ setPendingApproval(null);
1321
+ }, []);
1322
+
1323
+ const handleDeny = useCallback(() => {
1324
+ approvalResolverRef.current?.(false);
1325
+ approvalResolverRef.current = null;
1326
+ setPendingApproval(null);
1327
+ }, []);
1328
+
1329
+ const handleDryRun = useCallback(async () => {
1330
+ const action = pendingApproval?.action;
1331
+ approvalResolverRef.current?.(false);
1332
+ approvalResolverRef.current = null;
1333
+ setPendingApproval(null);
1334
+ if (action) {
1335
+ setMessages(prev => [...prev, {
1336
+ role: "assistant",
1337
+ 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.)*`,
1338
+ }]);
1339
+ }
1340
+ }, [pendingApproval]);
1341
+
1342
+ // ── Palette visibility ──
1343
+ const handleInputChange = useCallback((val) => {
1344
+ setInputValue(val);
1345
+ setShowPalette(val.startsWith("/") && val.length >= 2);
1346
+ }, []);
1347
+
1348
+ // Keep refs up to date
1349
+ useEffect(() => { handleInputChangeRef.current = handleInputChange; }, [handleInputChange]);
1350
+
1351
+ const handlePaletteSelect = useCallback((cmd) => {
1352
+ const base = cmd.replace(/<[^>]+>/g, "").trimEnd();
1353
+ setInputValue(base);
1354
+ setShowPalette(false);
1355
+ }, []);
1356
+
1357
+ const handlePaletteDismiss = useCallback(() => {
1358
+ setShowPalette(false);
1359
+ }, []);
1360
+
1361
+ // ── Message submit ──
1362
+ const handleSubmit = useCallback(async (value) => {
1363
+ // Clear readline's internal line buffer after we consume the value
1364
+ if (rlRef.current?._rl) {
1365
+ rlRef.current._rl.line = "";
1366
+ }
1367
+ if (pendingApproval) return;
1368
+ const input = value.trim();
1369
+ if (!input || loading) return;
1370
+ setInputValue("");
1371
+ setShowPalette(false);
1372
+
1373
+ if (view !== "chat") setView("chat");
1374
+
1375
+ // Slash commands
1376
+ if (input.startsWith("/")) {
1377
+ const parts = input.split(/\s+/);
1378
+ const cmd = parts[0].toLowerCase();
1379
+
1380
+ if (cmd === "/quit" || cmd === "/exit") { exit(); process.exit(0); return; }
1381
+ if (cmd === "/clear") {
1382
+ conversationRef.current = [];
1383
+ setMessages([{ role: "system_info", content: "Conversation cleared." }]);
1384
+ await saveConversation(activeWorkstream, []);
1385
+ return;
1386
+ }
1387
+ if (cmd === "/cost") {
1388
+ const bud = engine?.budget;
1389
+ const spent = bud?.sessionSpend ?? totalCost;
1390
+ const max = bud?.maxBudgetUsd;
1391
+ setMessages(prev => [...prev, {
1392
+ role: "system_info",
1393
+ content: `Budget: $${spent.toFixed(4)}${max ? ` / $${max.toFixed(2)}` : ""} — ${totalTokens} tokens`,
1394
+ }]);
1395
+ return;
1396
+ }
1397
+ if (cmd === "/timeline") { setView("audit"); return; }
1398
+ if (cmd === "/overview" || cmd === "/o") { setView("overview"); return; }
1399
+ if (cmd === "/agents" || cmd === "/a") { setView("agents"); return; }
1400
+ if (cmd === "/memories" || cmd === "/m") { setView("memory"); return; }
1401
+ if (cmd === "/audit" || cmd === "/u") { setView("audit"); return; }
1402
+ if (cmd === "/settings" || cmd === "/s") { setView("settings"); return; }
1403
+ if (cmd === "/model" && parts[1]) {
1404
+ engine.providers.setModel(parts[1]);
1405
+ setModel(parts[1]);
1406
+ setMessages(prev => [...prev, { role: "system_info", content: `Model → ${parts[1]}` }]);
1407
+ return;
1408
+ }
1409
+ if (cmd === "/workstream" && parts[1]) { await switchWorkstream(parts[1]); return; }
1410
+ if (cmd === "/ws" && parts[1]) { await switchWorkstream(parts[1]); return; }
1411
+ if (cmd === "/help") { setShowHelp(true); return; }
1412
+ if (cmd === "/trust" && parts[1]) {
1413
+ try {
1414
+ engine.permissions.setPolicy("run_command", parts[1]);
1415
+ setMessages(prev => [...prev, { role: "system_info", content: `Security level → ${parts[1]}` }]);
1416
+ } catch (e) {
1417
+ setMessages(prev => [...prev, { role: "system_info", content: `Error: ${e.message}` }]);
1418
+ }
1419
+ return;
1420
+ }
1421
+ }
1422
+
1423
+ // Add user message
1424
+ const userMsg = { role: "user", content: input };
1425
+ setMessages(prev => [...prev, userMsg]);
1426
+ conversationRef.current.push(userMsg);
1427
+ setLoading(true);
1428
+
1429
+ try {
1430
+ const result = await engine.processMessage(null, input, {
1431
+ onChunk: () => {},
1432
+ onToolCall: (name, args) => {
1433
+ setMessages(prev => [...prev, { role: "tool_call", name, args, result: null, receipt: null }]);
1434
+ },
1435
+ onToolResult: (name, toolResult) => {
1436
+ setMessages(prev => {
1437
+ const updated = [...prev];
1438
+ for (let i = updated.length - 1; i >= 0; i--) {
1439
+ if (updated[i].role === "tool_call" && updated[i].name === name && updated[i].result === null) {
1440
+ updated[i] = { ...updated[i], result: toolResult };
1441
+ break;
1442
+ }
1443
+ }
1444
+ return updated;
1445
+ });
1446
+ },
1447
+ onReceipt: (receipt) => {
1448
+ if (!receipt?.toolName) return;
1449
+ setMessages(prev => {
1450
+ const updated = [...prev];
1451
+ for (let i = updated.length - 1; i >= 0; i--) {
1452
+ if (updated[i].role === "tool_call" && updated[i].name === receipt.toolName) {
1453
+ updated[i] = { ...updated[i], receipt };
1454
+ break;
1455
+ }
1456
+ }
1457
+ return updated;
1458
+ });
1459
+ },
1460
+ noSave: true,
1461
+ });
1462
+
1463
+ const responseText = result.content;
1464
+
1465
+ // Update token/cost tracking
1466
+ const { input: inputToks = 0, output: outputToks = 0 } = engine.providers.sessionTokens ?? {};
1467
+ setTotalTokens(inputToks + outputToks);
1468
+
1469
+ // Use budget manager if available
1470
+ const bud = engine?.budget;
1471
+ if (bud) {
1472
+ setBudgetSpent(bud.sessionSpend ?? 0);
1473
+ setMaxBudget(bud.maxBudgetUsd ?? null);
1474
+ setTotalCost(bud.sessionSpend ?? 0);
1475
+ } else {
1476
+ const MODEL_PRICING = {
1477
+ "gemini-2.5-flash": { input: 0.15, output: 0.60 },
1478
+ "gemini-2.5-pro": { input: 1.25, output: 10.0 },
1479
+ "claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
1480
+ "gpt-4o": { input: 2.50, output: 10.0 },
1481
+ "gpt-4.1": { input: 2.0, output: 8.0 },
1482
+ };
1483
+ const pricing = MODEL_PRICING[model] ?? { input: 1.0, output: 3.0 };
1484
+ const cost = (inputToks * pricing.input + outputToks * pricing.output) / 1_000_000;
1485
+ setTotalCost(cost);
1486
+ }
1487
+ setModel(engine.model ?? model);
1488
+
1489
+ const assistantMsg = { role: "assistant", content: responseText };
1490
+ conversationRef.current.push(assistantMsg);
1491
+ setMessages(prev => [...prev, assistantMsg]);
1492
+ await saveConversation(activeWorkstream, conversationRef.current.filter(m => m.role !== "system"));
1493
+ } catch (err) {
1494
+ const errMsg = { role: "assistant", content: `Error: ${err.message.slice(0, 200)}` };
1495
+ setMessages(prev => [...prev, errMsg]);
1496
+ } finally {
1497
+ setLoading(false);
1498
+ }
1499
+ }, [loading, model, totalCost, totalTokens, engine, exit, pendingApproval, activeWorkstream, view, switchWorkstream]);
1500
+
1501
+ // Keep ref up to date
1502
+ useEffect(() => { handleSubmitRef.current = handleSubmit; }, [handleSubmit]);
1503
+
1504
+ // ── Layout ──
1505
+ const showSidebar = termWidth >= 80;
1506
+ const mainWidth = showSidebar ? termWidth - SIDEBAR_WIDTH - 2 : termWidth;
1507
+ const permMode = engine.permissions?.getPolicy?.("run_command") ?? "approve";
1508
+ const activeAgent = engine?._personality ?? engine?._activeAgent ?? null;
1509
+
1510
+ // ── Main content ──
1511
+ const mainContent = showHelp
1512
+ ? React.createElement(HelpOverlay, { onClose: () => setShowHelp(false) })
1513
+ : view === "chat"
1514
+ ? React.createElement(ChatView, {
1515
+ messages, loading, pendingApproval,
1516
+ onApprove: handleApprove, onDeny: handleDeny, onDryRun: handleDryRun,
1517
+ mainWidth,
1518
+ })
1519
+ : view === "overview"
1520
+ ? React.createElement(OverviewView, { workstreams, activeWorkstream, overviewData, browserStatus, mainWidth })
1521
+ : view === "agents"
1522
+ ? React.createElement(AgentsView, { agents, agentManager: engine?.agentManager })
1523
+ : view === "memory"
1524
+ ? React.createElement(MemoryView, { memoryFiles, memoryQuery, onQueryChange: setMemoryQuery })
1525
+ : view === "audit"
1526
+ ? React.createElement(AuditView, { timeline, mainWidth })
1527
+ : view === "settings"
1528
+ ? React.createElement(SettingsView, { engine })
1529
+ : null;
1530
+
1531
+ return React.createElement(
1532
+ Box, { flexDirection: "column", height: "100%" },
1533
+
1534
+ // Status bar
1535
+ React.createElement(StatusBar, {
1536
+ workstream: activeWorkstream,
1537
+ model, provider,
1538
+ permMode,
1539
+ syncStatus,
1540
+ tokens: totalTokens,
1541
+ cost: totalCost,
1542
+ pendingApprovals: pendingApproval ? 1 : 0,
1543
+ view,
1544
+ termWidth,
1545
+ }),
1546
+
1547
+ // Main body
1548
+ React.createElement(
1549
+ Box, { flexDirection: "row", flexGrow: 1 },
1550
+
1551
+ showSidebar
1552
+ ? React.createElement(Sidebar, {
1553
+ workstreams,
1554
+ activeWorkstream,
1555
+ activeAgent,
1556
+ agents,
1557
+ memoryCount: memoryFiles.length,
1558
+ userModelLoaded,
1559
+ cronCount,
1560
+ syncStatus,
1561
+ browserStatus,
1562
+ budgetSpent,
1563
+ maxBudget,
1564
+ onSelectWorkstream: switchWorkstream,
1565
+ })
1566
+ : null,
1567
+
1568
+ React.createElement(
1569
+ Box, { flexDirection: "column", flexGrow: 1 },
1570
+ mainContent,
1571
+ ),
1572
+ ),
1573
+
1574
+ // Timeline bar
1575
+ (showTimeline || view === "chat") && timeline.length > 0
1576
+ ? React.createElement(TimelineBar, { events: timeline })
1577
+ : null,
1578
+
1579
+ // Command palette
1580
+ !showHelp && showPalette && inputValue.startsWith("/")
1581
+ ? React.createElement(CommandPalette, {
1582
+ query: inputValue,
1583
+ onSelect: handlePaletteSelect,
1584
+ onDismiss: handlePaletteDismiss,
1585
+ })
1586
+ : null,
1587
+
1588
+ // Input (static display only — real input handled by ReadlineInput)
1589
+ !showHelp
1590
+ ? React.createElement(InputArea, {
1591
+ value: inputValue,
1592
+ loading,
1593
+ workstream: activeWorkstream,
1594
+ view,
1595
+ })
1596
+ : null,
1597
+ );
1598
+ }
1599
+
1600
+ // ─── Entry point ──────────────────────────────────────────────────────────────
1601
+
1602
+ async function main() {
1603
+ if (!process.stdin.isTTY) {
1604
+ console.error("Error: wispy --tui requires a TTY terminal");
1605
+ process.exit(1);
1606
+ }
1607
+
1608
+ const engine = new WispyEngine({ workstream: INITIAL_WORKSTREAM });
1609
+ const initResult = await engine.init();
1610
+
1611
+ if (!initResult) {
1612
+ console.error("No API key found. Run `wispy` first to set up your provider.");
1613
+ process.exit(1);
1614
+ }
1615
+
1616
+ process.stdout.write("\x1b[2J\x1b[H");
1617
+
1618
+ // Give Ink a fake stdin so it doesn't fight with readline for raw-mode control.
1619
+ // All actual input is handled by ReadlineInput inside WispyWorkspaceApp.
1620
+ const fakeStdin = new PassThrough();
1621
+ fakeStdin.isTTY = false;
1622
+ fakeStdin.setRawMode = () => {}; // no-op so Ink doesn't crash trying to set raw mode
1623
+
1624
+ const { waitUntilExit } = render(
1625
+ React.createElement(WispyWorkspaceApp, {
1626
+ engine,
1627
+ initialWorkstream: INITIAL_WORKSTREAM,
1628
+ }),
1629
+ {
1630
+ exitOnCtrlC: false, // ReadlineInput handles Ctrl+C
1631
+ stdin: fakeStdin,
1632
+ }
1633
+ );
1634
+
1635
+ await waitUntilExit();
1636
+ engine.destroy();
1637
+ }
1638
+
1639
+ main().catch(err => {
1640
+ console.error("TUI error:", err.message);
1641
+ if (process.env.WISPY_DEBUG) console.error(err.stack);
1642
+ process.exit(1);
1643
+ });