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