wispy-cli 1.1.1 → 1.2.1
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 +225 -0
- package/core/deploy.mjs +292 -0
- package/core/engine.mjs +115 -58
- package/core/harness.mjs +531 -0
- package/core/index.mjs +2 -0
- package/core/providers.mjs +53 -12
- package/core/tools.mjs +24 -4
- package/lib/channels/index.mjs +229 -4
- package/lib/wispy-repl.mjs +3 -0
- package/lib/wispy-tui.mjs +430 -30
- package/package.json +2 -2
package/lib/wispy-tui.mjs
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* wispy-tui.mjs — Ink-based TUI for Wispy
|
|
4
|
-
*
|
|
4
|
+
* v1.2: Trust UX — Approval dialogs, receipts, action timeline, diff views
|
|
5
5
|
*
|
|
6
6
|
* Features:
|
|
7
|
-
* - Status bar: provider / model / workstream / session cost
|
|
7
|
+
* - Status bar: provider / model / workstream / session cost / permission mode
|
|
8
8
|
* - Message list with basic markdown rendering
|
|
9
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
|
|
10
14
|
* - Input box (single-line with submit)
|
|
11
15
|
* - Loading spinner while AI is thinking
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
15
|
-
import { render, Box, Text, useApp, Newline } from "ink";
|
|
19
|
+
import { render, Box, Text, useApp, Newline, useInput } from "ink";
|
|
16
20
|
import Spinner from "ink-spinner";
|
|
17
21
|
import TextInput from "ink-text-input";
|
|
18
22
|
|
|
@@ -36,7 +40,7 @@ const ACTIVE_WORKSTREAM =
|
|
|
36
40
|
const HISTORY_FILE = path.join(CONVERSATIONS_DIR, `${ACTIVE_WORKSTREAM}.json`);
|
|
37
41
|
|
|
38
42
|
// -----------------------------------------------------------------------
|
|
39
|
-
// Conversation persistence
|
|
43
|
+
// Conversation persistence
|
|
40
44
|
// -----------------------------------------------------------------------
|
|
41
45
|
|
|
42
46
|
async function readFileOr(p, fallback = null) {
|
|
@@ -91,16 +95,207 @@ function renderMarkdown(text) {
|
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
// -----------------------------------------------------------------------
|
|
94
|
-
//
|
|
98
|
+
// ── Trust UX Components ──────────────────────────────────────────────────
|
|
95
99
|
// -----------------------------------------------------------------------
|
|
96
100
|
|
|
97
|
-
|
|
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;
|
|
107
|
+
|
|
108
|
+
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)),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
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]" : "";
|
|
138
|
+
|
|
139
|
+
const lines = [
|
|
140
|
+
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)`),
|
|
145
|
+
),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
if (receipt.error) {
|
|
149
|
+
lines.push(React.createElement(Text, { key: "err", color: "red" }, ` Error: ${receipt.error}`));
|
|
150
|
+
}
|
|
151
|
+
|
|
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
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return React.createElement(
|
|
173
|
+
Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0 },
|
|
174
|
+
...lines,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
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: "💾",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
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 ❌)" : "";
|
|
208
|
+
|
|
209
|
+
return React.createElement(
|
|
210
|
+
Box, { key: i },
|
|
211
|
+
React.createElement(Text, { dimColor: true }, `${ts} `),
|
|
212
|
+
React.createElement(Text, {}, `${icon} `),
|
|
213
|
+
React.createElement(Text, { color: "cyan" }, evt.toolName),
|
|
214
|
+
React.createElement(Text, { dimColor: true }, `${arg}${approvedTag} ${status}`),
|
|
215
|
+
);
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* ApprovalDialog — intercepts "approve" policy tools
|
|
222
|
+
*
|
|
223
|
+
* Resolves the pending approval and calls onApprove / onDeny / onDryRun
|
|
224
|
+
*/
|
|
225
|
+
function ApprovalDialog({ action, onApprove, onDeny, onDryRun }) {
|
|
226
|
+
const riskMap = {
|
|
227
|
+
run_command: "HIGH",
|
|
228
|
+
git: "HIGH",
|
|
229
|
+
keychain: "HIGH",
|
|
230
|
+
delete_file: "HIGH",
|
|
231
|
+
write_file: "MEDIUM",
|
|
232
|
+
file_edit: "MEDIUM",
|
|
233
|
+
};
|
|
234
|
+
const risk = riskMap[action?.toolName] ?? "MEDIUM";
|
|
235
|
+
const riskColor = risk === "HIGH" ? "red" : risk === "MEDIUM" ? "yellow" : "green";
|
|
236
|
+
|
|
237
|
+
// Format args summary
|
|
238
|
+
let argSummary = "";
|
|
239
|
+
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);
|
|
243
|
+
|
|
244
|
+
useInput((input, key) => {
|
|
245
|
+
const ch = input.toLowerCase();
|
|
246
|
+
if (ch === "y") { onApprove?.(); }
|
|
247
|
+
else if (ch === "n" || key.escape) { onDeny?.(); }
|
|
248
|
+
else if (ch === "d") { onDryRun?.(); }
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return React.createElement(
|
|
252
|
+
Box, {
|
|
253
|
+
flexDirection: "column",
|
|
254
|
+
borderStyle: "round",
|
|
255
|
+
borderColor: "yellow",
|
|
256
|
+
paddingX: 2,
|
|
257
|
+
paddingY: 1,
|
|
258
|
+
marginY: 1,
|
|
259
|
+
},
|
|
260
|
+
React.createElement(Text, { bold: true, color: "yellow" }, "⚠️ Permission Required"),
|
|
261
|
+
React.createElement(Text, {}, ""),
|
|
262
|
+
React.createElement(
|
|
263
|
+
Box, {},
|
|
264
|
+
React.createElement(Text, { dimColor: true }, "Tool: "),
|
|
265
|
+
React.createElement(Text, { bold: true }, action?.toolName ?? "?"),
|
|
266
|
+
),
|
|
267
|
+
argSummary ? React.createElement(
|
|
268
|
+
Box, {},
|
|
269
|
+
React.createElement(Text, { dimColor: true }, "Action: "),
|
|
270
|
+
React.createElement(Text, {}, argSummary),
|
|
271
|
+
) : null,
|
|
272
|
+
React.createElement(
|
|
273
|
+
Box, {},
|
|
274
|
+
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 },
|
|
280
|
+
React.createElement(Text, { color: "green", bold: true }, "[Y] Approve"),
|
|
281
|
+
React.createElement(Text, { color: "red", bold: true }, "[N] Deny"),
|
|
282
|
+
React.createElement(Text, { color: "cyan", bold: true }, "[D] Dry-run first"),
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// -----------------------------------------------------------------------
|
|
288
|
+
// Status Bar (enhanced)
|
|
289
|
+
// -----------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
function StatusBar({ provider, model, workstream, tokens, cost, permMode, pendingApprovals, auditCount, workMdLoaded }) {
|
|
98
292
|
const providerLabel = PROVIDERS[provider]?.label ?? provider ?? "?";
|
|
99
293
|
const costStr = cost > 0 ? `$${cost.toFixed(4)}` : "$0.0000";
|
|
100
294
|
const tokStr = tokens > 0 ? `${tokens}t` : "0t";
|
|
295
|
+
const permIcon = permMode === "approve" ? "🔐" : permMode === "notify" ? "📋" : "✅";
|
|
101
296
|
|
|
102
297
|
return React.createElement(
|
|
103
|
-
Box, { paddingX: 1, backgroundColor: "blue", width: "100%" },
|
|
298
|
+
Box, { paddingX: 1, backgroundColor: "blue", width: "100%", flexWrap: "wrap" },
|
|
104
299
|
React.createElement(Text, { color: "white", bold: true }, "🌿 Wispy"),
|
|
105
300
|
React.createElement(Text, { color: "white" }, " "),
|
|
106
301
|
React.createElement(Text, { color: "cyan" }, providerLabel),
|
|
@@ -108,11 +303,24 @@ function StatusBar({ provider, model, workstream, tokens, cost }) {
|
|
|
108
303
|
React.createElement(Text, { color: "yellow" }, model ?? "?"),
|
|
109
304
|
React.createElement(Text, { color: "white" }, " · ws: "),
|
|
110
305
|
React.createElement(Text, { color: "green" }, workstream ?? "default"),
|
|
306
|
+
workMdLoaded ? React.createElement(Text, { color: "green" }, " 📝") : null,
|
|
111
307
|
React.createElement(Text, { color: "white" }, " · "),
|
|
112
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,
|
|
113
317
|
);
|
|
114
318
|
}
|
|
115
319
|
|
|
320
|
+
// -----------------------------------------------------------------------
|
|
321
|
+
// Tool line & Message components
|
|
322
|
+
// -----------------------------------------------------------------------
|
|
323
|
+
|
|
116
324
|
function ToolLine({ name, args, result }) {
|
|
117
325
|
const argsStr = Object.values(args ?? {}).join(", ").slice(0, 40);
|
|
118
326
|
const status = result ? (result.success ? "✅" : "❌") : "⏳";
|
|
@@ -132,7 +340,11 @@ function Message({ msg }) {
|
|
|
132
340
|
React.createElement(Text, { color: "white" }, msg.content)));
|
|
133
341
|
}
|
|
134
342
|
if (msg.role === "tool_call") {
|
|
135
|
-
return React.createElement(
|
|
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
|
+
);
|
|
136
348
|
}
|
|
137
349
|
if (msg.role === "assistant") {
|
|
138
350
|
return React.createElement(
|
|
@@ -145,6 +357,10 @@ function Message({ msg }) {
|
|
|
145
357
|
return null;
|
|
146
358
|
}
|
|
147
359
|
|
|
360
|
+
// -----------------------------------------------------------------------
|
|
361
|
+
// Input Area
|
|
362
|
+
// -----------------------------------------------------------------------
|
|
363
|
+
|
|
148
364
|
function InputArea({ value, onChange, onSubmit, loading }) {
|
|
149
365
|
return React.createElement(
|
|
150
366
|
Box, { borderStyle: "single", borderColor: loading ? "yellow" : "green", paddingX: 1 },
|
|
@@ -174,21 +390,137 @@ function WispyTUI({ engine }) {
|
|
|
174
390
|
const [totalTokens, setTotalTokens] = useState(0);
|
|
175
391
|
const [totalCost, setTotalCost] = useState(0);
|
|
176
392
|
|
|
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);
|
|
398
|
+
|
|
177
399
|
// Conversation history for persistence
|
|
178
400
|
const conversationRef = useRef([]);
|
|
401
|
+
const approvalResolverRef = useRef(null);
|
|
179
402
|
|
|
403
|
+
// Wire harness events to TUI state
|
|
180
404
|
useEffect(() => {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
405
|
+
const harness = engine.harness;
|
|
406
|
+
if (!harness) return;
|
|
407
|
+
|
|
408
|
+
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,
|
|
414
|
+
timestamp: new Date().toISOString(),
|
|
415
|
+
success: null,
|
|
416
|
+
denied: false,
|
|
417
|
+
dryRun: false,
|
|
418
|
+
}]);
|
|
419
|
+
setAuditCount(c => c + 1);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const onComplete = ({ toolName, receipt }) => {
|
|
423
|
+
setActionTimeline(prev => {
|
|
424
|
+
const updated = [...prev];
|
|
425
|
+
// Update the last entry for this tool
|
|
426
|
+
for (let i = updated.length - 1; i >= 0; i--) {
|
|
427
|
+
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
|
+
};
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return updated;
|
|
438
|
+
});
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const onDenied = ({ toolName, receipt }) => {
|
|
442
|
+
setActionTimeline(prev => {
|
|
443
|
+
const updated = [...prev];
|
|
444
|
+
for (let i = updated.length - 1; i >= 0; i--) {
|
|
445
|
+
if (updated[i].toolName === toolName && updated[i].success === null) {
|
|
446
|
+
updated[i] = { ...updated[i], success: false, denied: true };
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return updated;
|
|
451
|
+
});
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
harness.on("tool:start", onStart);
|
|
455
|
+
harness.on("tool:complete", onComplete);
|
|
456
|
+
harness.on("tool:denied", onDenied);
|
|
457
|
+
|
|
458
|
+
return () => {
|
|
459
|
+
harness.off("tool:start", onStart);
|
|
460
|
+
harness.off("tool:complete", onComplete);
|
|
461
|
+
harness.off("tool:denied", onDenied);
|
|
462
|
+
};
|
|
463
|
+
}, [engine]);
|
|
464
|
+
|
|
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
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
engine.permissions.setApprovalHandler(async (action) => {
|
|
475
|
+
return new Promise((resolve) => {
|
|
476
|
+
approvalResolverRef.current = resolve;
|
|
477
|
+
setPendingApproval({ action });
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
return () => {
|
|
482
|
+
engine.permissions.setApprovalHandler(null);
|
|
483
|
+
};
|
|
484
|
+
}, [engine]);
|
|
485
|
+
|
|
486
|
+
const handleApprove = useCallback(() => {
|
|
487
|
+
approvalResolverRef.current?.(true);
|
|
488
|
+
approvalResolverRef.current = null;
|
|
489
|
+
setPendingApproval(null);
|
|
490
|
+
}, []);
|
|
491
|
+
|
|
492
|
+
const handleDeny = useCallback(() => {
|
|
493
|
+
approvalResolverRef.current?.(false);
|
|
494
|
+
approvalResolverRef.current = null;
|
|
495
|
+
setPendingApproval(null);
|
|
189
496
|
}, []);
|
|
190
497
|
|
|
498
|
+
const handleDryRun = useCallback(async () => {
|
|
499
|
+
// Deny the real execution, then re-run as dry-run (shown as a system message)
|
|
500
|
+
const action = pendingApproval?.action;
|
|
501
|
+
approvalResolverRef.current?.(false);
|
|
502
|
+
approvalResolverRef.current = null;
|
|
503
|
+
setPendingApproval(null);
|
|
504
|
+
|
|
505
|
+
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 {}
|
|
517
|
+
}
|
|
518
|
+
}, [pendingApproval]);
|
|
519
|
+
|
|
191
520
|
const handleSubmit = useCallback(async (value) => {
|
|
521
|
+
// If there's a pending approval, don't submit new messages
|
|
522
|
+
if (pendingApproval) return;
|
|
523
|
+
|
|
192
524
|
const input = value.trim();
|
|
193
525
|
if (!input || loading) return;
|
|
194
526
|
setInputValue("");
|
|
@@ -209,12 +541,21 @@ function WispyTUI({ engine }) {
|
|
|
209
541
|
setMessages(prev => [...prev, { role: "assistant", content: `Session: ${totalTokens} tokens (~$${totalCost.toFixed(4)}) 🌿` }]);
|
|
210
542
|
return;
|
|
211
543
|
}
|
|
544
|
+
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)"} 🌿` }]);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
212
549
|
if (cmd === "/model" && parts[1]) {
|
|
213
550
|
engine.providers.setModel(parts[1]);
|
|
214
551
|
setModel(parts[1]);
|
|
215
552
|
setMessages(prev => [...prev, { role: "assistant", content: `Model changed to ${parts[1]} 🌿` }]);
|
|
216
553
|
return;
|
|
217
554
|
}
|
|
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. 🌿" }]);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
218
559
|
}
|
|
219
560
|
|
|
220
561
|
setMessages(prev => [...prev, { role: "user", content: input }]);
|
|
@@ -222,20 +563,50 @@ function WispyTUI({ engine }) {
|
|
|
222
563
|
setLoading(true);
|
|
223
564
|
|
|
224
565
|
try {
|
|
225
|
-
let
|
|
566
|
+
let lastReceipt = null;
|
|
226
567
|
|
|
227
568
|
const result = await engine.processMessage(null, input, {
|
|
228
|
-
onChunk: (
|
|
229
|
-
// TUI doesn't stream to terminal, chunks are collected
|
|
230
|
-
},
|
|
569
|
+
onChunk: () => {},
|
|
231
570
|
onToolCall: (name, args) => {
|
|
232
|
-
const toolMsg = { role: "tool_call", name, args, result: null };
|
|
571
|
+
const toolMsg = { role: "tool_call", name, args, result: null, receipt: null };
|
|
233
572
|
setMessages(prev => [...prev, toolMsg]);
|
|
234
573
|
},
|
|
574
|
+
onToolResult: (name, toolResult) => {
|
|
575
|
+
// Update the last tool_call message with result
|
|
576
|
+
setMessages(prev => {
|
|
577
|
+
const updated = [...prev];
|
|
578
|
+
for (let i = updated.length - 1; i >= 0; i--) {
|
|
579
|
+
if (updated[i].role === "tool_call" && updated[i].name === name && updated[i].result === null) {
|
|
580
|
+
updated[i] = { ...updated[i], result: toolResult };
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return updated;
|
|
585
|
+
});
|
|
586
|
+
},
|
|
587
|
+
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
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return updated;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
if (receipt?.diff?.unified) {
|
|
603
|
+
setWorkMdLoaded(!!engine._workMdContent);
|
|
604
|
+
}
|
|
605
|
+
},
|
|
235
606
|
noSave: true,
|
|
236
607
|
});
|
|
237
608
|
|
|
238
|
-
responseText = result.content;
|
|
609
|
+
const responseText = result.content;
|
|
239
610
|
|
|
240
611
|
// Update token tracking
|
|
241
612
|
const { input: inputToks = 0, output: outputToks = 0 } = engine.providers.sessionTokens;
|
|
@@ -264,32 +635,61 @@ function WispyTUI({ engine }) {
|
|
|
264
635
|
} finally {
|
|
265
636
|
setLoading(false);
|
|
266
637
|
}
|
|
267
|
-
}, [loading, model, totalTokens, totalCost, engine, exit]);
|
|
638
|
+
}, [loading, model, totalTokens, totalCost, engine, exit, pendingApproval, actionTimeline]);
|
|
268
639
|
|
|
269
640
|
const displayMessages = messages.slice(-30);
|
|
641
|
+
// Determine permission mode from engine's policies
|
|
642
|
+
const permMode = engine.permissions?.getPolicy?.("run_command") ?? "approve";
|
|
270
643
|
|
|
271
644
|
return React.createElement(
|
|
272
645
|
Box, { flexDirection: "column", height: "100%" },
|
|
646
|
+
|
|
647
|
+
// Status bar
|
|
273
648
|
React.createElement(StatusBar, {
|
|
274
649
|
provider: engine.provider,
|
|
275
650
|
model,
|
|
276
651
|
workstream: ACTIVE_WORKSTREAM,
|
|
277
652
|
tokens: totalTokens,
|
|
278
653
|
cost: totalCost,
|
|
654
|
+
permMode,
|
|
655
|
+
pendingApprovals: pendingApproval ? 1 : 0,
|
|
656
|
+
auditCount,
|
|
657
|
+
workMdLoaded,
|
|
279
658
|
}),
|
|
659
|
+
|
|
660
|
+
// Message list
|
|
280
661
|
React.createElement(
|
|
281
662
|
Box, { flexDirection: "column", flexGrow: 1, paddingX: 1, overflowY: "hidden" },
|
|
282
663
|
displayMessages.length === 0
|
|
283
664
|
? React.createElement(Box, { marginY: 1 },
|
|
284
|
-
React.createElement(Text, { dimColor: true }, " 🌿 Type a message to start chatting. /help for commands."))
|
|
665
|
+
React.createElement(Text, { dimColor: true }, " 🌿 Type a message to start chatting. /help for commands, /timeline for action history."))
|
|
285
666
|
: displayMessages.map((msg, i) => React.createElement(Message, { key: i, msg })),
|
|
286
667
|
),
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
668
|
+
|
|
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
|
+
})
|
|
682
|
+
: null,
|
|
683
|
+
|
|
684
|
+
// Input area (hidden when approval dialog is shown)
|
|
685
|
+
!pendingApproval
|
|
686
|
+
? React.createElement(InputArea, {
|
|
687
|
+
value: inputValue,
|
|
688
|
+
onChange: setInputValue,
|
|
689
|
+
onSubmit: handleSubmit,
|
|
690
|
+
loading,
|
|
691
|
+
})
|
|
692
|
+
: null,
|
|
293
693
|
);
|
|
294
694
|
}
|
|
295
695
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wispy-cli",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "🌿 Wispy — AI workspace assistant with
|
|
3
|
+
"version": "1.2.1",
|
|
4
|
+
"description": "🌿 Wispy — AI workspace assistant with trustworthy execution (harness, receipts, approvals, diffs)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Minseo & Poropo",
|
|
7
7
|
"homepage": "https://github.com/mindsurf0176-ui/poropo-workspace/tree/master/agent-workstream-os/wispy-cli#readme",
|