wispy-cli 1.1.2 → 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/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
- * v0.7: uses core/engine.mjs for AI interaction
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 (for backward compat with CLI sessions)
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
- // TUI Components
98
+ // ── Trust UX Components ──────────────────────────────────────────────────
95
99
  // -----------------------------------------------------------------------
96
100
 
97
- function StatusBar({ provider, model, workstream, tokens, cost }) {
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(ToolLine, { name: msg.name, args: msg.args, result: msg.result });
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
- (async () => {
182
- const existing = await loadConversation();
183
- if (existing.length > 0) {
184
- const displayMsgs = existing.filter(m => m.role === "user" || m.role === "assistant").slice(-20);
185
- setMessages(displayMsgs);
186
- conversationRef.current = existing.slice(-20);
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 responseText = "";
566
+ let lastReceipt = null;
226
567
 
227
568
  const result = await engine.processMessage(null, input, {
228
- onChunk: (chunk) => {
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
- React.createElement(InputArea, {
288
- value: inputValue,
289
- onChange: setInputValue,
290
- onSubmit: handleSubmit,
291
- loading,
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.1.2",
4
- "description": "🌿 Wispy — AI workspace assistant with multi-agent orchestration and multi-channel bot support",
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",