wispy-cli 1.3.0 → 2.0.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/bin/wispy.mjs +89 -0
- package/core/deploy.mjs +51 -0
- package/core/engine.mjs +121 -0
- package/core/index.mjs +3 -0
- package/core/migrate.mjs +357 -0
- package/core/skills.mjs +339 -0
- package/core/user-model.mjs +302 -0
- package/lib/channels/email.mjs +187 -0
- package/lib/channels/index.mjs +66 -9
- package/lib/channels/signal.mjs +151 -0
- package/lib/channels/whatsapp.mjs +141 -0
- package/lib/wispy-repl.mjs +102 -24
- package/lib/wispy-tui.mjs +964 -380
- package/package.json +18 -2
package/lib/wispy-tui.mjs
CHANGED
|
@@ -1,68 +1,76 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* wispy-tui.mjs —
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
27
|
+
const INITIAL_WORKSTREAM =
|
|
36
28
|
process.env.WISPY_WORKSTREAM ??
|
|
37
29
|
(wsIdx !== -1 ? rawArgs[wsIdx + 1] : null) ??
|
|
38
30
|
"default";
|
|
39
31
|
|
|
40
|
-
|
|
32
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
41
33
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
34
|
+
const VIEWS = ["chat", "overview", "agents", "memory", "audit", "settings"];
|
|
35
|
+
const SIDEBAR_WIDTH = 16;
|
|
36
|
+
const TIMELINE_LINES = 3;
|
|
45
37
|
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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" },
|
|
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("==="))
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
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, {
|
|
110
|
-
React.createElement(Text, {
|
|
111
|
-
React.createElement(Text, {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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, {
|
|
142
|
-
React.createElement(Text, {
|
|
143
|
-
`${
|
|
144
|
-
React.createElement(Text, { dimColor: true }, `
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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",
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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",
|
|
199
|
-
React.createElement(Text, { bold: true,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 },
|
|
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
|
-
|
|
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
|
-
|
|
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" :
|
|
572
|
+
const riskColor = risk === "HIGH" ? "red" : "yellow";
|
|
236
573
|
|
|
237
|
-
// Format args summary
|
|
238
|
-
let argSummary = "";
|
|
239
574
|
const args = action?.args ?? {};
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
else argSummary =
|
|
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")
|
|
247
|
-
else if (ch === "n" || key.escape)
|
|
248
|
-
else if (ch === "d")
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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, {
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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" },
|
|
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
|
|
376
|
-
}))
|
|
688
|
+
placeholder,
|
|
689
|
+
})),
|
|
690
|
+
);
|
|
377
691
|
}
|
|
378
692
|
|
|
379
|
-
//
|
|
380
|
-
|
|
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
|
|
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
|
|
394
|
-
const [pendingApproval, setPendingApproval] = useState(null);
|
|
395
|
-
const [
|
|
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
|
-
//
|
|
400
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
442
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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: "
|
|
1089
|
+
setMessages(prev => [...prev, { role: "system_info", content: `${totalTokens}t · $${totalCost.toFixed(4)}` }]);
|
|
542
1090
|
return;
|
|
543
1091
|
}
|
|
544
1092
|
if (cmd === "/timeline") {
|
|
545
|
-
|
|
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: "
|
|
1104
|
+
setMessages(prev => [...prev, { role: "system_info", content: `Model → ${parts[1]} 🌿` }]);
|
|
553
1105
|
return;
|
|
554
1106
|
}
|
|
555
|
-
if (cmd === "/
|
|
556
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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,
|
|
1190
|
+
}, [loading, model, totalCost, totalTokens, engine, exit, pendingApproval, activeWorkstream, view, switchWorkstream]);
|
|
639
1191
|
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
650
|
-
model,
|
|
651
|
-
|
|
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
|
-
|
|
657
|
-
|
|
1230
|
+
view,
|
|
1231
|
+
termWidth,
|
|
658
1232
|
}),
|
|
659
1233
|
|
|
660
|
-
//
|
|
1234
|
+
// Main body
|
|
661
1235
|
React.createElement(
|
|
662
|
-
Box, { flexDirection: "
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
//
|
|
670
|
-
|
|
671
|
-
? React.createElement(
|
|
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
|
|
685
|
-
!
|
|
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
|
-
|
|
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(
|
|
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
|
});
|