wispy-cli 2.7.18 → 2.7.19

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.
Files changed (2) hide show
  1. package/lib/wispy-tui.mjs +433 -254
  2. package/package.json +1 -1
package/lib/wispy-tui.mjs CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * wispy-tui.mjs — Workspace OS TUI for Wispy v2.0
3
+ * wispy-tui.mjs — Workspace OS TUI for Wispy v2.7+
4
4
  *
5
5
  * Multi-panel workspace interface:
6
- * - Left sidebar: Workstreams, Agents, Memory, Cron, Sync
6
+ * - Left sidebar: Workstreams, Agent, Sub-agents, Memory, Browser, Budget
7
7
  * - Main area: Chat / Overview / Agents / Memory / Audit / Settings
8
8
  * - Bottom: Action Timeline bar + Input
9
- * - Overlays: Approval dialogs, Diff views
9
+ * - Overlays: Approval dialogs, Help, Command Palette
10
10
  */
11
11
 
12
- import React, { useState, useEffect, useRef, useCallback, useReducer } from "react";
13
- import { render, Box, Text, useApp, Newline, useInput, useStdout } from "ink";
12
+ import React, { useState, useEffect, useRef, useCallback } from "react";
13
+ import { render, Box, Text, useApp, useInput, useStdout } from "ink";
14
14
  import Spinner from "ink-spinner";
15
15
  import TextInput from "ink-text-input";
16
16
 
@@ -19,9 +19,21 @@ import { COMMANDS, filterCommands } from "./command-registry.mjs";
19
19
  import os from "node:os";
20
20
  import path from "node:path";
21
21
  import { readFile, writeFile, readdir, stat, mkdir } from "node:fs/promises";
22
+ import { createRequire } from "node:module";
23
+ import { fileURLToPath } from "node:url";
22
24
 
23
25
  import { WispyEngine, CONVERSATIONS_DIR, PROVIDERS, WISPY_DIR, MEMORY_DIR } from "../core/index.mjs";
24
26
 
27
+ // ─── Version ──────────────────────────────────────────────────────────────────
28
+
29
+ let PKG_VERSION = "?";
30
+ try {
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+ const pkgPath = path.join(__dirname, "..", "package.json");
33
+ const pkgRaw = await readFile(pkgPath, "utf8");
34
+ PKG_VERSION = JSON.parse(pkgRaw).version ?? "?";
35
+ } catch {}
36
+
25
37
  // ─── Parse CLI args ──────────────────────────────────────────────────────────
26
38
 
27
39
  const rawArgs = process.argv.slice(2);
@@ -34,13 +46,13 @@ const INITIAL_WORKSTREAM =
34
46
  // ─── Constants ───────────────────────────────────────────────────────────────
35
47
 
36
48
  const VIEWS = ["chat", "overview", "agents", "memory", "audit", "settings"];
37
- const SIDEBAR_WIDTH = 16;
49
+ const SIDEBAR_WIDTH = 18;
38
50
  const TIMELINE_LINES = 3;
39
51
 
40
52
  const TOOL_ICONS = {
41
53
  read_file: "[file]", write_file: "[edit]", file_edit: "[edit]", run_command: "[exec]",
42
54
  git: "[git]", web_search: "[search]", web_fetch: "[web]", list_directory: "[dir]",
43
- spawn_subagent: "[sub-agent]", spawn_agent: "[agent]", memory_save: "[save]",
55
+ spawn_subagent: "[sub]", spawn_agent: "[agent]", memory_save: "[save]",
44
56
  memory_search: "[find]", memory_list: "[list]", delete_file: "[delete]",
45
57
  node_execute: "[run]", update_work_context: "[update]",
46
58
  };
@@ -64,6 +76,13 @@ function fmtRelTime(iso) {
64
76
  } catch { return ""; }
65
77
  }
66
78
 
79
+ function fmtDuration(ms) {
80
+ if (!ms || ms < 0) return "";
81
+ if (ms < 1000) return `${ms}ms`;
82
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
83
+ return `${Math.floor(ms / 60_000)}m${Math.floor((ms % 60_000) / 1000)}s`;
84
+ }
85
+
67
86
  function truncate(str, n) {
68
87
  if (!str) return "";
69
88
  return str.length > n ? str.slice(0, n - 1) + "…" : str;
@@ -82,13 +101,13 @@ function renderMarkdown(text, maxWidth = 60) {
82
101
  if (line.startsWith("- ") || line.startsWith("* ")) {
83
102
  return React.createElement(Box, { key: i },
84
103
  React.createElement(Text, { color: "green" }, "• "),
85
- React.createElement(Text, null, line.slice(2)));
104
+ React.createElement(Text, { wrap: "wrap" }, line.slice(2)));
86
105
  }
87
106
  if (/^\d+\.\s/.test(line)) {
88
107
  const match = line.match(/^(\d+\.\s)(.*)/);
89
108
  return React.createElement(Box, { key: i },
90
109
  React.createElement(Text, { color: "yellow" }, match[1]),
91
- React.createElement(Text, null, match[2]));
110
+ React.createElement(Text, { wrap: "wrap" }, match[2]));
92
111
  }
93
112
  if (line.includes("**")) {
94
113
  const parts = line.split(/(\*\*[^*]+\*\*)/g);
@@ -109,119 +128,111 @@ function renderMarkdown(text, maxWidth = 60) {
109
128
  // ─── Status Bar ──────────────────────────────────────────────────────────────
110
129
 
111
130
  function StatusBar({ workstream, model, provider, permMode, syncStatus, tokens, cost, pendingApprovals, view, termWidth }) {
112
- const providerLabel = PROVIDERS[provider]?.label?.split(" ")[0] ?? provider ?? "?";
113
- const costStr = cost > 0 ? `$${cost.toFixed(4)}` : "";
114
- const permIcon = permMode === "approve" ? "🔐" : permMode === "notify" ? "📋" : "✅";
115
- const syncIcon = syncStatus === "auto" ? "💾" : syncStatus === "manual" ? "⏸" : "🔴";
116
- const viewLabel = view.toUpperCase();
117
-
118
131
  const wide = termWidth >= 100;
119
132
 
120
133
  return React.createElement(
121
134
  Box, { paddingX: 1, backgroundColor: "green", width: "100%" },
122
- React.createElement(Text, { color: "black", bold: true }, "🌿 Wispy"),
135
+ React.createElement(Text, { color: "black", bold: true }, "Wispy"),
123
136
  React.createElement(Text, { color: "black" }, " ─ "),
124
137
  React.createElement(Text, { color: "black", bold: true }, workstream),
125
138
  React.createElement(Text, { color: "black" }, " ─ "),
126
139
  React.createElement(Text, { color: "black" }, truncate(model ?? "?", 20)),
127
- wide ? React.createElement(Text, { color: "black" }, ` ─ ${permIcon}${permMode ?? "auto"}`) : null,
128
- wide && syncIcon ? React.createElement(Text, { color: "black" }, ` ─ ${syncIcon}synced`) : null,
129
- wide && costStr ? React.createElement(Text, { color: "black", dimColor: true }, ` ─ ${costStr}`) : null,
140
+ wide ? React.createElement(Text, { color: "black" }, ` ─ ${permMode ?? "approve"}`) : null,
141
+ wide && cost > 0 ? React.createElement(Text, { color: "black", dimColor: true }, ` ─ $${cost.toFixed(4)}`) : null,
130
142
  pendingApprovals > 0
131
- ? React.createElement(Text, { color: "black", bold: true }, ` ─ ⚠️ ${pendingApprovals} pending`)
143
+ ? React.createElement(Text, { color: "black", bold: true }, ` ─ ! ${pendingApprovals} pending`)
132
144
  : null,
133
- React.createElement(Text, { color: "black", dimColor: true }, ` [${viewLabel}] ? for help`),
145
+ React.createElement(Text, { color: "black", dimColor: true }, ` [${view.toUpperCase()}] ? for help`),
134
146
  );
135
147
  }
136
148
 
137
149
  // ─── Left Sidebar ─────────────────────────────────────────────────────────────
138
150
 
139
- function Sidebar({ workstreams, activeWorkstream, agents, memoryCount, userModelLoaded, cronCount, syncStatus, sidebarHeight, onSelectWorkstream }) {
140
- const agentRunning = agents.filter(a => a.status === "running");
141
- const agentPending = agents.filter(a => a.status === "pending");
142
-
143
- const agentIcon = (status) => {
144
- if (status === "running") return "🟢";
145
- if (status === "pending") return "🟡";
146
- if (status === "completed" || status === "done") return "✅";
147
- return "❌";
148
- };
149
-
150
- const syncIcon = syncStatus === "auto" ? "🔄 auto" : syncStatus === "manual" ? "⏸ manual" : "❌ off";
151
-
151
+ function Sidebar({ workstreams, activeWorkstream, activeAgent, agents, memoryCount, userModelLoaded, cronCount, syncStatus, browserStatus, budgetSpent, maxBudget, onSelectWorkstream }) {
152
152
  const rows = [];
153
153
 
154
- // Section: STREAMS
155
- rows.push(React.createElement(
156
- Box, { key: "streams-header", paddingLeft: 1 },
157
- React.createElement(Text, { bold: true, dimColor: true }, "STREAMS"),
158
- ));
154
+ const divider = (key) => React.createElement(Box, {
155
+ key, borderStyle: "single",
156
+ borderTop: false, borderRight: false, borderLeft: false, borderBottom: true,
157
+ borderColor: "gray", width: SIDEBAR_WIDTH,
158
+ });
159
159
 
160
- workstreams.forEach((ws, i) => {
160
+ // ── WORKSTREAMS ──
161
+ rows.push(React.createElement(Box, { key: "ws-h", paddingLeft: 1 },
162
+ React.createElement(Text, { bold: true, dimColor: true }, "WORKSTREAMS"),
163
+ ));
164
+ workstreams.forEach((ws) => {
161
165
  const isActive = ws === activeWorkstream;
162
- rows.push(React.createElement(
163
- Box, { key: `ws-${ws}`, paddingLeft: 1 },
166
+ rows.push(React.createElement(Box, { key: `ws-${ws}`, paddingLeft: 1 },
164
167
  React.createElement(Text, { color: isActive ? "green" : undefined, bold: isActive },
165
- `${isActive ? "" : " "}${truncate(ws, SIDEBAR_WIDTH - 2)}`),
168
+ `${isActive ? "" : ""} ${truncate(ws, SIDEBAR_WIDTH - 3)}`),
166
169
  ));
167
170
  });
168
171
 
169
- // Section: AGENTS
170
- rows.push(React.createElement(Box, { key: "div1", borderStyle: "single", borderTop: false, borderRight: false, borderLeft: false, borderBottom: true, borderColor: "gray", width: SIDEBAR_WIDTH }));
171
- rows.push(React.createElement(
172
- Box, { key: "agents-header", paddingLeft: 1 },
173
- React.createElement(Text, { bold: true, dimColor: true }, "AGENTS"),
174
- ));
172
+ // ── ACTIVE AGENT ──
173
+ if (activeAgent && activeAgent !== "default") {
174
+ rows.push(divider("d-agent"));
175
+ rows.push(React.createElement(Box, { key: "agent-h", paddingLeft: 1 },
176
+ React.createElement(Text, { bold: true, dimColor: true }, "AGENT"),
177
+ ));
178
+ rows.push(React.createElement(Box, { key: "agent-v", paddingLeft: 1 },
179
+ React.createElement(Text, { color: "cyan" }, truncate(activeAgent, SIDEBAR_WIDTH - 2)),
180
+ ));
181
+ }
175
182
 
183
+ // ── SUB-AGENTS ──
184
+ rows.push(divider("d-sub"));
185
+ rows.push(React.createElement(Box, { key: "sub-h", paddingLeft: 1 },
186
+ React.createElement(Text, { bold: true, dimColor: true }, `AGENTS `),
187
+ React.createElement(Text, { color: agents.length > 0 ? "yellow" : "gray" }, `${agents.length}`),
188
+ ));
176
189
  if (agents.length === 0) {
177
- rows.push(React.createElement(
178
- Box, { key: "no-agents", paddingLeft: 1 },
190
+ rows.push(React.createElement(Box, { key: "sub-none", paddingLeft: 1 },
179
191
  React.createElement(Text, { dimColor: true }, " (none)"),
180
192
  ));
181
193
  } else {
182
194
  agents.slice(0, 4).forEach((a, i) => {
183
- rows.push(React.createElement(
184
- Box, { key: `agent-${i}`, paddingLeft: 1 },
185
- React.createElement(Text, {}, `${agentIcon(a.status)}${truncate(a.label ?? a.role ?? "agent", SIDEBAR_WIDTH - 3)}`),
195
+ const isRunning = a.status === "running";
196
+ rows.push(React.createElement(Box, { key: `sub-${i}`, paddingLeft: 1 },
197
+ React.createElement(Text, { color: isRunning ? "green" : "gray" }, isRunning ? "● " : "◯ "),
198
+ React.createElement(Text, { dimColor: !isRunning }, truncate(a.label ?? a.role ?? "agent", SIDEBAR_WIDTH - 4)),
186
199
  ));
187
200
  });
188
201
  }
189
202
 
190
- // Section: MEMORY
191
- rows.push(React.createElement(Box, { key: "div2", borderStyle: "single", borderTop: false, borderRight: false, borderLeft: false, borderBottom: true, borderColor: "gray", width: SIDEBAR_WIDTH }));
192
- rows.push(React.createElement(
193
- Box, { key: "mem-header", paddingLeft: 1 },
194
- React.createElement(Text, { bold: true, dimColor: true }, "MEMORY"),
195
- ));
196
- rows.push(React.createElement(
197
- Box, { key: "mem-files", paddingLeft: 1 },
198
- React.createElement(Text, {}, `📝${memoryCount} files`),
199
- ));
200
- rows.push(React.createElement(
201
- Box, { key: "mem-model", paddingLeft: 1 },
202
- React.createElement(Text, {}, `🧠model ${userModelLoaded ? "✅" : "❌"}`),
203
+ // ── MEMORY ──
204
+ rows.push(divider("d-mem"));
205
+ rows.push(React.createElement(Box, { key: "mem-h", paddingLeft: 1 },
206
+ React.createElement(Text, { bold: true, dimColor: true }, "MEMORY "),
207
+ React.createElement(Text, { color: "cyan" }, `${memoryCount}`),
203
208
  ));
204
209
 
205
- // Section: CRON
206
- rows.push(React.createElement(Box, { key: "div3", borderStyle: "single", borderTop: false, borderRight: false, borderLeft: false, borderBottom: true, borderColor: "gray", width: SIDEBAR_WIDTH }));
207
- rows.push(React.createElement(
208
- Box, { key: "cron-header", paddingLeft: 1 },
209
- React.createElement(Text, { bold: true, dimColor: true }, "CRON"),
210
- ));
211
- rows.push(React.createElement(
212
- Box, { key: "cron-jobs", paddingLeft: 1 },
213
- React.createElement(Text, {}, `⏰${cronCount} jobs`),
210
+ // ── BROWSER ──
211
+ rows.push(divider("d-br"));
212
+ rows.push(React.createElement(Box, { key: "br-h", paddingLeft: 1 },
213
+ React.createElement(Text, { bold: true, dimColor: true }, "BROWSER"),
214
214
  ));
215
+ if (browserStatus?.session) {
216
+ rows.push(React.createElement(Box, { key: "br-v", paddingLeft: 1 },
217
+ React.createElement(Text, { color: "green" }, "● "),
218
+ React.createElement(Text, {}, truncate(browserStatus.session.browser ?? "connected", SIDEBAR_WIDTH - 4)),
219
+ ));
220
+ } else {
221
+ rows.push(React.createElement(Box, { key: "br-v", paddingLeft: 1 },
222
+ React.createElement(Text, { dimColor: true }, "◯ off"),
223
+ ));
224
+ }
215
225
 
216
- // Section: SYNC
217
- rows.push(React.createElement(Box, { key: "div4", borderStyle: "single", borderTop: false, borderRight: false, borderLeft: false, borderBottom: true, borderColor: "gray", width: SIDEBAR_WIDTH }));
218
- rows.push(React.createElement(
219
- Box, { key: "sync-header", paddingLeft: 1 },
220
- React.createElement(Text, { bold: true, dimColor: true }, "SYNC"),
226
+ // ── BUDGET ──
227
+ rows.push(divider("d-bud"));
228
+ rows.push(React.createElement(Box, { key: "bud-h", paddingLeft: 1 },
229
+ React.createElement(Text, { bold: true, dimColor: true }, "BUDGET"),
221
230
  ));
222
- rows.push(React.createElement(
223
- Box, { key: "sync-status", paddingLeft: 1 },
224
- React.createElement(Text, {}, syncIcon),
231
+ rows.push(React.createElement(Box, { key: "bud-v", paddingLeft: 1 },
232
+ React.createElement(Text, { color: budgetSpent > 0 ? "yellow" : "gray" },
233
+ budgetSpent > 0
234
+ ? `$${budgetSpent.toFixed(3)}${maxBudget ? `/$${maxBudget.toFixed(2)}` : ""}`
235
+ : "$0.000"),
225
236
  ));
226
237
 
227
238
  return React.createElement(
@@ -241,18 +252,19 @@ function Sidebar({ workstreams, activeWorkstream, agents, memoryCount, userModel
241
252
  // ─── Chat View ───────────────────────────────────────────────────────────────
242
253
 
243
254
  function ToolLine({ name, args, receipt }) {
244
- const icon = TOOL_ICONS[name] ?? "🔧";
255
+ const icon = TOOL_ICONS[name] ?? "[tool]";
245
256
  const primaryArg = args?.path ?? args?.command ?? args?.query ?? args?.task ?? args?.key ?? "";
246
257
  const statusIcon = receipt
247
- ? (receipt.success ? "" : "")
248
- : "";
258
+ ? (receipt.success ? "" : "")
259
+ : "";
260
+ const statusColor = receipt ? (receipt.success ? "green" : "red") : "yellow";
249
261
 
250
262
  return React.createElement(
251
263
  Box, { flexDirection: "column", paddingLeft: 3 },
252
264
  React.createElement(
253
265
  Box, {},
254
- React.createElement(Text, { color: "cyan", dimColor: true },
255
- `${statusIcon} ${icon} ${name}`),
266
+ React.createElement(Text, { color: statusColor }, statusIcon + " "),
267
+ React.createElement(Text, { color: "cyan", dimColor: true }, `${icon} ${name}`),
256
268
  primaryArg ? React.createElement(Text, { dimColor: true }, ` → ${truncate(primaryArg, 35)}`) : null,
257
269
  ),
258
270
  receipt?.diff?.unified ? React.createElement(MiniDiff, { unified: receipt.diff.unified, filePath: args?.path }) : null,
@@ -280,7 +292,7 @@ function ChatMessage({ msg, mainWidth }) {
280
292
  return React.createElement(
281
293
  Box, { flexDirection: "column", paddingLeft: 1, marginTop: 1 },
282
294
  React.createElement(Box, {},
283
- React.createElement(Text, { color: "green", bold: true }, "🧑 "),
295
+ React.createElement(Text, { color: "green", bold: true }, "you "),
284
296
  React.createElement(Text, { color: "white", wrap: "wrap" }, msg.content)));
285
297
  }
286
298
  if (msg.role === "tool_call") {
@@ -289,7 +301,7 @@ function ChatMessage({ msg, mainWidth }) {
289
301
  if (msg.role === "assistant") {
290
302
  return React.createElement(
291
303
  Box, { flexDirection: "column", paddingLeft: 1, marginTop: 1 },
292
- React.createElement(Text, { color: "cyan", bold: true }, "🤖"),
304
+ React.createElement(Text, { color: "cyan", bold: true }, "wispy ›"),
293
305
  React.createElement(Box, { flexDirection: "column", paddingLeft: 2 },
294
306
  ...renderMarkdown(msg.content, mainWidth - 4)),
295
307
  );
@@ -310,7 +322,7 @@ function ChatView({ messages, loading, pendingApproval, onApprove, onDeny, onDry
310
322
  displayMessages.length === 0
311
323
  ? React.createElement(
312
324
  Box, { flexGrow: 1, alignItems: "center", justifyContent: "center", flexDirection: "column" },
313
- React.createElement(Text, { dimColor: true }, "🌿 Welcome to Wispy"),
325
+ React.createElement(Text, { dimColor: true }, "Wispy AI workspace assistant"),
314
326
  React.createElement(Text, { dimColor: true }, "Type a message to start. ? for help."),
315
327
  )
316
328
  : React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
@@ -336,24 +348,36 @@ function ChatView({ messages, loading, pendingApproval, onApprove, onDeny, onDry
336
348
 
337
349
  // ─── Overview View ────────────────────────────────────────────────────────────
338
350
 
339
- function OverviewView({ workstreams, activeWorkstream, overviewData, mainWidth }) {
351
+ function OverviewView({ workstreams, activeWorkstream, overviewData, browserStatus, mainWidth }) {
340
352
  return React.createElement(
341
353
  Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
342
354
  React.createElement(Text, { bold: true, color: "green" }, "Workstream Overview"),
343
355
  React.createElement(Box, { height: 1 }),
344
- ...workstreams.map((ws, i) => {
356
+ // Browser status section
357
+ React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
358
+ React.createElement(Text, { bold: true, color: "cyan" }, "Browser"),
359
+ browserStatus?.session
360
+ ? React.createElement(Box, { paddingLeft: 2 },
361
+ React.createElement(Text, { color: "green" }, "● "),
362
+ React.createElement(Text, {}, `Connected — ${browserStatus.session.browser ?? "unknown"}`))
363
+ : React.createElement(Box, { paddingLeft: 2 },
364
+ React.createElement(Text, { dimColor: true }, "◯ Not connected")),
365
+ ),
366
+ React.createElement(Text, { dimColor: true }, "─".repeat(Math.min(40, mainWidth - 4))),
367
+ React.createElement(Box, { height: 1 }),
368
+ ...workstreams.map((ws) => {
345
369
  const data = overviewData[ws] ?? {};
346
370
  const isActive = ws === activeWorkstream;
347
371
  return React.createElement(
348
372
  Box, { key: ws, flexDirection: "column", marginBottom: 1 },
349
373
  React.createElement(Box, {},
350
- React.createElement(Text, { bold: true, color: isActive ? "green" : "white" }, ws),
351
- React.createElement(Text, { dimColor: true }, " "),
374
+ React.createElement(Text, { bold: true, color: isActive ? "green" : "white" },
375
+ `${isActive ? "●" : ""} ${ws}`),
352
376
  data.lastActivity
353
- ? React.createElement(Text, { dimColor: true }, `Last: ${fmtRelTime(data.lastActivity)}`)
377
+ ? React.createElement(Text, { dimColor: true }, ` ${fmtRelTime(data.lastActivity)}`)
354
378
  : null,
355
379
  data.agents > 0
356
- ? React.createElement(Text, { color: "yellow" }, ` Agents: ${data.agents} running`)
380
+ ? React.createElement(Text, { color: "yellow" }, ` ${data.agents} agents`)
357
381
  : null,
358
382
  ),
359
383
  data.lastMessage
@@ -366,7 +390,7 @@ function OverviewView({ workstreams, activeWorkstream, overviewData, mainWidth }
366
390
  data.workMd
367
391
  ? React.createElement(
368
392
  Box, { paddingLeft: 2 },
369
- React.createElement(Text, { dimColor: true }, "└── work.md: "),
393
+ React.createElement(Text, { dimColor: true }, "└── "),
370
394
  React.createElement(Text, { dimColor: true, italic: true }, `"${truncate(data.workMd, mainWidth - 20)}"`),
371
395
  )
372
396
  : null,
@@ -377,7 +401,19 @@ function OverviewView({ workstreams, activeWorkstream, overviewData, mainWidth }
377
401
 
378
402
  // ─── Agents View ─────────────────────────────────────────────────────────────
379
403
 
380
- function AgentsView({ agents }) {
404
+ function AgentsView({ agents, agentManager }) {
405
+ const [builtinAgents, setBuiltinAgents] = useState([]);
406
+ const [progressMap, setProgressMap] = useState({});
407
+
408
+ useEffect(() => {
409
+ if (agentManager) {
410
+ try {
411
+ const list = agentManager.list();
412
+ setBuiltinAgents(list);
413
+ } catch {}
414
+ }
415
+ }, [agentManager]);
416
+
381
417
  const statusColor = (s) => {
382
418
  if (s === "running") return "green";
383
419
  if (s === "pending") return "yellow";
@@ -385,36 +421,67 @@ function AgentsView({ agents }) {
385
421
  return "red";
386
422
  };
387
423
  const statusIcon = (s) => {
388
- if (s === "running") return "🟢";
389
- if (s === "pending") return "🟡";
390
- if (s === "completed" || s === "done") return "";
391
- return "";
424
+ if (s === "running") return "";
425
+ if (s === "pending") return "";
426
+ if (s === "completed" || s === "done") return "";
427
+ return "";
392
428
  };
393
429
 
430
+ const running = agents.filter(a => a.status === "running");
431
+ const completed = agents.filter(a => a.status === "completed" || a.status === "done");
432
+
394
433
  return React.createElement(
395
434
  Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
396
- React.createElement(Text, { bold: true, color: "green" }, "Active Agents"),
435
+ // ── Custom / Built-in Agents ──
436
+ React.createElement(Text, { bold: true, color: "green" }, "Agent Profiles"),
437
+ React.createElement(Box, { height: 1 }),
438
+ builtinAgents.length === 0
439
+ ? React.createElement(Text, { dimColor: true }, "No agents loaded.")
440
+ : React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
441
+ ...builtinAgents.slice(0, 10).map((a, i) => React.createElement(
442
+ Box, { key: i },
443
+ React.createElement(Text, { color: a.builtin ? "cyan" : "magenta" },
444
+ a.builtin ? " [built-in] " : " [custom] "),
445
+ React.createElement(Text, { bold: true }, a.name),
446
+ React.createElement(Text, { dimColor: true }, ` — ${truncate(a.description, 45)}`),
447
+ ))),
448
+
449
+ React.createElement(Text, { dimColor: true }, "─".repeat(40)),
450
+ React.createElement(Box, { height: 1 }),
451
+
452
+ // ── Running Sub-Agents ──
453
+ React.createElement(Text, { bold: true, color: "green" },
454
+ `Sub-Agents (${running.length} running, ${completed.length} completed)`),
397
455
  React.createElement(Box, { height: 1 }),
398
456
  agents.length === 0
399
- ? React.createElement(Text, { dimColor: true }, "No agents running.")
457
+ ? React.createElement(Text, { dimColor: true }, "No sub-agents recorded.")
400
458
  : React.createElement(Box, { flexDirection: "column" },
401
- ...agents.map((a, i) => React.createElement(
402
- Box, { key: i, flexDirection: "column", marginBottom: 1 },
403
- React.createElement(Box, {},
404
- React.createElement(Text, {}, `${statusIcon(a.status)} `),
405
- React.createElement(Text, { bold: true, color: statusColor(a.status) }, a.label ?? a.id),
406
- React.createElement(Text, { dimColor: true }, ` [${a.model ?? "?"}]`),
407
- a.createdAt
408
- ? React.createElement(Text, { dimColor: true }, ` started ${fmtRelTime(a.createdAt)}`)
459
+ ...agents.map((a, i) => {
460
+ const runtime = a.createdAt && a.completedAt
461
+ ? fmtDuration(new Date(a.completedAt) - new Date(a.createdAt))
462
+ : a.createdAt ? `${fmtRelTime(a.createdAt)}` : "";
463
+ return React.createElement(
464
+ Box, { key: i, flexDirection: "column", marginBottom: 1 },
465
+ React.createElement(Box, {},
466
+ React.createElement(Text, { color: statusColor(a.status) }, `${statusIcon(a.status)} `),
467
+ React.createElement(Text, { bold: true, color: statusColor(a.status) }, truncate(a.label ?? a.id ?? "agent", 20)),
468
+ React.createElement(Text, { dimColor: true }, ` [${a.model ?? "?"}]`),
469
+ runtime ? React.createElement(Text, { dimColor: true }, ` ${runtime}`) : null,
470
+ ),
471
+ a.task
472
+ ? React.createElement(
473
+ Box, { paddingLeft: 3 },
474
+ React.createElement(Text, { dimColor: true, wrap: "wrap" }, truncate(a.task, 60)),
475
+ )
409
476
  : null,
410
- ),
411
- a.task
412
- ? React.createElement(
413
- Box, { paddingLeft: 3 },
414
- React.createElement(Text, { dimColor: true, wrap: "wrap" }, truncate(a.task, 60)),
415
- )
416
- : null,
417
- ))),
477
+ progressMap[a.id]
478
+ ? React.createElement(Box, { paddingLeft: 3 },
479
+ React.createElement(Text, { color: "yellow" }, "… "),
480
+ React.createElement(Text, { dimColor: true, wrap: "wrap" }, truncate(progressMap[a.id], 55)),
481
+ )
482
+ : null,
483
+ );
484
+ })),
418
485
  );
419
486
  }
420
487
 
@@ -474,15 +541,15 @@ function AuditView({ timeline, mainWidth }) {
474
541
  ? React.createElement(Text, { dimColor: true }, "No actions recorded yet.")
475
542
  : React.createElement(Box, { flexDirection: "column" },
476
543
  ...timeline.slice().reverse().slice(0, 30).map((evt, i) => {
477
- const icon = TOOL_ICONS[evt.toolName] ?? "🔧";
478
- const statusIcon = evt.denied ? "" : evt.dryRun ? "👁️" : evt.success ? "" : "";
544
+ const icon = TOOL_ICONS[evt.toolName] ?? "[tool]";
545
+ const statusIcon = evt.denied ? "" : evt.dryRun ? "" : evt.success ? "" : "";
479
546
  const color = evt.denied ? "red" : evt.dryRun ? "blue" : evt.success ? "green" : "yellow";
480
547
  const ts = fmtTime(evt.timestamp);
481
- const arg = evt.primaryArg ? ` → ${truncate(evt.primaryArg, 30)}` : "";
548
+ const arg = evt.primaryArg ? ` → ${truncate(evt.primaryArg, 28)}` : "";
482
549
  return React.createElement(
483
550
  Box, { key: i },
484
551
  React.createElement(Text, { dimColor: true }, `${ts} `),
485
- React.createElement(Text, {}, `${icon} `),
552
+ React.createElement(Text, { dimColor: true }, `${icon} `),
486
553
  React.createElement(Text, { color: "cyan" }, evt.toolName),
487
554
  React.createElement(Text, { dimColor: true }, arg),
488
555
  React.createElement(Text, { color }, ` ${statusIcon}`),
@@ -495,37 +562,117 @@ function AuditView({ timeline, mainWidth }) {
495
562
 
496
563
  function SettingsView({ engine }) {
497
564
  const [config, setConfig] = useState(null);
565
+ const [features, setFeatures] = useState([]);
566
+ const [browserSt, setBrowserSt] = useState(null);
567
+ const [refreshKey, setRefreshKey] = useState(0);
498
568
 
499
569
  useEffect(() => {
570
+ let mounted = true;
500
571
  (async () => {
501
572
  try {
502
573
  const { loadConfig } = await import("../core/config.mjs");
503
574
  const cfg = await loadConfig();
504
- setConfig(cfg);
575
+ if (mounted) setConfig(cfg);
576
+ } catch {}
577
+
578
+ // Load features
579
+ try {
580
+ const { getFeatureManager } = await import("../core/features.mjs");
581
+ const fm = getFeatureManager();
582
+ const list = await fm.list();
583
+ if (mounted) setFeatures(list);
584
+ } catch {}
585
+
586
+ // Load browser status
587
+ try {
588
+ const bs = engine?.browser?.status?.() ?? null;
589
+ if (mounted) setBrowserSt(bs);
505
590
  } catch {}
506
591
  })();
507
- }, []);
592
+ return () => { mounted = false; };
593
+ }, [refreshKey]);
594
+
595
+ const budget = engine?.budget;
596
+ const budgetSpent = budget?.sessionSpend ?? 0;
597
+ const maxBudget = budget?.maxBudgetUsd ?? null;
598
+
599
+ const personality = engine?._personality ?? config?.personality ?? "default";
600
+ const effort = engine?._effort ?? config?.effort ?? "medium";
601
+ const permMode = engine?.permissions?.getPolicy?.("run_command") ?? "approve";
602
+ const activeWorkstream = engine?.activeWorkstream ?? engine?._activeWorkstream ?? "default";
603
+ const activeAgent = engine?._activeAgent ?? config?.defaultAgent ?? "default";
508
604
 
509
- const rows = config ? [
510
- ["Provider", engine.provider ?? "?"],
511
- ["Model", engine.model ?? "?"],
512
- ["Workstream", engine.activeWorkstream ?? "default"],
513
- ["Permission mode", engine.permissions?.getPolicy?.("run_command") ?? "approve"],
514
- ["Version", "2.0.0"],
515
- ["Config", path.join(os.homedir(), ".wispy", "config.json")],
516
- ] : [];
605
+ const configDir = path.join(os.homedir(), ".wispy");
606
+ const providerLabel = config?.provider ?? engine?.provider ?? "?";
607
+ const modelLabel = config?.model ?? engine?.model ?? "?";
517
608
 
518
609
  return React.createElement(
519
610
  Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
520
611
  React.createElement(Text, { bold: true, color: "green" }, "Settings"),
521
612
  React.createElement(Box, { height: 1 }),
522
- ...rows.map(([k, v], i) => React.createElement(
523
- Box, { key: i, marginBottom: 0 },
524
- React.createElement(Text, { color: "cyan", bold: true }, `${k}: `),
525
- React.createElement(Text, {}, v),
526
- )),
613
+
614
+ // ── General ──
615
+ React.createElement(Text, { bold: true, color: "cyan" }, "General"),
616
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1 },
617
+ ...([
618
+ ["Version", PKG_VERSION],
619
+ ["Config dir", configDir],
620
+ ["Provider", providerLabel],
621
+ ["Model", modelLabel],
622
+ ["Personality", personality],
623
+ ["Effort", effort],
624
+ ["Security", permMode],
625
+ ]).map(([k, v], i) => React.createElement(
626
+ Box, { key: i },
627
+ React.createElement(Text, { color: "white", dimColor: true }, k.padEnd(14)),
628
+ React.createElement(Text, { color: "white" }, v ?? "?"),
629
+ )),
630
+ ),
631
+
632
+ // ── Active Session ──
633
+ React.createElement(Text, { bold: true, color: "cyan" }, "Active Session"),
634
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1 },
635
+ React.createElement(Box, {},
636
+ React.createElement(Text, { dimColor: true }, "Agent".padEnd(14)),
637
+ React.createElement(Text, {}, activeAgent ?? "default"),
638
+ ),
639
+ React.createElement(Box, {},
640
+ React.createElement(Text, { dimColor: true }, "Workstream".padEnd(14)),
641
+ React.createElement(Text, {}, activeWorkstream),
642
+ ),
643
+ React.createElement(Box, {},
644
+ React.createElement(Text, { dimColor: true }, "Budget".padEnd(14)),
645
+ React.createElement(Text, { color: budgetSpent > 0 ? "yellow" : "white" },
646
+ `$${budgetSpent.toFixed(4)}${maxBudget != null ? ` / $${maxBudget.toFixed(2)}` : " (no limit)"}`),
647
+ ),
648
+ ),
649
+
650
+ // ── Features ──
651
+ features.length > 0 ? React.createElement(React.Fragment, null,
652
+ React.createElement(Text, { bold: true, color: "cyan" }, "Features"),
653
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1 },
654
+ ...features.map((f, i) => React.createElement(
655
+ Box, { key: i },
656
+ React.createElement(Text, { color: f.enabled ? "green" : "red" }, f.enabled ? "✓ " : "✗ "),
657
+ React.createElement(Text, { color: f.enabled ? "white" : "gray" }, f.name),
658
+ React.createElement(Text, { dimColor: true }, ` [${f.stage}]`),
659
+ )),
660
+ ),
661
+ ) : null,
662
+
663
+ // ── Browser ──
664
+ React.createElement(Text, { bold: true, color: "cyan" }, "Browser"),
665
+ React.createElement(Box, { paddingLeft: 2, marginBottom: 1 },
666
+ React.createElement(Text, { dimColor: true }, "Status".padEnd(14)),
667
+ browserSt?.session
668
+ ? React.createElement(Text, { color: "green" }, `connected (${browserSt.session.browser ?? "unknown"})`)
669
+ : React.createElement(Text, { dimColor: true }, "disconnected"),
670
+ ),
671
+
672
+ // ── Keys ──
527
673
  React.createElement(Box, { height: 1 }),
528
- React.createElement(Text, { dimColor: true }, "Commands: /model <name> /clear /timeline /cost /help"),
674
+ React.createElement(Text, { dimColor: true },
675
+ "Keys: /model <n> /trust <level> /agent <n> /features enable <n> /cost"),
529
676
  );
530
677
  }
531
678
 
@@ -546,15 +693,15 @@ function TimelineBar({ events }) {
546
693
  paddingX: 1,
547
694
  },
548
695
  ...last.map((evt, i) => {
549
- const icon = TOOL_ICONS[evt.toolName] ?? "🔧";
550
- const statusIcon = evt.denied ? "" : evt.dryRun ? "👁️" : evt.success === null ? "" : evt.success ? "" : "";
696
+ const icon = TOOL_ICONS[evt.toolName] ?? "[tool]";
697
+ const statusIcon = evt.denied ? "" : evt.dryRun ? "" : evt.success === null ? "" : evt.success ? "" : "";
551
698
  const color = evt.denied ? "red" : evt.success === null ? "yellow" : evt.success ? "green" : "red";
552
699
  const ts = fmtTime(evt.timestamp);
553
700
  const arg = evt.primaryArg ? ` ${truncate(evt.primaryArg, 28)}` : "";
554
701
  return React.createElement(
555
702
  Box, { key: i },
556
703
  React.createElement(Text, { dimColor: true }, `${ts} `),
557
- React.createElement(Text, {}, `${icon} `),
704
+ React.createElement(Text, { dimColor: true }, `${icon} `),
558
705
  React.createElement(Text, { color: "cyan" }, evt.toolName),
559
706
  React.createElement(Text, { dimColor: true }, arg),
560
707
  React.createElement(Text, { color }, ` ${statusIcon}`),
@@ -596,14 +743,14 @@ function ApprovalDialog({ action, onApprove, onDeny, onDryRun }) {
596
743
  marginY: 1,
597
744
  marginX: 2,
598
745
  },
599
- React.createElement(Text, { bold: true, color: "yellow" }, "⚠️ Permission Required"),
746
+ React.createElement(Text, { bold: true, color: "yellow" }, "! Permission Required"),
600
747
  React.createElement(Box, { height: 1 }),
601
748
  React.createElement(Box, {},
602
749
  React.createElement(Text, { dimColor: true }, "Tool: "),
603
750
  React.createElement(Text, { bold: true }, action?.toolName ?? "?")),
604
751
  argSummary ? React.createElement(Box, {},
605
752
  React.createElement(Text, { dimColor: true }, "Action: "),
606
- React.createElement(Text, {}, argSummary)) : null,
753
+ React.createElement(Text, { wrap: "wrap" }, argSummary)) : null,
607
754
  React.createElement(Box, {},
608
755
  React.createElement(Text, { dimColor: true }, "Risk: "),
609
756
  React.createElement(Text, { bold: true, color: riskColor }, risk)),
@@ -623,22 +770,25 @@ function HelpOverlay({ onClose }) {
623
770
  });
624
771
 
625
772
  const shortcuts = [
626
- ["Tab", "Switch view (chat/overview/agents/memory/audit/settings)"],
627
- ["1-9", "Switch workstream by number"],
628
- ["n", "New workstream (in chat: prompts for name)"],
773
+ ["Tab", "Switch view"],
774
+ ["1-6", "Jump to view (1=chat 2=overview 3=agents 4=memory 5=audit 6=settings)"],
629
775
  ["o", "Overview view"],
630
776
  ["a", "Agents view"],
631
777
  ["m", "Memory view"],
632
778
  ["u", "Audit view"],
633
779
  ["s", "Settings view"],
634
- ["t", "Toggle timeline expansion"],
635
- ["Ctrl+L", "Clear chat"],
636
- ["q / Ctrl+C", "Quit"],
780
+ ["t", "Toggle timeline"],
637
781
  ["?", "Toggle this help"],
638
- ["/help", "Commands help (in input)"],
639
- ["/model <n>", "Switch model"],
782
+ ["Ctrl+L", "Clear chat"],
783
+ ["Ctrl+C / q", "Quit"],
784
+ ["/help", "Show all slash commands"],
785
+ ["/model <n>", "Change model"],
786
+ ["/trust <level>", "Change security level"],
787
+ ["/agent <n>", "Switch agent"],
788
+ ["/features enable <n>", "Toggle feature"],
789
+ ["/cost", "Show budget"],
640
790
  ["/clear", "Clear conversation"],
641
- ["/overview", "Overview view"],
791
+ ["/ws <name>", "Switch workstream"],
642
792
  ];
643
793
 
644
794
  return React.createElement(
@@ -651,12 +801,12 @@ function HelpOverlay({ onClose }) {
651
801
  marginY: 1,
652
802
  marginX: 2,
653
803
  },
654
- React.createElement(Text, { bold: true, color: "cyan" }, "🌿 Wispy Keyboard Shortcuts"),
804
+ React.createElement(Text, { bold: true, color: "cyan" }, "Wispy Keyboard Shortcuts"),
655
805
  React.createElement(Box, { height: 1 }),
656
806
  ...shortcuts.map(([key, desc], i) => React.createElement(
657
807
  Box, { key: i },
658
- React.createElement(Text, { color: "yellow", bold: true }, key.padEnd(15)),
659
- React.createElement(Text, { dimColor: true }, desc),
808
+ React.createElement(Text, { color: "yellow", bold: true }, key.padEnd(22)),
809
+ React.createElement(Text, { dimColor: true, wrap: "wrap" }, desc),
660
810
  )),
661
811
  React.createElement(Box, { height: 1 }),
662
812
  React.createElement(Text, { dimColor: true }, "Press ? or q to close"),
@@ -670,46 +820,32 @@ function CommandPalette({ query, onSelect, onDismiss }) {
670
820
 
671
821
  const matches = filterCommands(query, COMMANDS, 8);
672
822
 
673
- // Reset selection when query changes
674
823
  useEffect(() => { setSelectedIdx(0); }, [query]);
675
824
 
676
825
  useInput((input, key) => {
677
826
  if (matches.length === 0) return;
678
- if (key.upArrow) {
679
- setSelectedIdx(i => Math.max(0, i - 1));
680
- return;
681
- }
682
- if (key.downArrow) {
683
- setSelectedIdx(i => Math.min(matches.length - 1, i + 1));
684
- return;
685
- }
686
- if (key.return) {
687
- onSelect?.(matches[selectedIdx]?.cmd ?? query);
688
- return;
689
- }
690
- if (key.escape || (key.ctrl && input === 'c')) {
691
- onDismiss?.();
692
- return;
693
- }
827
+ if (key.upArrow) { setSelectedIdx(i => Math.max(0, i - 1)); return; }
828
+ if (key.downArrow) { setSelectedIdx(i => Math.min(matches.length - 1, i + 1)); return; }
829
+ if (key.return) { onSelect?.(matches[selectedIdx]?.cmd ?? query); return; }
830
+ if (key.escape || (key.ctrl && input === "c")) { onDismiss?.(); return; }
694
831
  });
695
832
 
696
833
  if (matches.length === 0) return null;
697
834
 
698
835
  const maxCmdLen = Math.max(...matches.map(m => m.cmd.length));
699
- const width = Math.min(60, maxCmdLen + 30);
700
836
 
701
837
  return React.createElement(
702
838
  Box, {
703
- flexDirection: 'column',
704
- borderStyle: 'single',
705
- borderColor: 'cyan',
839
+ flexDirection: "column",
840
+ borderStyle: "single",
841
+ borderColor: "cyan",
706
842
  paddingX: 1,
707
843
  marginBottom: 0,
708
844
  },
709
845
  React.createElement(
710
846
  Box, { paddingX: 1 },
711
- React.createElement(Text, { bold: true, color: 'cyan' }, '─ Commands '),
712
- React.createElement(Text, { dimColor: true }, '↑↓ navigate · Enter select · Esc dismiss'),
847
+ React.createElement(Text, { bold: true, color: "cyan" }, "Commands "),
848
+ React.createElement(Text, { dimColor: true }, "↑↓ navigate Enter select Esc dismiss"),
713
849
  ),
714
850
  ...matches.map((cmd, i) => {
715
851
  const isActive = i === selectedIdx;
@@ -717,11 +853,12 @@ function CommandPalette({ query, onSelect, onDismiss }) {
717
853
  return React.createElement(
718
854
  Box, { key: cmd.cmd },
719
855
  isActive
720
- ? React.createElement(Text, { color: 'black', backgroundColor: 'cyan' },
856
+ ? React.createElement(Text, { color: "black", backgroundColor: "cyan" },
721
857
  ` ${cmdStr} ${cmd.desc} `)
722
858
  : React.createElement(Box, {},
723
- React.createElement(Text, { color: 'cyan' }, ` ${cmd.cmd}`),
724
- React.createElement(Text, { dimColor: true }, ' '.repeat(Math.max(1, maxCmdLen - cmd.cmd.length + 2)) + cmd.desc),
859
+ React.createElement(Text, { color: "cyan" }, ` ${cmd.cmd}`),
860
+ React.createElement(Text, { dimColor: true },
861
+ " ".repeat(Math.max(1, maxCmdLen - cmd.cmd.length + 2)) + cmd.desc),
725
862
  ),
726
863
  );
727
864
  }),
@@ -735,8 +872,8 @@ function InputArea({ value, onChange, onSubmit, loading, workstream, view }) {
735
872
  const placeholder = loading
736
873
  ? "waiting for response..."
737
874
  : view !== "chat"
738
- ? `${view} mode — press Tab for chat`
739
- : "Type a message… (/help for commands)";
875
+ ? `${view} mode — Tab to switch to chat`
876
+ : "Type a message… (/help for commands | Tab to switch views)";
740
877
 
741
878
  return React.createElement(
742
879
  Box, {
@@ -849,7 +986,6 @@ async function loadOverviewData(workstreams) {
849
986
  result[ws] = { lastActivity: null, lastMessage: null, agents: 0 };
850
987
  }
851
988
 
852
- // Try work.md
853
989
  try {
854
990
  const workMdPath = path.join(WISPY_DIR, "workstreams", ws, "work.md");
855
991
  const wmd = await readFile(workMdPath, "utf8");
@@ -884,7 +1020,7 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
884
1020
  const [inputValue, setInputValue] = useState("");
885
1021
  const [loading, setLoading] = useState(false);
886
1022
 
887
- // Command palette (autocomplete)
1023
+ // Command palette
888
1024
  const [showPalette, setShowPalette] = useState(false);
889
1025
 
890
1026
  // Engine stats
@@ -904,6 +1040,9 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
904
1040
  const [memoryQuery, setMemoryQuery] = useState("");
905
1041
  const [cronCount, setCronCount] = useState(0);
906
1042
  const [userModelLoaded, setUserModelLoaded] = useState(false);
1043
+ const [browserStatus, setBrowserStatus] = useState(null);
1044
+ const [budgetSpent, setBudgetSpent] = useState(0);
1045
+ const [maxBudget, setMaxBudget] = useState(null);
907
1046
 
908
1047
  // Overview data
909
1048
  const [overviewData, setOverviewData] = useState({});
@@ -911,7 +1050,6 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
911
1050
  // Refs
912
1051
  const approvalResolverRef = useRef(null);
913
1052
  const conversationRef = useRef([]);
914
- const engineRef = useRef(engine);
915
1053
 
916
1054
  // ── Terminal resize ──
917
1055
  useEffect(() => {
@@ -941,7 +1079,6 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
941
1079
  loadSyncStatus(),
942
1080
  ]);
943
1081
 
944
- // Make sure active workstream is in list
945
1082
  const wsSet = new Set(wsList);
946
1083
  wsSet.add(initialWorkstream);
947
1084
  const wsFinal = Array.from(wsSet);
@@ -951,15 +1088,28 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
951
1088
  setCronCount(cron);
952
1089
  setSyncStatus(sync);
953
1090
  setUserModelLoaded(memFiles.some(f => f.key === "user"));
1091
+
1092
+ // Browser status
1093
+ try {
1094
+ const bs = engine?.browser?.status?.() ?? null;
1095
+ setBrowserStatus(bs);
1096
+ } catch {}
1097
+
1098
+ // Budget
1099
+ try {
1100
+ const bud = engine?.budget;
1101
+ if (bud) {
1102
+ setBudgetSpent(bud.sessionSpend ?? 0);
1103
+ setMaxBudget(bud.maxBudgetUsd ?? null);
1104
+ }
1105
+ } catch {}
954
1106
  };
955
1107
  loadAll();
956
-
957
- // Refresh periodically
958
1108
  const interval = setInterval(loadAll, 15_000);
959
1109
  return () => clearInterval(interval);
960
- }, [initialWorkstream]);
1110
+ }, [initialWorkstream, engine]);
961
1111
 
962
- // ── Load overview data ──
1112
+ // ── Load overview data — refresh when view switches to overview ──
963
1113
  useEffect(() => {
964
1114
  const refresh = async () => {
965
1115
  const data = await loadOverviewData(workstreams);
@@ -970,6 +1120,27 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
970
1120
  return () => clearInterval(interval);
971
1121
  }, [workstreams]);
972
1122
 
1123
+ // ── Refresh view data on view switch ──
1124
+ useEffect(() => {
1125
+ if (view === "overview") {
1126
+ loadOverviewData(workstreams).then(setOverviewData);
1127
+ try { setBrowserStatus(engine?.browser?.status?.() ?? null); } catch {}
1128
+ }
1129
+ if (view === "memory") {
1130
+ loadMemoryFiles().then(setMemoryFiles);
1131
+ }
1132
+ if (view === "settings") {
1133
+ try {
1134
+ const bud = engine?.budget;
1135
+ if (bud) {
1136
+ setBudgetSpent(bud.sessionSpend ?? 0);
1137
+ setMaxBudget(bud.maxBudgetUsd ?? null);
1138
+ }
1139
+ setBrowserStatus(engine?.browser?.status?.() ?? null);
1140
+ } catch {}
1141
+ }
1142
+ }, [view]);
1143
+
973
1144
  // ── Load agents ──
974
1145
  useEffect(() => {
975
1146
  const loadAgents = async () => {
@@ -978,8 +1149,8 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
978
1149
  const raw = await readFile(agentsFile, "utf8");
979
1150
  const all = JSON.parse(raw);
980
1151
  setAgents(all.filter(a =>
981
- a.status === "running" || a.status === "pending"
982
- ).slice(-5));
1152
+ a.status === "running" || a.status === "pending" || a.status === "completed" || a.status === "done"
1153
+ ).slice(-10));
983
1154
  } catch { setAgents([]); }
984
1155
  };
985
1156
  loadAgents();
@@ -1050,16 +1221,20 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1050
1221
 
1051
1222
  // ── Keyboard shortcuts ──
1052
1223
  useInput((input, key) => {
1053
- // Don't intercept when typing in input or approval dialog is up
1054
1224
  if (pendingApproval || showHelp) return;
1055
1225
 
1056
- // View switches
1057
1226
  if (key.tab && !loading) {
1058
1227
  const idx = VIEWS.indexOf(view);
1059
1228
  setView(VIEWS[(idx + 1) % VIEWS.length]);
1060
1229
  return;
1061
1230
  }
1062
1231
  if (input === "?" && !inputValue) { setShowHelp(true); return; }
1232
+ if (input === "1" && !inputValue && !loading) { setView("chat"); return; }
1233
+ if (input === "2" && !inputValue && !loading) { setView("overview"); return; }
1234
+ if (input === "3" && !inputValue && !loading) { setView("agents"); return; }
1235
+ if (input === "4" && !inputValue && !loading) { setView("memory"); return; }
1236
+ if (input === "5" && !inputValue && !loading) { setView("audit"); return; }
1237
+ if (input === "6" && !inputValue && !loading) { setView("settings"); return; }
1063
1238
  if (input === "o" && !inputValue && !loading) { setView("overview"); return; }
1064
1239
  if (input === "a" && !inputValue && !loading) { setView("agents"); return; }
1065
1240
  if (input === "m" && !inputValue && !loading) { setView("memory"); return; }
@@ -1067,7 +1242,6 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1067
1242
  if (input === "s" && !inputValue && !loading) { setView("settings"); return; }
1068
1243
  if (input === "t" && !inputValue) { setShowTimeline(prev => !prev); return; }
1069
1244
 
1070
- // Ctrl+L = clear chat
1071
1245
  if (key.ctrl && input === "l") {
1072
1246
  conversationRef.current = [];
1073
1247
  setMessages([]);
@@ -1075,8 +1249,8 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1075
1249
  return;
1076
1250
  }
1077
1251
 
1078
- // Workstream number switch
1079
- if (/^[1-9]$/.test(input) && !inputValue && !loading) {
1252
+ // Workstream number switch (7-9 and beyond 6)
1253
+ if (/^[7-9]$/.test(input) && !inputValue && !loading) {
1080
1254
  const idx = parseInt(input) - 1;
1081
1255
  if (idx < workstreams.length) {
1082
1256
  switchWorkstream(workstreams[idx]);
@@ -1084,7 +1258,6 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1084
1258
  return;
1085
1259
  }
1086
1260
 
1087
- // q = quit (only when input is empty)
1088
1261
  if (input === "q" && !inputValue && !loading) {
1089
1262
  exit();
1090
1263
  process.exit(0);
@@ -1128,24 +1301,19 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1128
1301
  if (action) {
1129
1302
  setMessages(prev => [...prev, {
1130
1303
  role: "assistant",
1131
- content: `👁️ **Dry-run preview**\n\nWould execute: \`${action.toolName}\`\n\`\`\`\n${JSON.stringify(action.args, null, 2).slice(0, 300)}\n\`\`\`\n*(Real execution denied. Approve to run.)* 🌿`,
1304
+ 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.)*`,
1132
1305
  }]);
1133
1306
  }
1134
1307
  }, [pendingApproval]);
1135
1308
 
1136
- // ── Palette visibility: show when typing slash commands ──
1309
+ // ── Palette visibility ──
1137
1310
  const handleInputChange = useCallback((val) => {
1138
1311
  setInputValue(val);
1139
- if (val.startsWith('/') && val.length >= 2) {
1140
- setShowPalette(true);
1141
- } else {
1142
- setShowPalette(false);
1143
- }
1312
+ setShowPalette(val.startsWith("/") && val.length >= 2);
1144
1313
  }, []);
1145
1314
 
1146
1315
  const handlePaletteSelect = useCallback((cmd) => {
1147
- // Fill in base command (strip template args like <name>)
1148
- const base = cmd.replace(/<[^>]+>/g, '').trimEnd();
1316
+ const base = cmd.replace(/<[^>]+>/g, "").trimEnd();
1149
1317
  setInputValue(base);
1150
1318
  setShowPalette(false);
1151
1319
  }, []);
@@ -1162,7 +1330,6 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1162
1330
  setInputValue("");
1163
1331
  setShowPalette(false);
1164
1332
 
1165
- // Switch to chat if not there
1166
1333
  if (view !== "chat") setView("chat");
1167
1334
 
1168
1335
  // Slash commands
@@ -1173,18 +1340,21 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1173
1340
  if (cmd === "/quit" || cmd === "/exit") { exit(); process.exit(0); return; }
1174
1341
  if (cmd === "/clear") {
1175
1342
  conversationRef.current = [];
1176
- setMessages([{ role: "system_info", content: "Conversation cleared. 🌿" }]);
1343
+ setMessages([{ role: "system_info", content: "Conversation cleared." }]);
1177
1344
  await saveConversation(activeWorkstream, []);
1178
1345
  return;
1179
1346
  }
1180
1347
  if (cmd === "/cost") {
1181
- setMessages(prev => [...prev, { role: "system_info", content: `${totalTokens}t · $${totalCost.toFixed(4)}` }]);
1182
- return;
1183
- }
1184
- if (cmd === "/timeline") {
1185
- setView("audit");
1348
+ const bud = engine?.budget;
1349
+ const spent = bud?.sessionSpend ?? totalCost;
1350
+ const max = bud?.maxBudgetUsd;
1351
+ setMessages(prev => [...prev, {
1352
+ role: "system_info",
1353
+ content: `Budget: $${spent.toFixed(4)}${max ? ` / $${max.toFixed(2)}` : ""} — ${totalTokens} tokens`,
1354
+ }]);
1186
1355
  return;
1187
1356
  }
1357
+ if (cmd === "/timeline") { setView("audit"); return; }
1188
1358
  if (cmd === "/overview" || cmd === "/o") { setView("overview"); return; }
1189
1359
  if (cmd === "/agents" || cmd === "/a") { setView("agents"); return; }
1190
1360
  if (cmd === "/memories" || cmd === "/m") { setView("memory"); return; }
@@ -1193,19 +1363,19 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1193
1363
  if (cmd === "/model" && parts[1]) {
1194
1364
  engine.providers.setModel(parts[1]);
1195
1365
  setModel(parts[1]);
1196
- setMessages(prev => [...prev, { role: "system_info", content: `Model → ${parts[1]} 🌿` }]);
1197
- return;
1198
- }
1199
- if (cmd === "/workstream" && parts[1]) {
1200
- await switchWorkstream(parts[1]);
1201
- return;
1202
- }
1203
- if (cmd === "/help") {
1204
- setShowHelp(true);
1366
+ setMessages(prev => [...prev, { role: "system_info", content: `Model → ${parts[1]}` }]);
1205
1367
  return;
1206
1368
  }
1207
- if (cmd === "/ws" && parts[1]) {
1208
- await switchWorkstream(parts[1]);
1369
+ if (cmd === "/workstream" && parts[1]) { await switchWorkstream(parts[1]); return; }
1370
+ if (cmd === "/ws" && parts[1]) { await switchWorkstream(parts[1]); return; }
1371
+ if (cmd === "/help") { setShowHelp(true); return; }
1372
+ if (cmd === "/trust" && parts[1]) {
1373
+ try {
1374
+ engine.permissions.setPolicy("run_command", parts[1]);
1375
+ setMessages(prev => [...prev, { role: "system_info", content: `Security level → ${parts[1]}` }]);
1376
+ } catch (e) {
1377
+ setMessages(prev => [...prev, { role: "system_info", content: `Error: ${e.message}` }]);
1378
+ }
1209
1379
  return;
1210
1380
  }
1211
1381
  }
@@ -1255,18 +1425,25 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1255
1425
  // Update token/cost tracking
1256
1426
  const { input: inputToks = 0, output: outputToks = 0 } = engine.providers.sessionTokens ?? {};
1257
1427
  setTotalTokens(inputToks + outputToks);
1258
- const MODEL_PRICING = {
1259
- "gemini-2.5-flash": { input: 0.15, output: 0.60 },
1260
- "gemini-2.5-pro": { input: 1.25, output: 10.0 },
1261
- "claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
1262
- "gpt-4o": { input: 2.50, output: 10.0 },
1263
- "gpt-4.1": { input: 2.0, output: 8.0 },
1264
- "llama-3.3-70b-versatile": { input: 0.59, output: 0.79 },
1265
- "deepseek-chat": { input: 0.27, output: 1.10 },
1266
- };
1267
- const pricing = MODEL_PRICING[model] ?? { input: 1.0, output: 3.0 };
1268
- const cost = (inputToks * pricing.input + outputToks * pricing.output) / 1_000_000;
1269
- setTotalCost(cost);
1428
+
1429
+ // Use budget manager if available
1430
+ const bud = engine?.budget;
1431
+ if (bud) {
1432
+ setBudgetSpent(bud.sessionSpend ?? 0);
1433
+ setMaxBudget(bud.maxBudgetUsd ?? null);
1434
+ setTotalCost(bud.sessionSpend ?? 0);
1435
+ } else {
1436
+ const MODEL_PRICING = {
1437
+ "gemini-2.5-flash": { input: 0.15, output: 0.60 },
1438
+ "gemini-2.5-pro": { input: 1.25, output: 10.0 },
1439
+ "claude-sonnet-4-20250514": { input: 3.0, output: 15.0 },
1440
+ "gpt-4o": { input: 2.50, output: 10.0 },
1441
+ "gpt-4.1": { input: 2.0, output: 8.0 },
1442
+ };
1443
+ const pricing = MODEL_PRICING[model] ?? { input: 1.0, output: 3.0 };
1444
+ const cost = (inputToks * pricing.input + outputToks * pricing.output) / 1_000_000;
1445
+ setTotalCost(cost);
1446
+ }
1270
1447
  setModel(engine.model ?? model);
1271
1448
 
1272
1449
  const assistantMsg = { role: "assistant", content: responseText };
@@ -1274,19 +1451,20 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1274
1451
  setMessages(prev => [...prev, assistantMsg]);
1275
1452
  await saveConversation(activeWorkstream, conversationRef.current.filter(m => m.role !== "system"));
1276
1453
  } catch (err) {
1277
- const errMsg = { role: "assistant", content: `❌ Error: ${err.message.slice(0, 200)} 🌿` };
1454
+ const errMsg = { role: "assistant", content: `Error: ${err.message.slice(0, 200)}` };
1278
1455
  setMessages(prev => [...prev, errMsg]);
1279
1456
  } finally {
1280
1457
  setLoading(false);
1281
1458
  }
1282
1459
  }, [loading, model, totalCost, totalTokens, engine, exit, pendingApproval, activeWorkstream, view, switchWorkstream]);
1283
1460
 
1284
- // ── Layout computation ──
1461
+ // ── Layout ──
1285
1462
  const showSidebar = termWidth >= 80;
1286
1463
  const mainWidth = showSidebar ? termWidth - SIDEBAR_WIDTH - 2 : termWidth;
1287
1464
  const permMode = engine.permissions?.getPolicy?.("run_command") ?? "approve";
1465
+ const activeAgent = engine?._personality ?? engine?._activeAgent ?? null;
1288
1466
 
1289
- // ── Render ──
1467
+ // ── Main content ──
1290
1468
  const mainContent = showHelp
1291
1469
  ? React.createElement(HelpOverlay, { onClose: () => setShowHelp(false) })
1292
1470
  : view === "chat"
@@ -1296,9 +1474,9 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1296
1474
  mainWidth,
1297
1475
  })
1298
1476
  : view === "overview"
1299
- ? React.createElement(OverviewView, { workstreams, activeWorkstream, overviewData, mainWidth })
1477
+ ? React.createElement(OverviewView, { workstreams, activeWorkstream, overviewData, browserStatus, mainWidth })
1300
1478
  : view === "agents"
1301
- ? React.createElement(AgentsView, { agents })
1479
+ ? React.createElement(AgentsView, { agents, agentManager: engine?.agentManager })
1302
1480
  : view === "memory"
1303
1481
  ? React.createElement(MemoryView, { memoryFiles, memoryQuery, onQueryChange: setMemoryQuery })
1304
1482
  : view === "audit"
@@ -1327,34 +1505,36 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1327
1505
  React.createElement(
1328
1506
  Box, { flexDirection: "row", flexGrow: 1 },
1329
1507
 
1330
- // Left sidebar
1331
1508
  showSidebar
1332
1509
  ? React.createElement(Sidebar, {
1333
1510
  workstreams,
1334
1511
  activeWorkstream,
1512
+ activeAgent,
1335
1513
  agents,
1336
1514
  memoryCount: memoryFiles.length,
1337
1515
  userModelLoaded,
1338
1516
  cronCount,
1339
1517
  syncStatus,
1518
+ browserStatus,
1519
+ budgetSpent,
1520
+ maxBudget,
1340
1521
  onSelectWorkstream: switchWorkstream,
1341
1522
  })
1342
1523
  : null,
1343
1524
 
1344
- // Main view
1345
1525
  React.createElement(
1346
1526
  Box, { flexDirection: "column", flexGrow: 1 },
1347
1527
  mainContent,
1348
1528
  ),
1349
1529
  ),
1350
1530
 
1351
- // Timeline bar (only in chat view, last 3 events)
1531
+ // Timeline bar
1352
1532
  (showTimeline || view === "chat") && timeline.length > 0
1353
1533
  ? React.createElement(TimelineBar, { events: timeline })
1354
1534
  : null,
1355
1535
 
1356
- // Command palette (autocomplete) — shown above input when typing /command
1357
- !showHelp && showPalette && inputValue.startsWith('/')
1536
+ // Command palette
1537
+ !showHelp && showPalette && inputValue.startsWith("/")
1358
1538
  ? React.createElement(CommandPalette, {
1359
1539
  query: inputValue,
1360
1540
  onSelect: handlePaletteSelect,
@@ -1362,7 +1542,7 @@ function WispyWorkspaceApp({ engine, initialWorkstream }) {
1362
1542
  })
1363
1543
  : null,
1364
1544
 
1365
- // Input area (always at bottom; hidden when help shown)
1545
+ // Input
1366
1546
  !showHelp
1367
1547
  ? React.createElement(InputArea, {
1368
1548
  value: inputValue,
@@ -1392,7 +1572,6 @@ async function main() {
1392
1572
  process.exit(1);
1393
1573
  }
1394
1574
 
1395
- // Clear screen
1396
1575
  process.stdout.write("\x1b[2J\x1b[H");
1397
1576
 
1398
1577
  const { waitUntilExit } = render(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wispy-cli",
3
- "version": "2.7.18",
3
+ "version": "2.7.19",
4
4
  "description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
5
5
  "license": "MIT",
6
6
  "author": "Minseo & Poropo",