wispy-cli 1.2.3 → 1.4.0

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/lib/wispy-tui.mjs CHANGED
@@ -1,68 +1,76 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * wispy-tui.mjs — Ink-based TUI for Wispy
4
- * v1.2: Trust UX — Approval dialogs, receipts, action timeline, diff views
3
+ * wispy-tui.mjs — Workspace OS TUI for Wispy v2.0
5
4
  *
6
- * Features:
7
- * - Status bar: provider / model / workstream / session cost / permission mode
8
- * - Message list with basic markdown rendering
9
- * - Tool execution display
10
- * - Approval dialog (inline keyboard: Y/N/D)
11
- * - Receipt view after tool execution
12
- * - Action timeline
13
- * - Diff view with color
14
- * - Input box (single-line with submit)
15
- * - Loading spinner while AI is thinking
5
+ * Multi-panel workspace interface:
6
+ * - Left sidebar: Workstreams, Agents, Memory, Cron, Sync
7
+ * - Main area: Chat / Overview / Agents / Memory / Audit / Settings
8
+ * - Bottom: Action Timeline bar + Input
9
+ * - Overlays: Approval dialogs, Diff views
16
10
  */
17
11
 
18
- import React, { useState, useEffect, useRef, useCallback } from "react";
19
- import { render, Box, Text, useApp, Newline, useInput } from "ink";
12
+ import React, { useState, useEffect, useRef, useCallback, useReducer } from "react";
13
+ import { render, Box, Text, useApp, Newline, useInput, useStdout } from "ink";
20
14
  import Spinner from "ink-spinner";
21
15
  import TextInput from "ink-text-input";
22
16
 
23
17
  import os from "node:os";
24
18
  import path from "node:path";
25
- import { readFile, writeFile, mkdir } from "node:fs/promises";
19
+ import { readFile, writeFile, readdir, stat, mkdir } from "node:fs/promises";
26
20
 
27
- import { WispyEngine, CONVERSATIONS_DIR, PROVIDERS } from "../core/index.mjs";
21
+ import { WispyEngine, CONVERSATIONS_DIR, PROVIDERS, WISPY_DIR, MEMORY_DIR } from "../core/index.mjs";
28
22
 
29
- // -----------------------------------------------------------------------
30
- // Parse workstream from args
31
- // -----------------------------------------------------------------------
23
+ // ─── Parse CLI args ──────────────────────────────────────────────────────────
32
24
 
33
25
  const rawArgs = process.argv.slice(2);
34
26
  const wsIdx = rawArgs.findIndex((a) => a === "-w" || a === "--workstream");
35
- const ACTIVE_WORKSTREAM =
27
+ const INITIAL_WORKSTREAM =
36
28
  process.env.WISPY_WORKSTREAM ??
37
29
  (wsIdx !== -1 ? rawArgs[wsIdx + 1] : null) ??
38
30
  "default";
39
31
 
40
- const HISTORY_FILE = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.json`);
32
+ // ─── Constants ───────────────────────────────────────────────────────────────
41
33
 
42
- // -----------------------------------------------------------------------
43
- // Conversation persistence
44
- // -----------------------------------------------------------------------
34
+ const VIEWS = ["chat", "overview", "agents", "memory", "audit", "settings"];
35
+ const SIDEBAR_WIDTH = 16;
36
+ const TIMELINE_LINES = 3;
45
37
 
46
- async function readFileOr(p, fallback = null) {
47
- try { return await readFile(p, "utf8"); } catch { return fallback; }
38
+ const TOOL_ICONS = {
39
+ read_file: "📖", write_file: "✏️", file_edit: "✏️", run_command: "⚙️",
40
+ git: "🌿", web_search: "🔍", web_fetch: "🌐", list_directory: "📁",
41
+ spawn_subagent: "🤖", spawn_agent: "🤖", memory_save: "💾",
42
+ memory_search: "🔍", memory_list: "📝", delete_file: "🗑️",
43
+ node_execute: "🖥️", update_work_context: "📝",
44
+ };
45
+
46
+ // ─── Utilities ───────────────────────────────────────────────────────────────
47
+
48
+ function fmtTime(iso) {
49
+ if (!iso) return "";
50
+ try { return new Date(iso).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" }); }
51
+ catch { return ""; }
48
52
  }
49
53
 
50
- async function loadConversation() {
51
- const raw = await readFileOr(HISTORY_FILE);
52
- if (!raw) return [];
53
- try { return JSON.parse(raw); } catch { return []; }
54
+ function fmtRelTime(iso) {
55
+ if (!iso) return "";
56
+ try {
57
+ const diff = Date.now() - new Date(iso).getTime();
58
+ if (diff < 60_000) return "just now";
59
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}min ago`;
60
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}hr ago`;
61
+ return "yesterday";
62
+ } catch { return ""; }
54
63
  }
55
64
 
56
- async function saveConversation(messages) {
57
- await mkdir(CONVERSATIONS_DIR, { recursive: true });
58
- await writeFile(HISTORY_FILE, JSON.stringify(messages.slice(-50), null, 2) + "\n", "utf8");
65
+ function truncate(str, n) {
66
+ if (!str) return "";
67
+ return str.length > n ? str.slice(0, n - 1) + "" : str;
59
68
  }
60
69
 
61
- // -----------------------------------------------------------------------
62
- // Simple markdown → Ink renderer
63
- // -----------------------------------------------------------------------
70
+ // ─── Markdown renderer ───────────────────────────────────────────────────────
64
71
 
65
- function renderMarkdown(text) {
72
+ function renderMarkdown(text, maxWidth = 60) {
73
+ if (!text) return [];
66
74
  const lines = text.split("\n");
67
75
  return lines.map((line, i) => {
68
76
  if (line.startsWith("```")) return React.createElement(Text, { key: i, dimColor: true }, line);
@@ -71,13 +79,13 @@ function renderMarkdown(text) {
71
79
  if (line.startsWith("# ")) return React.createElement(Text, { key: i, bold: true, color: "magenta" }, line.slice(2));
72
80
  if (line.startsWith("- ") || line.startsWith("* ")) {
73
81
  return React.createElement(Box, { key: i },
74
- React.createElement(Text, { color: "green" }, " • "),
82
+ React.createElement(Text, { color: "green" }, "• "),
75
83
  React.createElement(Text, null, line.slice(2)));
76
84
  }
77
85
  if (/^\d+\.\s/.test(line)) {
78
86
  const match = line.match(/^(\d+\.\s)(.*)/);
79
87
  return React.createElement(Box, { key: i },
80
- React.createElement(Text, { color: "yellow" }, " " + match[1]),
88
+ React.createElement(Text, { color: "yellow" }, match[1]),
81
89
  React.createElement(Text, null, match[2]));
82
90
  }
83
91
  if (line.includes("**")) {
@@ -88,164 +96,492 @@ function renderMarkdown(text) {
88
96
  : React.createElement(Text, { key: j }, p));
89
97
  return React.createElement(Box, { key: i }, ...children);
90
98
  }
91
- if (line.startsWith("---") || line.startsWith("===")) return React.createElement(Text, { key: i, dimColor: true }, "─".repeat(50));
92
- if (line === "") return React.createElement(Newline, { key: i });
93
- return React.createElement(Text, { key: i }, line);
99
+ if (line.startsWith("---") || line.startsWith("===")) {
100
+ return React.createElement(Text, { key: i, dimColor: true }, "─".repeat(Math.min(50, maxWidth)));
101
+ }
102
+ if (line === "") return React.createElement(Box, { key: i, height: 1 });
103
+ return React.createElement(Text, { key: i, wrap: "wrap" }, line);
94
104
  });
95
105
  }
96
106
 
97
- // -----------------------------------------------------------------------
98
- // ── Trust UX Components ──────────────────────────────────────────────────
99
- // -----------------------------------------------------------------------
107
+ // ─── Status Bar ──────────────────────────────────────────────────────────────
100
108
 
101
- /**
102
- * DiffView shows unified diff with +/- coloring
103
- */
104
- function DiffView({ before, after, filePath, unified }) {
105
- const diffLines = unified ? unified.split("\n") : [];
106
- if (diffLines.length === 0) return null;
109
+ function StatusBar({ workstream, model, provider, permMode, syncStatus, tokens, cost, pendingApprovals, view, termWidth }) {
110
+ const providerLabel = PROVIDERS[provider]?.label?.split(" ")[0] ?? provider ?? "?";
111
+ const costStr = cost > 0 ? `$${cost.toFixed(4)}` : "";
112
+ const permIcon = permMode === "approve" ? "🔐" : permMode === "notify" ? "📋" : "✅";
113
+ const syncIcon = syncStatus === "auto" ? "💾" : syncStatus === "manual" ? "⏸" : "🔴";
114
+ const viewLabel = view.toUpperCase();
115
+
116
+ const wide = termWidth >= 100;
107
117
 
108
118
  return React.createElement(
109
- Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0 },
110
- React.createElement(Text, { dimColor: true }, "".repeat(40)),
111
- React.createElement(Text, { bold: true, color: "cyan" }, `Diff: ${filePath ?? "file"}`),
112
- ...diffLines.slice(0, 40).map((line, i) => {
113
- if (line.startsWith("+++") || line.startsWith("---")) {
114
- return React.createElement(Text, { key: i, dimColor: true }, line);
115
- }
116
- if (line.startsWith("@@")) {
117
- return React.createElement(Text, { key: i, color: "cyan" }, line);
118
- }
119
- if (line.startsWith("+")) {
120
- return React.createElement(Text, { key: i, color: "green" }, line);
121
- }
122
- if (line.startsWith("-")) {
123
- return React.createElement(Text, { key: i, color: "red" }, line);
124
- }
125
- return React.createElement(Text, { key: i, dimColor: true }, line);
126
- }),
127
- React.createElement(Text, { dimColor: true }, "─".repeat(40)),
119
+ Box, { paddingX: 1, backgroundColor: "green", width: "100%" },
120
+ React.createElement(Text, { color: "black", bold: true }, "🌿 Wispy"),
121
+ React.createElement(Text, { color: "black" }, " "),
122
+ React.createElement(Text, { color: "black", bold: true }, workstream),
123
+ React.createElement(Text, { color: "black" }, ""),
124
+ React.createElement(Text, { color: "black" }, truncate(model ?? "?", 20)),
125
+ wide ? React.createElement(Text, { color: "black" }, ` ─ ${permIcon}${permMode ?? "auto"}`) : null,
126
+ wide && syncIcon ? React.createElement(Text, { color: "black" }, ` ─ ${syncIcon}synced`) : null,
127
+ wide && costStr ? React.createElement(Text, { color: "black", dimColor: true }, ` ─ ${costStr}`) : null,
128
+ pendingApprovals > 0
129
+ ? React.createElement(Text, { color: "black", bold: true }, ` ─ ⚠️ ${pendingApprovals} pending`)
130
+ : null,
131
+ React.createElement(Text, { color: "black", dimColor: true }, ` [${viewLabel}] ? for help`),
128
132
  );
129
133
  }
130
134
 
131
- /**
132
- * ReceiptView — shows after tool execution
133
- */
134
- function ReceiptView({ receipt }) {
135
- if (!receipt) return null;
136
- const icon = receipt.success ? "✅" : "❌";
137
- const dryTag = receipt.dryRun ? " [DRY RUN]" : "";
135
+ // ─── Left Sidebar ─────────────────────────────────────────────────────────────
136
+
137
+ function Sidebar({ workstreams, activeWorkstream, agents, memoryCount, userModelLoaded, cronCount, syncStatus, sidebarHeight, onSelectWorkstream }) {
138
+ const agentRunning = agents.filter(a => a.status === "running");
139
+ const agentPending = agents.filter(a => a.status === "pending");
138
140
 
139
- const lines = [
141
+ const agentIcon = (status) => {
142
+ if (status === "running") return "🟢";
143
+ if (status === "pending") return "🟡";
144
+ if (status === "completed" || status === "done") return "✅";
145
+ return "❌";
146
+ };
147
+
148
+ const syncIcon = syncStatus === "auto" ? "🔄 auto" : syncStatus === "manual" ? "⏸ manual" : "❌ off";
149
+
150
+ const rows = [];
151
+
152
+ // Section: STREAMS
153
+ rows.push(React.createElement(
154
+ Box, { key: "streams-header", paddingLeft: 1 },
155
+ React.createElement(Text, { bold: true, dimColor: true }, "STREAMS"),
156
+ ));
157
+
158
+ workstreams.forEach((ws, i) => {
159
+ const isActive = ws === activeWorkstream;
160
+ rows.push(React.createElement(
161
+ Box, { key: `ws-${ws}`, paddingLeft: 1 },
162
+ React.createElement(Text, { color: isActive ? "green" : undefined, bold: isActive },
163
+ `${isActive ? "▸" : " "}${truncate(ws, SIDEBAR_WIDTH - 2)}`),
164
+ ));
165
+ });
166
+
167
+ // Section: AGENTS
168
+ rows.push(React.createElement(Box, { key: "div1", borderStyle: "single", borderTop: false, borderRight: false, borderLeft: false, borderBottom: true, borderColor: "gray", width: SIDEBAR_WIDTH }));
169
+ rows.push(React.createElement(
170
+ Box, { key: "agents-header", paddingLeft: 1 },
171
+ React.createElement(Text, { bold: true, dimColor: true }, "AGENTS"),
172
+ ));
173
+
174
+ if (agents.length === 0) {
175
+ rows.push(React.createElement(
176
+ Box, { key: "no-agents", paddingLeft: 1 },
177
+ React.createElement(Text, { dimColor: true }, " (none)"),
178
+ ));
179
+ } else {
180
+ agents.slice(0, 4).forEach((a, i) => {
181
+ rows.push(React.createElement(
182
+ Box, { key: `agent-${i}`, paddingLeft: 1 },
183
+ React.createElement(Text, {}, `${agentIcon(a.status)}${truncate(a.label ?? a.role ?? "agent", SIDEBAR_WIDTH - 3)}`),
184
+ ));
185
+ });
186
+ }
187
+
188
+ // Section: MEMORY
189
+ rows.push(React.createElement(Box, { key: "div2", borderStyle: "single", borderTop: false, borderRight: false, borderLeft: false, borderBottom: true, borderColor: "gray", width: SIDEBAR_WIDTH }));
190
+ rows.push(React.createElement(
191
+ Box, { key: "mem-header", paddingLeft: 1 },
192
+ React.createElement(Text, { bold: true, dimColor: true }, "MEMORY"),
193
+ ));
194
+ rows.push(React.createElement(
195
+ Box, { key: "mem-files", paddingLeft: 1 },
196
+ React.createElement(Text, {}, `📝${memoryCount} files`),
197
+ ));
198
+ rows.push(React.createElement(
199
+ Box, { key: "mem-model", paddingLeft: 1 },
200
+ React.createElement(Text, {}, `🧠model ${userModelLoaded ? "✅" : "❌"}`),
201
+ ));
202
+
203
+ // Section: CRON
204
+ rows.push(React.createElement(Box, { key: "div3", borderStyle: "single", borderTop: false, borderRight: false, borderLeft: false, borderBottom: true, borderColor: "gray", width: SIDEBAR_WIDTH }));
205
+ rows.push(React.createElement(
206
+ Box, { key: "cron-header", paddingLeft: 1 },
207
+ React.createElement(Text, { bold: true, dimColor: true }, "CRON"),
208
+ ));
209
+ rows.push(React.createElement(
210
+ Box, { key: "cron-jobs", paddingLeft: 1 },
211
+ React.createElement(Text, {}, `⏰${cronCount} jobs`),
212
+ ));
213
+
214
+ // Section: SYNC
215
+ rows.push(React.createElement(Box, { key: "div4", borderStyle: "single", borderTop: false, borderRight: false, borderLeft: false, borderBottom: true, borderColor: "gray", width: SIDEBAR_WIDTH }));
216
+ rows.push(React.createElement(
217
+ Box, { key: "sync-header", paddingLeft: 1 },
218
+ React.createElement(Text, { bold: true, dimColor: true }, "SYNC"),
219
+ ));
220
+ rows.push(React.createElement(
221
+ Box, { key: "sync-status", paddingLeft: 1 },
222
+ React.createElement(Text, {}, syncIcon),
223
+ ));
224
+
225
+ return React.createElement(
226
+ Box, {
227
+ flexDirection: "column",
228
+ width: SIDEBAR_WIDTH,
229
+ borderStyle: "single",
230
+ borderColor: "gray",
231
+ borderTop: false,
232
+ borderBottom: false,
233
+ borderLeft: false,
234
+ },
235
+ ...rows,
236
+ );
237
+ }
238
+
239
+ // ─── Chat View ───────────────────────────────────────────────────────────────
240
+
241
+ function ToolLine({ name, args, receipt }) {
242
+ const icon = TOOL_ICONS[name] ?? "🔧";
243
+ const primaryArg = args?.path ?? args?.command ?? args?.query ?? args?.task ?? args?.key ?? "";
244
+ const statusIcon = receipt
245
+ ? (receipt.success ? "✅" : "❌")
246
+ : "⏳";
247
+
248
+ return React.createElement(
249
+ Box, { flexDirection: "column", paddingLeft: 3 },
140
250
  React.createElement(
141
- Box, { key: "header" },
142
- React.createElement(Text, { bold: true, color: receipt.success ? "green" : "red" },
143
- `${icon} ${receipt.toolName}${dryTag}`),
144
- React.createElement(Text, { dimColor: true }, ` (${receipt.duration}ms)`),
251
+ Box, {},
252
+ React.createElement(Text, { color: "cyan", dimColor: true },
253
+ `${statusIcon} ${icon} ${name}`),
254
+ primaryArg ? React.createElement(Text, { dimColor: true }, ` ${truncate(primaryArg, 35)}`) : null,
145
255
  ),
146
- ];
256
+ receipt?.diff?.unified ? React.createElement(MiniDiff, { unified: receipt.diff.unified, filePath: args?.path }) : null,
257
+ );
258
+ }
147
259
 
148
- if (receipt.error) {
149
- lines.push(React.createElement(Text, { key: "err", color: "red" }, ` Error: ${receipt.error}`));
260
+ function MiniDiff({ unified, filePath }) {
261
+ const lines = unified.split("\n").filter(l => l);
262
+ let added = 0; let removed = 0;
263
+ for (const l of lines) {
264
+ if (l.startsWith("+") && !l.startsWith("+++")) added++;
265
+ else if (l.startsWith("-") && !l.startsWith("---")) removed++;
150
266
  }
267
+ return React.createElement(
268
+ Box, { paddingLeft: 2 },
269
+ React.createElement(Text, { color: "green" }, `+${added}`),
270
+ React.createElement(Text, { dimColor: true }, "/"),
271
+ React.createElement(Text, { color: "red" }, `-${removed}`),
272
+ filePath ? React.createElement(Text, { dimColor: true }, ` ${path.basename(filePath)}`) : null,
273
+ );
274
+ }
151
275
 
152
- if (receipt.diff?.unified) {
153
- const stats = (() => {
154
- let added = 0; let removed = 0;
155
- for (const l of receipt.diff.unified.split("\n")) {
156
- if (l.startsWith("+") && !l.startsWith("+++")) added++;
157
- else if (l.startsWith("-") && !l.startsWith("---")) removed++;
158
- }
159
- return { added, removed };
160
- })();
161
- lines.push(
162
- React.createElement(Text, { key: "stats", color: "yellow" },
163
- ` Changes: +${stats.added} lines, -${stats.removed} lines`),
164
- React.createElement(DiffView, {
165
- key: "diff",
166
- filePath: receipt.args?.path,
167
- unified: receipt.diff.unified,
168
- }),
276
+ function ChatMessage({ msg, mainWidth }) {
277
+ if (msg.role === "user") {
278
+ return React.createElement(
279
+ Box, { flexDirection: "column", paddingLeft: 1, marginTop: 1 },
280
+ React.createElement(Box, {},
281
+ React.createElement(Text, { color: "green", bold: true }, "🧑 "),
282
+ React.createElement(Text, { color: "white", wrap: "wrap" }, msg.content)));
283
+ }
284
+ if (msg.role === "tool_call") {
285
+ return React.createElement(ToolLine, { name: msg.name, args: msg.args, receipt: msg.receipt });
286
+ }
287
+ if (msg.role === "assistant") {
288
+ return React.createElement(
289
+ Box, { flexDirection: "column", paddingLeft: 1, marginTop: 1 },
290
+ React.createElement(Text, { color: "cyan", bold: true }, "🤖"),
291
+ React.createElement(Box, { flexDirection: "column", paddingLeft: 2 },
292
+ ...renderMarkdown(msg.content, mainWidth - 4)),
169
293
  );
170
294
  }
295
+ if (msg.role === "system_info") {
296
+ return React.createElement(
297
+ Box, { paddingLeft: 2 },
298
+ React.createElement(Text, { dimColor: true, italic: true }, msg.content));
299
+ }
300
+ return null;
301
+ }
302
+
303
+ function ChatView({ messages, loading, pendingApproval, onApprove, onDeny, onDryRun, mainWidth }) {
304
+ const displayMessages = messages.slice(-25);
305
+
306
+ return React.createElement(
307
+ Box, { flexDirection: "column", flexGrow: 1, overflowY: "hidden" },
308
+ displayMessages.length === 0
309
+ ? React.createElement(
310
+ Box, { flexGrow: 1, alignItems: "center", justifyContent: "center", flexDirection: "column" },
311
+ React.createElement(Text, { dimColor: true }, "🌿 Welcome to Wispy"),
312
+ React.createElement(Text, { dimColor: true }, "Type a message to start. ? for help."),
313
+ )
314
+ : React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
315
+ ...displayMessages.map((msg, i) =>
316
+ React.createElement(ChatMessage, { key: i, msg, mainWidth }))),
317
+
318
+ loading
319
+ ? React.createElement(
320
+ Box, { paddingLeft: 2, marginTop: 1 },
321
+ React.createElement(Spinner, { type: "dots" }),
322
+ React.createElement(Text, { color: "yellow" }, " thinking..."),
323
+ )
324
+ : null,
171
325
 
326
+ pendingApproval
327
+ ? React.createElement(ApprovalDialog, {
328
+ action: pendingApproval.action,
329
+ onApprove, onDeny, onDryRun,
330
+ })
331
+ : null,
332
+ );
333
+ }
334
+
335
+ // ─── Overview View ────────────────────────────────────────────────────────────
336
+
337
+ function OverviewView({ workstreams, activeWorkstream, overviewData, mainWidth }) {
172
338
  return React.createElement(
173
- Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0 },
174
- ...lines,
339
+ Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
340
+ React.createElement(Text, { bold: true, color: "green" }, "Workstream Overview"),
341
+ React.createElement(Box, { height: 1 }),
342
+ ...workstreams.map((ws, i) => {
343
+ const data = overviewData[ws] ?? {};
344
+ const isActive = ws === activeWorkstream;
345
+ return React.createElement(
346
+ Box, { key: ws, flexDirection: "column", marginBottom: 1 },
347
+ React.createElement(Box, {},
348
+ React.createElement(Text, { bold: true, color: isActive ? "green" : "white" }, ws),
349
+ React.createElement(Text, { dimColor: true }, " "),
350
+ data.lastActivity
351
+ ? React.createElement(Text, { dimColor: true }, `Last: ${fmtRelTime(data.lastActivity)}`)
352
+ : null,
353
+ data.agents > 0
354
+ ? React.createElement(Text, { color: "yellow" }, ` Agents: ${data.agents} running`)
355
+ : null,
356
+ ),
357
+ data.lastMessage
358
+ ? React.createElement(
359
+ Box, { paddingLeft: 2 },
360
+ React.createElement(Text, { dimColor: true }, "└── "),
361
+ React.createElement(Text, { wrap: "wrap" }, truncate(data.lastMessage, mainWidth - 10)),
362
+ )
363
+ : null,
364
+ data.workMd
365
+ ? React.createElement(
366
+ Box, { paddingLeft: 2 },
367
+ React.createElement(Text, { dimColor: true }, "└── work.md: "),
368
+ React.createElement(Text, { dimColor: true, italic: true }, `"${truncate(data.workMd, mainWidth - 20)}"`),
369
+ )
370
+ : null,
371
+ );
372
+ }),
175
373
  );
176
374
  }
177
375
 
178
- /**
179
- * ActionTimeline — shows a running log of tool actions
180
- */
181
- function ActionTimeline({ events }) {
182
- if (!events || events.length === 0) return null;
183
-
184
- const TOOL_ICONS = {
185
- read_file: "📖",
186
- write_file: "✏️",
187
- file_edit: "✏️",
188
- run_command: "🔧",
189
- git: "🌿",
190
- web_search: "🔍",
191
- web_fetch: "🌐",
192
- list_directory: "📁",
193
- spawn_subagent: "🤖",
194
- memory_save: "💾",
376
+ // ─── Agents View ─────────────────────────────────────────────────────────────
377
+
378
+ function AgentsView({ agents }) {
379
+ const statusColor = (s) => {
380
+ if (s === "running") return "green";
381
+ if (s === "pending") return "yellow";
382
+ if (s === "completed" || s === "done") return "cyan";
383
+ return "red";
384
+ };
385
+ const statusIcon = (s) => {
386
+ if (s === "running") return "🟢";
387
+ if (s === "pending") return "🟡";
388
+ if (s === "completed" || s === "done") return "✅";
389
+ return "";
195
390
  };
196
391
 
197
392
  return React.createElement(
198
- Box, { flexDirection: "column", paddingLeft: 1, marginTop: 0 },
199
- React.createElement(Text, { bold: true, dimColor: true }, "── Actions ──"),
200
- ...events.slice(-8).map((evt, i) => {
201
- const icon = TOOL_ICONS[evt.toolName] ?? "🔧";
202
- const ts = evt.timestamp
203
- ? new Date(evt.timestamp).toLocaleTimeString("en-US", { hour12: false })
204
- : "";
205
- const status = evt.denied ? "❌" : evt.dryRun ? "👁️" : evt.success ? "✅" : "⏳";
206
- const arg = evt.primaryArg ? ` → ${evt.primaryArg.slice(0, 30)}` : "";
207
- const approvedTag = evt.approved === true ? " (approved ✅)" : evt.approved === false ? " (denied ❌)" : "";
393
+ Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
394
+ React.createElement(Text, { bold: true, color: "green" }, "Active Agents"),
395
+ React.createElement(Box, { height: 1 }),
396
+ agents.length === 0
397
+ ? React.createElement(Text, { dimColor: true }, "No agents running.")
398
+ : React.createElement(Box, { flexDirection: "column" },
399
+ ...agents.map((a, i) => React.createElement(
400
+ Box, { key: i, flexDirection: "column", marginBottom: 1 },
401
+ React.createElement(Box, {},
402
+ React.createElement(Text, {}, `${statusIcon(a.status)} `),
403
+ React.createElement(Text, { bold: true, color: statusColor(a.status) }, a.label ?? a.id),
404
+ React.createElement(Text, { dimColor: true }, ` [${a.model ?? "?"}]`),
405
+ a.createdAt
406
+ ? React.createElement(Text, { dimColor: true }, ` started ${fmtRelTime(a.createdAt)}`)
407
+ : null,
408
+ ),
409
+ a.task
410
+ ? React.createElement(
411
+ Box, { paddingLeft: 3 },
412
+ React.createElement(Text, { dimColor: true, wrap: "wrap" }, truncate(a.task, 60)),
413
+ )
414
+ : null,
415
+ ))),
416
+ );
417
+ }
418
+
419
+ // ─── Memory View ──────────────────────────────────────────────────────────────
420
+
421
+ function MemoryView({ memoryFiles, memoryQuery, onQueryChange }) {
422
+ const filtered = memoryQuery
423
+ ? memoryFiles.filter(f =>
424
+ f.key.toLowerCase().includes(memoryQuery.toLowerCase()) ||
425
+ (f.preview ?? "").toLowerCase().includes(memoryQuery.toLowerCase()))
426
+ : memoryFiles;
427
+
428
+ return React.createElement(
429
+ Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
430
+ React.createElement(Text, { bold: true, color: "green" }, "Memory Files"),
431
+ React.createElement(Box, { marginTop: 1 },
432
+ React.createElement(Text, { dimColor: true }, "Search: "),
433
+ React.createElement(TextInput, {
434
+ value: memoryQuery,
435
+ onChange: onQueryChange,
436
+ placeholder: "filter files...",
437
+ }),
438
+ ),
439
+ React.createElement(Box, { height: 1 }),
440
+ filtered.length === 0
441
+ ? React.createElement(Text, { dimColor: true }, "No memory files found.")
442
+ : React.createElement(Box, { flexDirection: "column" },
443
+ ...filtered.slice(0, 20).map((f, i) => React.createElement(
444
+ Box, { key: i, flexDirection: "column", marginBottom: 0 },
445
+ React.createElement(Box, {},
446
+ React.createElement(Text, { color: "cyan" }, f.key),
447
+ f.size
448
+ ? React.createElement(Text, { dimColor: true }, ` (${(f.size / 1024).toFixed(1)}KB)`)
449
+ : null,
450
+ f.updatedAt
451
+ ? React.createElement(Text, { dimColor: true }, ` ${fmtRelTime(f.updatedAt)}`)
452
+ : null,
453
+ ),
454
+ f.preview
455
+ ? React.createElement(
456
+ Box, { paddingLeft: 2 },
457
+ React.createElement(Text, { dimColor: true, wrap: "wrap" }, truncate(f.preview, 70)),
458
+ )
459
+ : null,
460
+ ))),
461
+ );
462
+ }
208
463
 
464
+ // ─── Audit View ───────────────────────────────────────────────────────────────
465
+
466
+ function AuditView({ timeline, mainWidth }) {
467
+ return React.createElement(
468
+ Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
469
+ React.createElement(Text, { bold: true, color: "green" }, "Action Timeline"),
470
+ React.createElement(Box, { height: 1 }),
471
+ timeline.length === 0
472
+ ? React.createElement(Text, { dimColor: true }, "No actions recorded yet.")
473
+ : React.createElement(Box, { flexDirection: "column" },
474
+ ...timeline.slice().reverse().slice(0, 30).map((evt, i) => {
475
+ const icon = TOOL_ICONS[evt.toolName] ?? "🔧";
476
+ const statusIcon = evt.denied ? "❌" : evt.dryRun ? "👁️" : evt.success ? "✅" : "⏳";
477
+ const color = evt.denied ? "red" : evt.dryRun ? "blue" : evt.success ? "green" : "yellow";
478
+ const ts = fmtTime(evt.timestamp);
479
+ const arg = evt.primaryArg ? ` → ${truncate(evt.primaryArg, 30)}` : "";
480
+ return React.createElement(
481
+ Box, { key: i },
482
+ React.createElement(Text, { dimColor: true }, `${ts} `),
483
+ React.createElement(Text, {}, `${icon} `),
484
+ React.createElement(Text, { color: "cyan" }, evt.toolName),
485
+ React.createElement(Text, { dimColor: true }, arg),
486
+ React.createElement(Text, { color }, ` ${statusIcon}`),
487
+ );
488
+ })),
489
+ );
490
+ }
491
+
492
+ // ─── Settings View ────────────────────────────────────────────────────────────
493
+
494
+ function SettingsView({ engine }) {
495
+ const [config, setConfig] = useState(null);
496
+
497
+ useEffect(() => {
498
+ (async () => {
499
+ try {
500
+ const { loadConfig } = await import("../core/config.mjs");
501
+ const cfg = await loadConfig();
502
+ setConfig(cfg);
503
+ } catch {}
504
+ })();
505
+ }, []);
506
+
507
+ const rows = config ? [
508
+ ["Provider", engine.provider ?? "?"],
509
+ ["Model", engine.model ?? "?"],
510
+ ["Workstream", engine.activeWorkstream ?? "default"],
511
+ ["Permission mode", engine.permissions?.getPolicy?.("run_command") ?? "approve"],
512
+ ["Version", "2.0.0"],
513
+ ["Config", path.join(os.homedir(), ".wispy", "config.json")],
514
+ ] : [];
515
+
516
+ return React.createElement(
517
+ Box, { flexDirection: "column", flexGrow: 1, paddingX: 1 },
518
+ React.createElement(Text, { bold: true, color: "green" }, "Settings"),
519
+ React.createElement(Box, { height: 1 }),
520
+ ...rows.map(([k, v], i) => React.createElement(
521
+ Box, { key: i, marginBottom: 0 },
522
+ React.createElement(Text, { color: "cyan", bold: true }, `${k}: `),
523
+ React.createElement(Text, {}, v),
524
+ )),
525
+ React.createElement(Box, { height: 1 }),
526
+ React.createElement(Text, { dimColor: true }, "Commands: /model <name> /clear /timeline /cost /help"),
527
+ );
528
+ }
529
+
530
+ // ─── Action Timeline Bar ─────────────────────────────────────────────────────
531
+
532
+ function TimelineBar({ events }) {
533
+ const last = events.slice(-TIMELINE_LINES);
534
+ if (last.length === 0) return null;
535
+
536
+ return React.createElement(
537
+ Box, {
538
+ flexDirection: "column",
539
+ borderStyle: "single",
540
+ borderColor: "gray",
541
+ borderBottom: false,
542
+ borderLeft: false,
543
+ borderRight: false,
544
+ paddingX: 1,
545
+ },
546
+ ...last.map((evt, i) => {
547
+ const icon = TOOL_ICONS[evt.toolName] ?? "🔧";
548
+ const statusIcon = evt.denied ? "❌" : evt.dryRun ? "👁️" : evt.success === null ? "⏳" : evt.success ? "✅" : "❌";
549
+ const color = evt.denied ? "red" : evt.success === null ? "yellow" : evt.success ? "green" : "red";
550
+ const ts = fmtTime(evt.timestamp);
551
+ const arg = evt.primaryArg ? ` ${truncate(evt.primaryArg, 28)}` : "";
209
552
  return React.createElement(
210
553
  Box, { key: i },
211
554
  React.createElement(Text, { dimColor: true }, `${ts} `),
212
555
  React.createElement(Text, {}, `${icon} `),
213
556
  React.createElement(Text, { color: "cyan" }, evt.toolName),
214
- React.createElement(Text, { dimColor: true }, `${arg}${approvedTag} ${status}`),
557
+ React.createElement(Text, { dimColor: true }, arg),
558
+ React.createElement(Text, { color }, ` ${statusIcon}`),
215
559
  );
216
560
  }),
217
561
  );
218
562
  }
219
563
 
220
- /**
221
- * ApprovalDialog — intercepts "approve" policy tools
222
- *
223
- * Resolves the pending approval and calls onApprove / onDeny / onDryRun
224
- */
564
+ // ─── Approval Dialog ─────────────────────────────────────────────────────────
565
+
225
566
  function ApprovalDialog({ action, onApprove, onDeny, onDryRun }) {
226
567
  const riskMap = {
227
- run_command: "HIGH",
228
- git: "HIGH",
229
- keychain: "HIGH",
230
- delete_file: "HIGH",
231
- write_file: "MEDIUM",
232
- file_edit: "MEDIUM",
568
+ run_command: "HIGH", git: "HIGH", keychain: "HIGH", delete_file: "HIGH",
569
+ write_file: "MEDIUM", file_edit: "MEDIUM", node_execute: "HIGH",
233
570
  };
234
571
  const risk = riskMap[action?.toolName] ?? "MEDIUM";
235
- const riskColor = risk === "HIGH" ? "red" : risk === "MEDIUM" ? "yellow" : "green";
572
+ const riskColor = risk === "HIGH" ? "red" : "yellow";
236
573
 
237
- // Format args summary
238
- let argSummary = "";
239
574
  const args = action?.args ?? {};
240
- if (args.command) argSummary = `Command: ${args.command}`;
241
- else if (args.path) argSummary = `Path: ${args.path}`;
242
- else argSummary = JSON.stringify(args).slice(0, 60);
575
+ let argSummary = "";
576
+ if (args.command) argSummary = truncate(args.command, 50);
577
+ else if (args.path) argSummary = truncate(args.path, 50);
578
+ else argSummary = truncate(JSON.stringify(args), 50);
243
579
 
244
580
  useInput((input, key) => {
245
581
  const ch = input.toLowerCase();
246
- if (ch === "y") { onApprove?.(); }
247
- else if (ch === "n" || key.escape) { onDeny?.(); }
248
- else if (ch === "d") { onDryRun?.(); }
582
+ if (ch === "y") onApprove?.();
583
+ else if (ch === "n" || key.escape) onDeny?.();
584
+ else if (ch === "d") onDryRun?.();
249
585
  });
250
586
 
251
587
  return React.createElement(
@@ -256,181 +592,351 @@ function ApprovalDialog({ action, onApprove, onDeny, onDryRun }) {
256
592
  paddingX: 2,
257
593
  paddingY: 1,
258
594
  marginY: 1,
595
+ marginX: 2,
259
596
  },
260
597
  React.createElement(Text, { bold: true, color: "yellow" }, "⚠️ Permission Required"),
261
- React.createElement(Text, {}, ""),
262
- React.createElement(
263
- Box, {},
598
+ React.createElement(Box, { height: 1 }),
599
+ React.createElement(Box, {},
264
600
  React.createElement(Text, { dimColor: true }, "Tool: "),
265
- React.createElement(Text, { bold: true }, action?.toolName ?? "?"),
266
- ),
267
- argSummary ? React.createElement(
268
- Box, {},
601
+ React.createElement(Text, { bold: true }, action?.toolName ?? "?")),
602
+ argSummary ? React.createElement(Box, {},
269
603
  React.createElement(Text, { dimColor: true }, "Action: "),
270
- React.createElement(Text, {}, argSummary),
271
- ) : null,
272
- React.createElement(
273
- Box, {},
604
+ React.createElement(Text, {}, argSummary)) : null,
605
+ React.createElement(Box, {},
274
606
  React.createElement(Text, { dimColor: true }, "Risk: "),
275
- React.createElement(Text, { bold: true, color: riskColor }, risk),
276
- ),
277
- React.createElement(Text, {}, ""),
278
- React.createElement(
279
- Box, { gap: 2 },
607
+ React.createElement(Text, { bold: true, color: riskColor }, risk)),
608
+ React.createElement(Box, { height: 1 }),
609
+ React.createElement(Box, { gap: 2 },
280
610
  React.createElement(Text, { color: "green", bold: true }, "[Y] Approve"),
281
611
  React.createElement(Text, { color: "red", bold: true }, "[N] Deny"),
282
- React.createElement(Text, { color: "cyan", bold: true }, "[D] Dry-run first"),
283
- ),
612
+ React.createElement(Text, { color: "cyan", bold: true }, "[D] Dry-run")),
284
613
  );
285
614
  }
286
615
 
287
- // -----------------------------------------------------------------------
288
- // Status Bar (enhanced)
289
- // -----------------------------------------------------------------------
290
-
291
- function StatusBar({ provider, model, workstream, tokens, cost, permMode, pendingApprovals, auditCount, workMdLoaded }) {
292
- const providerLabel = PROVIDERS[provider]?.label ?? provider ?? "?";
293
- const costStr = cost > 0 ? `$${cost.toFixed(4)}` : "$0.0000";
294
- const tokStr = tokens > 0 ? `${tokens}t` : "0t";
295
- const permIcon = permMode === "approve" ? "🔐" : permMode === "notify" ? "📋" : "✅";
616
+ // ─── Help Overlay ─────────────────────────────────────────────────────────────
296
617
 
297
- return React.createElement(
298
- Box, { paddingX: 1, backgroundColor: "blue", width: "100%", flexWrap: "wrap" },
299
- React.createElement(Text, { color: "white", bold: true }, "🌿 Wispy"),
300
- React.createElement(Text, { color: "white" }, " "),
301
- React.createElement(Text, { color: "cyan" }, providerLabel),
302
- React.createElement(Text, { color: "white" }, " / "),
303
- React.createElement(Text, { color: "yellow" }, model ?? "?"),
304
- React.createElement(Text, { color: "white" }, " · ws: "),
305
- React.createElement(Text, { color: "green" }, workstream ?? "default"),
306
- workMdLoaded ? React.createElement(Text, { color: "green" }, " 📝") : null,
307
- React.createElement(Text, { color: "white" }, " · "),
308
- React.createElement(Text, { color: "white", dimColor: true }, `${tokStr} ${costStr}`),
309
- React.createElement(Text, { color: "white" }, " · "),
310
- React.createElement(Text, { color: "white" }, `${permIcon} ${permMode ?? "auto"}`),
311
- pendingApprovals > 0
312
- ? React.createElement(Text, { color: "yellow", bold: true }, ` ⚠️ ${pendingApprovals} pending`)
313
- : null,
314
- auditCount > 0
315
- ? React.createElement(Text, { dimColor: true, color: "white" }, ` ${auditCount} events`)
316
- : null,
317
- );
318
- }
618
+ function HelpOverlay({ onClose }) {
619
+ useInput((input, key) => {
620
+ if (input === "?" || input === "q" || key.escape) onClose?.();
621
+ });
319
622
 
320
- // -----------------------------------------------------------------------
321
- // Tool line & Message components
322
- // -----------------------------------------------------------------------
623
+ const shortcuts = [
624
+ ["Tab", "Switch view (chat/overview/agents/memory/audit/settings)"],
625
+ ["1-9", "Switch workstream by number"],
626
+ ["n", "New workstream (in chat: prompts for name)"],
627
+ ["o", "Overview view"],
628
+ ["a", "Agents view"],
629
+ ["m", "Memory view"],
630
+ ["u", "Audit view"],
631
+ ["s", "Settings view"],
632
+ ["t", "Toggle timeline expansion"],
633
+ ["Ctrl+L", "Clear chat"],
634
+ ["q / Ctrl+C", "Quit"],
635
+ ["?", "Toggle this help"],
636
+ ["/help", "Commands help (in input)"],
637
+ ["/model <n>", "Switch model"],
638
+ ["/clear", "Clear conversation"],
639
+ ["/overview", "Overview view"],
640
+ ];
323
641
 
324
- function ToolLine({ name, args, result }) {
325
- const argsStr = Object.values(args ?? {}).join(", ").slice(0, 40);
326
- const status = result ? (result.success ? "✅" : "❌") : "⏳";
327
642
  return React.createElement(
328
- Box, { paddingLeft: 2 },
329
- React.createElement(Text, { color: "cyan", dimColor: true }, `${status} 🔧 ${name}`),
330
- React.createElement(Text, { dimColor: true }, `(${argsStr})`),
643
+ Box, {
644
+ flexDirection: "column",
645
+ borderStyle: "round",
646
+ borderColor: "cyan",
647
+ paddingX: 2,
648
+ paddingY: 1,
649
+ marginY: 1,
650
+ marginX: 2,
651
+ },
652
+ React.createElement(Text, { bold: true, color: "cyan" }, "🌿 Wispy Keyboard Shortcuts"),
653
+ React.createElement(Box, { height: 1 }),
654
+ ...shortcuts.map(([key, desc], i) => React.createElement(
655
+ Box, { key: i },
656
+ React.createElement(Text, { color: "yellow", bold: true }, key.padEnd(15)),
657
+ React.createElement(Text, { dimColor: true }, desc),
658
+ )),
659
+ React.createElement(Box, { height: 1 }),
660
+ React.createElement(Text, { dimColor: true }, "Press ? or q to close"),
331
661
  );
332
662
  }
333
663
 
334
- function Message({ msg }) {
335
- if (msg.role === "user") {
336
- return React.createElement(
337
- Box, { flexDirection: "column", paddingLeft: 1 },
338
- React.createElement(Box, {},
339
- React.createElement(Text, { color: "green", bold: true }, "› "),
340
- React.createElement(Text, { color: "white" }, msg.content)));
341
- }
342
- if (msg.role === "tool_call") {
343
- return React.createElement(
344
- Box, { flexDirection: "column" },
345
- React.createElement(ToolLine, { name: msg.name, args: msg.args, result: msg.result }),
346
- msg.receipt ? React.createElement(ReceiptView, { receipt: msg.receipt }) : null,
347
- );
348
- }
349
- if (msg.role === "assistant") {
350
- return React.createElement(
351
- Box, { flexDirection: "column", paddingLeft: 1, marginTop: 0 },
352
- React.createElement(Box, { flexDirection: "column" },
353
- React.createElement(Text, { color: "cyan", bold: true }, "🌿 "),
354
- ...renderMarkdown(msg.content)),
355
- React.createElement(Box, { height: 1 }));
356
- }
357
- return null;
358
- }
664
+ // ─── Input Area ───────────────────────────────────────────────────────────────
359
665
 
360
- // -----------------------------------------------------------------------
361
- // Input Area
362
- // -----------------------------------------------------------------------
666
+ function InputArea({ value, onChange, onSubmit, loading, workstream, view }) {
667
+ const prompt = `${workstream} › `;
668
+ const placeholder = loading
669
+ ? "waiting for response..."
670
+ : view !== "chat"
671
+ ? `${view} mode — press Tab for chat`
672
+ : "Type a message… (/help for commands)";
363
673
 
364
- function InputArea({ value, onChange, onSubmit, loading }) {
365
674
  return React.createElement(
366
- Box, { borderStyle: "single", borderColor: loading ? "yellow" : "green", paddingX: 1 },
675
+ Box, {
676
+ borderStyle: "single",
677
+ borderColor: loading ? "yellow" : view === "chat" ? "green" : "gray",
678
+ paddingX: 1,
679
+ },
367
680
  loading
368
681
  ? React.createElement(Box, {},
369
682
  React.createElement(Spinner, { type: "dots" }),
370
- React.createElement(Text, { color: "yellow" }, " thinking..."))
683
+ React.createElement(Text, { color: "yellow" }, ` ${prompt}thinking...`))
371
684
  : React.createElement(Box, {},
372
- React.createElement(Text, { color: "green" }, "› "),
685
+ React.createElement(Text, { color: view === "chat" ? "green" : "gray" }, prompt),
373
686
  React.createElement(TextInput, {
374
687
  value, onChange, onSubmit,
375
- placeholder: "Type a message... (Ctrl+C to exit, /help for commands)",
376
- })));
688
+ placeholder,
689
+ })),
690
+ );
377
691
  }
378
692
 
379
- // -----------------------------------------------------------------------
380
- // Main App Component
381
- // -----------------------------------------------------------------------
693
+ // ─── Persistence helpers ──────────────────────────────────────────────────────
694
+
695
+ async function loadConversation(workstream) {
696
+ const file = path.join(CONVERSATIONS_DIR, `${workstream}.json`);
697
+ try {
698
+ const raw = await readFile(file, "utf8");
699
+ return JSON.parse(raw);
700
+ } catch { return []; }
701
+ }
382
702
 
383
- function WispyTUI({ engine }) {
703
+ async function saveConversation(workstream, messages) {
704
+ await mkdir(CONVERSATIONS_DIR, { recursive: true });
705
+ const file = path.join(CONVERSATIONS_DIR, `${workstream}.json`);
706
+ await writeFile(file, JSON.stringify(messages.slice(-50), null, 2) + "\n", "utf8");
707
+ }
708
+
709
+ // ─── Sidebar Data Loaders ─────────────────────────────────────────────────────
710
+
711
+ async function loadWorkstreams() {
712
+ try {
713
+ const wsDir = path.join(WISPY_DIR, "workstreams");
714
+ const dirs = await readdir(wsDir);
715
+ const result = [];
716
+ for (const d of dirs) {
717
+ try {
718
+ const s = await stat(path.join(wsDir, d));
719
+ if (s.isDirectory()) result.push(d);
720
+ } catch {}
721
+ }
722
+ return result.length ? result : ["default"];
723
+ } catch { return ["default"]; }
724
+ }
725
+
726
+ async function loadMemoryFiles() {
727
+ try {
728
+ const files = await readdir(MEMORY_DIR);
729
+ const result = [];
730
+ for (const f of files) {
731
+ if (!f.endsWith(".md")) continue;
732
+ const fp = path.join(MEMORY_DIR, f);
733
+ try {
734
+ const s = await stat(fp);
735
+ const raw = await readFile(fp, "utf8");
736
+ result.push({
737
+ key: f.replace(".md", ""),
738
+ preview: raw.split("\n").find(l => l.trim() && !l.startsWith("_")) ?? "",
739
+ size: s.size,
740
+ updatedAt: s.mtime.toISOString(),
741
+ });
742
+ } catch {}
743
+ }
744
+ return result;
745
+ } catch { return []; }
746
+ }
747
+
748
+ async function loadCronCount() {
749
+ try {
750
+ const jobsFile = path.join(WISPY_DIR, "cron", "jobs.json");
751
+ const raw = await readFile(jobsFile, "utf8");
752
+ const jobs = JSON.parse(raw);
753
+ return Array.isArray(jobs) ? jobs.filter(j => j.enabled !== false).length : 0;
754
+ } catch { return 0; }
755
+ }
756
+
757
+ async function loadSyncStatus() {
758
+ try {
759
+ const syncFile = path.join(WISPY_DIR, "sync.json");
760
+ const raw = await readFile(syncFile, "utf8");
761
+ const cfg = JSON.parse(raw);
762
+ return cfg.auto ? "auto" : "manual";
763
+ } catch { return "off"; }
764
+ }
765
+
766
+ async function loadOverviewData(workstreams) {
767
+ const result = {};
768
+ for (const ws of workstreams) {
769
+ try {
770
+ const convFile = path.join(CONVERSATIONS_DIR, `${ws}.json`);
771
+ const raw = await readFile(convFile, "utf8");
772
+ const conv = JSON.parse(raw);
773
+ const lastMsg = conv.filter(m => m.role === "assistant").pop();
774
+ const lastUser = conv.filter(m => m.role === "user").pop();
775
+ const lastActivityFile = await stat(convFile).catch(() => null);
776
+ result[ws] = {
777
+ lastActivity: lastActivityFile?.mtime?.toISOString() ?? null,
778
+ lastMessage: lastMsg?.content?.slice(0, 80) ?? lastUser?.content?.slice(0, 80) ?? null,
779
+ agents: 0,
780
+ };
781
+ } catch {
782
+ result[ws] = { lastActivity: null, lastMessage: null, agents: 0 };
783
+ }
784
+
785
+ // Try work.md
786
+ try {
787
+ const workMdPath = path.join(WISPY_DIR, "workstreams", ws, "work.md");
788
+ const wmd = await readFile(workMdPath, "utf8");
789
+ const firstLine = wmd.split("\n").find(l => l.trim());
790
+ result[ws].workMd = firstLine?.slice(0, 60);
791
+ } catch {}
792
+ }
793
+ return result;
794
+ }
795
+
796
+ // ─── Main App ─────────────────────────────────────────────────────────────────
797
+
798
+ function WispyWorkspaceApp({ engine, initialWorkstream }) {
384
799
  const { exit } = useApp();
800
+ const { stdout } = useStdout();
801
+
802
+ // Terminal dimensions
803
+ const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80);
804
+ const [termHeight, setTermHeight] = useState(stdout?.rows ?? 24);
805
+
806
+ // View state
807
+ const [view, setView] = useState("chat");
808
+ const [showHelp, setShowHelp] = useState(false);
809
+ const [showTimeline, setShowTimeline] = useState(false);
810
+
811
+ // Workstream state
812
+ const [activeWorkstream, setActiveWorkstream] = useState(initialWorkstream);
813
+ const [workstreams, setWorkstreams] = useState([initialWorkstream]);
385
814
 
815
+ // Chat state
386
816
  const [messages, setMessages] = useState([]);
387
817
  const [inputValue, setInputValue] = useState("");
388
818
  const [loading, setLoading] = useState(false);
819
+
820
+ // Engine stats
389
821
  const [model, setModel] = useState(engine.model ?? "?");
822
+ const [provider, setProvider] = useState(engine.provider ?? "?");
390
823
  const [totalTokens, setTotalTokens] = useState(0);
391
824
  const [totalCost, setTotalCost] = useState(0);
825
+ const [syncStatus, setSyncStatus] = useState("off");
392
826
 
393
- // Trust UX state
394
- const [pendingApproval, setPendingApproval] = useState(null); // { action, resolve }
395
- const [actionTimeline, setActionTimeline] = useState([]);
396
- const [auditCount, setAuditCount] = useState(0);
397
- const [workMdLoaded, setWorkMdLoaded] = useState(false);
827
+ // Trust UX
828
+ const [pendingApproval, setPendingApproval] = useState(null);
829
+ const [timeline, setTimeline] = useState([]);
398
830
 
399
- // Conversation history for persistence
400
- const conversationRef = useRef([]);
831
+ // Sidebar data
832
+ const [agents, setAgents] = useState([]);
833
+ const [memoryFiles, setMemoryFiles] = useState([]);
834
+ const [memoryQuery, setMemoryQuery] = useState("");
835
+ const [cronCount, setCronCount] = useState(0);
836
+ const [userModelLoaded, setUserModelLoaded] = useState(false);
837
+
838
+ // Overview data
839
+ const [overviewData, setOverviewData] = useState({});
840
+
841
+ // Refs
401
842
  const approvalResolverRef = useRef(null);
843
+ const conversationRef = useRef([]);
844
+ const engineRef = useRef(engine);
845
+
846
+ // ── Terminal resize ──
847
+ useEffect(() => {
848
+ const onResize = () => {
849
+ setTermWidth(stdout?.columns ?? 80);
850
+ setTermHeight(stdout?.rows ?? 24);
851
+ };
852
+ stdout?.on?.("resize", onResize);
853
+ return () => stdout?.off?.("resize", onResize);
854
+ }, [stdout]);
855
+
856
+ // ── Load initial conversation ──
857
+ useEffect(() => {
858
+ loadConversation(initialWorkstream).then(msgs => {
859
+ conversationRef.current = msgs;
860
+ setMessages(msgs.map(m => ({ ...m, _loaded: true })));
861
+ });
862
+ }, [initialWorkstream]);
863
+
864
+ // ── Load sidebar data ──
865
+ useEffect(() => {
866
+ const loadAll = async () => {
867
+ const [wsList, memFiles, cron, sync] = await Promise.all([
868
+ loadWorkstreams(),
869
+ loadMemoryFiles(),
870
+ loadCronCount(),
871
+ loadSyncStatus(),
872
+ ]);
873
+
874
+ // Make sure active workstream is in list
875
+ const wsSet = new Set(wsList);
876
+ wsSet.add(initialWorkstream);
877
+ const wsFinal = Array.from(wsSet);
878
+
879
+ setWorkstreams(wsFinal);
880
+ setMemoryFiles(memFiles);
881
+ setCronCount(cron);
882
+ setSyncStatus(sync);
883
+ setUserModelLoaded(memFiles.some(f => f.key === "user"));
884
+ };
885
+ loadAll();
886
+
887
+ // Refresh periodically
888
+ const interval = setInterval(loadAll, 15_000);
889
+ return () => clearInterval(interval);
890
+ }, [initialWorkstream]);
891
+
892
+ // ── Load overview data ──
893
+ useEffect(() => {
894
+ const refresh = async () => {
895
+ const data = await loadOverviewData(workstreams);
896
+ setOverviewData(data);
897
+ };
898
+ refresh();
899
+ const interval = setInterval(refresh, 20_000);
900
+ return () => clearInterval(interval);
901
+ }, [workstreams]);
402
902
 
403
- // Wire harness events to TUI state
903
+ // ── Load agents ──
904
+ useEffect(() => {
905
+ const loadAgents = async () => {
906
+ try {
907
+ const agentsFile = path.join(WISPY_DIR, "agents.json");
908
+ const raw = await readFile(agentsFile, "utf8");
909
+ const all = JSON.parse(raw);
910
+ setAgents(all.filter(a =>
911
+ a.status === "running" || a.status === "pending"
912
+ ).slice(-5));
913
+ } catch { setAgents([]); }
914
+ };
915
+ loadAgents();
916
+ const interval = setInterval(loadAgents, 10_000);
917
+ return () => clearInterval(interval);
918
+ }, []);
919
+
920
+ // ── Wire harness events ──
404
921
  useEffect(() => {
405
922
  const harness = engine.harness;
406
923
  if (!harness) return;
407
924
 
408
925
  const onStart = ({ toolName, args }) => {
409
- const primaryArg = args?.path ?? args?.command ?? args?.query ?? args?.task ?? "";
410
- setActionTimeline(prev => [...prev.slice(-20), {
411
- toolName,
412
- args,
413
- primaryArg,
926
+ const primaryArg = args?.path ?? args?.command ?? args?.query ?? args?.task ?? args?.key ?? "";
927
+ setTimeline(prev => [...prev.slice(-50), {
928
+ toolName, args, primaryArg,
414
929
  timestamp: new Date().toISOString(),
415
- success: null,
416
- denied: false,
417
- dryRun: false,
930
+ success: null, denied: false, dryRun: false,
418
931
  }]);
419
- setAuditCount(c => c + 1);
420
932
  };
421
933
 
422
934
  const onComplete = ({ toolName, receipt }) => {
423
- setActionTimeline(prev => {
935
+ setTimeline(prev => {
424
936
  const updated = [...prev];
425
- // Update the last entry for this tool
426
937
  for (let i = updated.length - 1; i >= 0; i--) {
427
938
  if (updated[i].toolName === toolName && updated[i].success === null) {
428
- updated[i] = {
429
- ...updated[i],
430
- success: receipt?.success ?? true,
431
- approved: receipt?.approved,
432
- dryRun: receipt?.dryRun ?? false,
433
- };
939
+ updated[i] = { ...updated[i], success: receipt?.success ?? true, dryRun: receipt?.dryRun ?? false };
434
940
  break;
435
941
  }
436
942
  }
@@ -438,8 +944,8 @@ function WispyTUI({ engine }) {
438
944
  });
439
945
  };
440
946
 
441
- const onDenied = ({ toolName, receipt }) => {
442
- setActionTimeline(prev => {
947
+ const onDenied = ({ toolName }) => {
948
+ setTimeline(prev => {
443
949
  const updated = [...prev];
444
950
  for (let i = updated.length - 1; i >= 0; i--) {
445
951
  if (updated[i].toolName === toolName && updated[i].success === null) {
@@ -454,7 +960,6 @@ function WispyTUI({ engine }) {
454
960
  harness.on("tool:start", onStart);
455
961
  harness.on("tool:complete", onComplete);
456
962
  harness.on("tool:denied", onDenied);
457
-
458
963
  return () => {
459
964
  harness.off("tool:start", onStart);
460
965
  harness.off("tool:complete", onComplete);
@@ -462,14 +967,7 @@ function WispyTUI({ engine }) {
462
967
  };
463
968
  }, [engine]);
464
969
 
465
- // Check if work.md is loaded
466
- useEffect(() => {
467
- engine._loadWorkMd?.().then(content => {
468
- setWorkMdLoaded(!!content);
469
- }).catch(() => {});
470
- }, [engine]);
471
-
472
- // Wire approval handler to TUI
970
+ // ── Wire approval handler ──
473
971
  useEffect(() => {
474
972
  engine.permissions.setApprovalHandler(async (action) => {
475
973
  return new Promise((resolve) => {
@@ -477,12 +975,69 @@ function WispyTUI({ engine }) {
477
975
  setPendingApproval({ action });
478
976
  });
479
977
  });
480
-
481
- return () => {
482
- engine.permissions.setApprovalHandler(null);
483
- };
978
+ return () => { engine.permissions.setApprovalHandler(null); };
484
979
  }, [engine]);
485
980
 
981
+ // ── Keyboard shortcuts ──
982
+ useInput((input, key) => {
983
+ // Don't intercept when typing in input or approval dialog is up
984
+ if (pendingApproval || showHelp) return;
985
+
986
+ // View switches
987
+ if (key.tab && !loading) {
988
+ const idx = VIEWS.indexOf(view);
989
+ setView(VIEWS[(idx + 1) % VIEWS.length]);
990
+ return;
991
+ }
992
+ if (input === "?" && !inputValue) { setShowHelp(true); return; }
993
+ if (input === "o" && !inputValue && !loading) { setView("overview"); return; }
994
+ if (input === "a" && !inputValue && !loading) { setView("agents"); return; }
995
+ if (input === "m" && !inputValue && !loading) { setView("memory"); return; }
996
+ if (input === "u" && !inputValue && !loading) { setView("audit"); return; }
997
+ if (input === "s" && !inputValue && !loading) { setView("settings"); return; }
998
+ if (input === "t" && !inputValue) { setShowTimeline(prev => !prev); return; }
999
+
1000
+ // Ctrl+L = clear chat
1001
+ if (key.ctrl && input === "l") {
1002
+ conversationRef.current = [];
1003
+ setMessages([]);
1004
+ saveConversation(activeWorkstream, []).catch(() => {});
1005
+ return;
1006
+ }
1007
+
1008
+ // Workstream number switch
1009
+ if (/^[1-9]$/.test(input) && !inputValue && !loading) {
1010
+ const idx = parseInt(input) - 1;
1011
+ if (idx < workstreams.length) {
1012
+ switchWorkstream(workstreams[idx]);
1013
+ }
1014
+ return;
1015
+ }
1016
+
1017
+ // q = quit (only when input is empty)
1018
+ if (input === "q" && !inputValue && !loading) {
1019
+ exit();
1020
+ process.exit(0);
1021
+ }
1022
+ });
1023
+
1024
+ // ── Switch workstream ──
1025
+ const switchWorkstream = useCallback(async (ws) => {
1026
+ if (ws === activeWorkstream) return;
1027
+ setActiveWorkstream(ws);
1028
+ setMessages([]);
1029
+ setInputValue("");
1030
+ conversationRef.current = [];
1031
+ const msgs = await loadConversation(ws);
1032
+ conversationRef.current = msgs;
1033
+ setMessages(msgs);
1034
+ setView("chat");
1035
+ engine._activeWorkstream = ws;
1036
+ engine._workMdLoaded = false;
1037
+ engine._workMdContent = null;
1038
+ }, [activeWorkstream, engine]);
1039
+
1040
+ // ── Approval handlers ──
486
1041
  const handleApprove = useCallback(() => {
487
1042
  approvalResolverRef.current?.(true);
488
1043
  approvalResolverRef.current = null;
@@ -496,83 +1051,86 @@ function WispyTUI({ engine }) {
496
1051
  }, []);
497
1052
 
498
1053
  const handleDryRun = useCallback(async () => {
499
- // Deny the real execution, then re-run as dry-run (shown as a system message)
500
1054
  const action = pendingApproval?.action;
501
1055
  approvalResolverRef.current?.(false);
502
1056
  approvalResolverRef.current = null;
503
1057
  setPendingApproval(null);
504
-
505
1058
  if (action) {
506
- // Show dry-run result inline
507
- try {
508
- const { simulateDryRun } = await import("../core/harness.mjs").catch(() => ({}));
509
- const preview = simulateDryRun
510
- ? simulateDryRun(action.toolName, action.args ?? {})
511
- : { preview: `Would execute: ${action.toolName}` };
512
- setMessages(prev => [...prev, {
513
- role: "assistant",
514
- content: `👁️ **Dry-run preview**\n\n\`\`\`\n${preview.preview ?? JSON.stringify(preview, null, 2)}\n\`\`\`\n\n*(Real execution was denied. Approve to run for real.)* 🌿`,
515
- }]);
516
- } catch {}
1059
+ setMessages(prev => [...prev, {
1060
+ role: "assistant",
1061
+ 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.)* 🌿`,
1062
+ }]);
517
1063
  }
518
1064
  }, [pendingApproval]);
519
1065
 
1066
+ // ── Message submit ──
520
1067
  const handleSubmit = useCallback(async (value) => {
521
- // If there's a pending approval, don't submit new messages
522
1068
  if (pendingApproval) return;
523
-
524
1069
  const input = value.trim();
525
1070
  if (!input || loading) return;
526
1071
  setInputValue("");
527
1072
 
1073
+ // Switch to chat if not there
1074
+ if (view !== "chat") setView("chat");
1075
+
528
1076
  // Slash commands
529
1077
  if (input.startsWith("/")) {
530
1078
  const parts = input.split(/\s+/);
531
1079
  const cmd = parts[0].toLowerCase();
1080
+
532
1081
  if (cmd === "/quit" || cmd === "/exit") { exit(); process.exit(0); return; }
533
1082
  if (cmd === "/clear") {
534
1083
  conversationRef.current = [];
535
- setMessages([]);
536
- await saveConversation([]);
537
- setMessages([{ role: "assistant", content: "Conversation cleared. 🌿" }]);
1084
+ setMessages([{ role: "system_info", content: "Conversation cleared. 🌿" }]);
1085
+ await saveConversation(activeWorkstream, []);
538
1086
  return;
539
1087
  }
540
1088
  if (cmd === "/cost") {
541
- setMessages(prev => [...prev, { role: "assistant", content: `Session: ${totalTokens} tokens (~$${totalCost.toFixed(4)}) 🌿` }]);
1089
+ setMessages(prev => [...prev, { role: "system_info", content: `${totalTokens}t · $${totalCost.toFixed(4)}` }]);
542
1090
  return;
543
1091
  }
544
1092
  if (cmd === "/timeline") {
545
- const tl = actionTimeline.map(e => `- ${e.toolName}: ${e.success ? "" : e.denied ? "❌" : "⏳"}`).join("\n");
546
- setMessages(prev => [...prev, { role: "assistant", content: `**Action Timeline:**\n${tl || "(empty)"} 🌿` }]);
1093
+ setView("audit");
547
1094
  return;
548
1095
  }
1096
+ if (cmd === "/overview" || cmd === "/o") { setView("overview"); return; }
1097
+ if (cmd === "/agents" || cmd === "/a") { setView("agents"); return; }
1098
+ if (cmd === "/memories" || cmd === "/m") { setView("memory"); return; }
1099
+ if (cmd === "/audit" || cmd === "/u") { setView("audit"); return; }
1100
+ if (cmd === "/settings" || cmd === "/s") { setView("settings"); return; }
549
1101
  if (cmd === "/model" && parts[1]) {
550
1102
  engine.providers.setModel(parts[1]);
551
1103
  setModel(parts[1]);
552
- setMessages(prev => [...prev, { role: "assistant", content: `Model changed to ${parts[1]} 🌿` }]);
1104
+ setMessages(prev => [...prev, { role: "system_info", content: `Model ${parts[1]} 🌿` }]);
553
1105
  return;
554
1106
  }
555
- if (cmd === "/dryrun") {
556
- setMessages(prev => [...prev, { role: "assistant", content: "Dry-run mode is set per tool call. Use `/dryrun <toolname> <json_args>` to simulate. 🌿" }]);
1107
+ if (cmd === "/workstream" && parts[1]) {
1108
+ await switchWorkstream(parts[1]);
1109
+ return;
1110
+ }
1111
+ if (cmd === "/help") {
1112
+ setShowHelp(true);
1113
+ return;
1114
+ }
1115
+ if (cmd === "/ws" && parts[1]) {
1116
+ await switchWorkstream(parts[1]);
557
1117
  return;
558
1118
  }
559
1119
  }
560
1120
 
561
- setMessages(prev => [...prev, { role: "user", content: input }]);
562
- conversationRef.current.push({ role: "user", content: input });
1121
+ // Add user message
1122
+ const userMsg = { role: "user", content: input };
1123
+ setMessages(prev => [...prev, userMsg]);
1124
+ conversationRef.current.push(userMsg);
563
1125
  setLoading(true);
564
1126
 
565
1127
  try {
566
- let lastReceipt = null;
567
-
568
1128
  const result = await engine.processMessage(null, input, {
569
1129
  onChunk: () => {},
570
1130
  onToolCall: (name, args) => {
571
- const toolMsg = { role: "tool_call", name, args, result: null, receipt: null };
572
- setMessages(prev => [...prev, toolMsg]);
1131
+ setMessages(prev => [...prev, { role: "tool_call", name, args, result: null, receipt: null }]);
573
1132
  },
574
1133
  onToolResult: (name, toolResult) => {
575
- // Update the last tool_call message with result
576
1134
  setMessages(prev => {
577
1135
  const updated = [...prev];
578
1136
  for (let i = updated.length - 1; i >= 0; i--) {
@@ -585,34 +1143,26 @@ function WispyTUI({ engine }) {
585
1143
  });
586
1144
  },
587
1145
  onReceipt: (receipt) => {
588
- lastReceipt = receipt;
589
- // Attach receipt to the last matching tool_call message
590
- if (receipt?.toolName) {
591
- setMessages(prev => {
592
- const updated = [...prev];
593
- for (let i = updated.length - 1; i >= 0; i--) {
594
- if (updated[i].role === "tool_call" && updated[i].name === receipt.toolName) {
595
- updated[i] = { ...updated[i], receipt };
596
- break;
597
- }
1146
+ if (!receipt?.toolName) return;
1147
+ setMessages(prev => {
1148
+ const updated = [...prev];
1149
+ for (let i = updated.length - 1; i >= 0; i--) {
1150
+ if (updated[i].role === "tool_call" && updated[i].name === receipt.toolName) {
1151
+ updated[i] = { ...updated[i], receipt };
1152
+ break;
598
1153
  }
599
- return updated;
600
- });
601
- }
602
- if (receipt?.diff?.unified) {
603
- setWorkMdLoaded(!!engine._workMdContent);
604
- }
1154
+ }
1155
+ return updated;
1156
+ });
605
1157
  },
606
1158
  noSave: true,
607
1159
  });
608
1160
 
609
1161
  const responseText = result.content;
610
1162
 
611
- // Update token tracking
612
- const { input: inputToks = 0, output: outputToks = 0 } = engine.providers.sessionTokens;
1163
+ // Update token/cost tracking
1164
+ const { input: inputToks = 0, output: outputToks = 0 } = engine.providers.sessionTokens ?? {};
613
1165
  setTotalTokens(inputToks + outputToks);
614
-
615
- // Estimate cost
616
1166
  const MODEL_PRICING = {
617
1167
  "gemini-2.5-flash": { input: 0.15, output: 0.60 },
618
1168
  "gemini-2.5-pro": { input: 1.25, output: 10.0 },
@@ -621,81 +1171,111 @@ function WispyTUI({ engine }) {
621
1171
  "gpt-4.1": { input: 2.0, output: 8.0 },
622
1172
  "llama-3.3-70b-versatile": { input: 0.59, output: 0.79 },
623
1173
  "deepseek-chat": { input: 0.27, output: 1.10 },
624
- "llama3.2": { input: 0, output: 0 },
625
1174
  };
626
1175
  const pricing = MODEL_PRICING[model] ?? { input: 1.0, output: 3.0 };
627
1176
  const cost = (inputToks * pricing.input + outputToks * pricing.output) / 1_000_000;
628
1177
  setTotalCost(cost);
1178
+ setModel(engine.model ?? model);
629
1179
 
630
- conversationRef.current.push({ role: "assistant", content: responseText });
631
- setMessages(prev => [...prev, { role: "assistant", content: responseText }]);
632
- await saveConversation(conversationRef.current.filter(m => m.role !== "system"));
1180
+ const assistantMsg = { role: "assistant", content: responseText };
1181
+ conversationRef.current.push(assistantMsg);
1182
+ setMessages(prev => [...prev, assistantMsg]);
1183
+ await saveConversation(activeWorkstream, conversationRef.current.filter(m => m.role !== "system"));
633
1184
  } catch (err) {
634
- setMessages(prev => [...prev, { role: "assistant", content: `❌ Error: ${err.message.slice(0, 200)} 🌿` }]);
1185
+ const errMsg = { role: "assistant", content: `❌ Error: ${err.message.slice(0, 200)} 🌿` };
1186
+ setMessages(prev => [...prev, errMsg]);
635
1187
  } finally {
636
1188
  setLoading(false);
637
1189
  }
638
- }, [loading, model, totalTokens, totalCost, engine, exit, pendingApproval, actionTimeline]);
1190
+ }, [loading, model, totalCost, totalTokens, engine, exit, pendingApproval, activeWorkstream, view, switchWorkstream]);
639
1191
 
640
- const displayMessages = messages.slice(-30);
641
- // Determine permission mode from engine's policies
1192
+ // ── Layout computation ──
1193
+ const showSidebar = termWidth >= 80;
1194
+ const mainWidth = showSidebar ? termWidth - SIDEBAR_WIDTH - 2 : termWidth;
642
1195
  const permMode = engine.permissions?.getPolicy?.("run_command") ?? "approve";
643
1196
 
1197
+ // ── Render ──
1198
+ const mainContent = showHelp
1199
+ ? React.createElement(HelpOverlay, { onClose: () => setShowHelp(false) })
1200
+ : view === "chat"
1201
+ ? React.createElement(ChatView, {
1202
+ messages, loading, pendingApproval,
1203
+ onApprove: handleApprove, onDeny: handleDeny, onDryRun: handleDryRun,
1204
+ mainWidth,
1205
+ })
1206
+ : view === "overview"
1207
+ ? React.createElement(OverviewView, { workstreams, activeWorkstream, overviewData, mainWidth })
1208
+ : view === "agents"
1209
+ ? React.createElement(AgentsView, { agents })
1210
+ : view === "memory"
1211
+ ? React.createElement(MemoryView, { memoryFiles, memoryQuery, onQueryChange: setMemoryQuery })
1212
+ : view === "audit"
1213
+ ? React.createElement(AuditView, { timeline, mainWidth })
1214
+ : view === "settings"
1215
+ ? React.createElement(SettingsView, { engine })
1216
+ : null;
1217
+
644
1218
  return React.createElement(
645
1219
  Box, { flexDirection: "column", height: "100%" },
646
1220
 
647
1221
  // Status bar
648
1222
  React.createElement(StatusBar, {
649
- provider: engine.provider,
650
- model,
651
- workstream: ACTIVE_WORKSTREAM,
1223
+ workstream: activeWorkstream,
1224
+ model, provider,
1225
+ permMode,
1226
+ syncStatus,
652
1227
  tokens: totalTokens,
653
1228
  cost: totalCost,
654
- permMode,
655
1229
  pendingApprovals: pendingApproval ? 1 : 0,
656
- auditCount,
657
- workMdLoaded,
1230
+ view,
1231
+ termWidth,
658
1232
  }),
659
1233
 
660
- // Message list
1234
+ // Main body
661
1235
  React.createElement(
662
- Box, { flexDirection: "column", flexGrow: 1, paddingX: 1, overflowY: "hidden" },
663
- displayMessages.length === 0
664
- ? React.createElement(Box, { marginY: 1 },
665
- React.createElement(Text, { dimColor: true }, " 🌿 Type a message to start chatting. /help for commands, /timeline for action history."))
666
- : displayMessages.map((msg, i) => React.createElement(Message, { key: i, msg })),
1236
+ Box, { flexDirection: "row", flexGrow: 1 },
1237
+
1238
+ // Left sidebar
1239
+ showSidebar
1240
+ ? React.createElement(Sidebar, {
1241
+ workstreams,
1242
+ activeWorkstream,
1243
+ agents,
1244
+ memoryCount: memoryFiles.length,
1245
+ userModelLoaded,
1246
+ cronCount,
1247
+ syncStatus,
1248
+ onSelectWorkstream: switchWorkstream,
1249
+ })
1250
+ : null,
1251
+
1252
+ // Main view
1253
+ React.createElement(
1254
+ Box, { flexDirection: "column", flexGrow: 1 },
1255
+ mainContent,
1256
+ ),
667
1257
  ),
668
1258
 
669
- // Action timeline (always visible at bottom, above input)
670
- actionTimeline.length > 0
671
- ? React.createElement(ActionTimeline, { events: actionTimeline })
672
- : null,
673
-
674
- // Approval dialog (shown above input when pending)
675
- pendingApproval
676
- ? React.createElement(ApprovalDialog, {
677
- action: pendingApproval.action,
678
- onApprove: handleApprove,
679
- onDeny: handleDeny,
680
- onDryRun: handleDryRun,
681
- })
1259
+ // Timeline bar (only in chat view, last 3 events)
1260
+ (showTimeline || view === "chat") && timeline.length > 0
1261
+ ? React.createElement(TimelineBar, { events: timeline })
682
1262
  : null,
683
1263
 
684
- // Input area (hidden when approval dialog is shown)
685
- !pendingApproval
1264
+ // Input area (always at bottom; hidden when help shown)
1265
+ !showHelp
686
1266
  ? React.createElement(InputArea, {
687
1267
  value: inputValue,
688
1268
  onChange: setInputValue,
689
1269
  onSubmit: handleSubmit,
690
1270
  loading,
1271
+ workstream: activeWorkstream,
1272
+ view,
691
1273
  })
692
1274
  : null,
693
1275
  );
694
1276
  }
695
1277
 
696
- // -----------------------------------------------------------------------
697
- // Entry point
698
- // -----------------------------------------------------------------------
1278
+ // ─── Entry point ──────────────────────────────────────────────────────────────
699
1279
 
700
1280
  async function main() {
701
1281
  if (!process.stdin.isTTY) {
@@ -703,8 +1283,7 @@ async function main() {
703
1283
  process.exit(1);
704
1284
  }
705
1285
 
706
- // Initialize engine
707
- const engine = new WispyEngine({ workstream: ACTIVE_WORKSTREAM });
1286
+ const engine = new WispyEngine({ workstream: INITIAL_WORKSTREAM });
708
1287
  const initResult = await engine.init();
709
1288
 
710
1289
  if (!initResult) {
@@ -712,10 +1291,14 @@ async function main() {
712
1291
  process.exit(1);
713
1292
  }
714
1293
 
1294
+ // Clear screen
715
1295
  process.stdout.write("\x1b[2J\x1b[H");
716
1296
 
717
1297
  const { waitUntilExit } = render(
718
- React.createElement(WispyTUI, { engine }),
1298
+ React.createElement(WispyWorkspaceApp, {
1299
+ engine,
1300
+ initialWorkstream: INITIAL_WORKSTREAM,
1301
+ }),
719
1302
  { exitOnCtrlC: true }
720
1303
  );
721
1304
 
@@ -725,5 +1308,6 @@ async function main() {
725
1308
 
726
1309
  main().catch(err => {
727
1310
  console.error("TUI error:", err.message);
1311
+ if (process.env.WISPY_DEBUG) console.error(err.stack);
728
1312
  process.exit(1);
729
1313
  });