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.
- package/bin/wispy-tui.mjs +1 -1
- package/bin/wispy.mjs +71 -2
- package/lib/wispy-tui-ink.mjs +1643 -0
- package/lib/wispy-tui-v3.mjs +1236 -0
- package/package.json +2 -1
|
@@ -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
|
+
});
|