wave-code 1.0.0 → 1.0.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.
Files changed (70) hide show
  1. package/dist/cli.js +20 -1
  2. package/dist/components/App.js +7 -0
  3. package/dist/components/BtwDisplay.js +13 -3
  4. package/dist/components/ChatInterface.js +21 -6
  5. package/dist/components/InputBox.d.ts +0 -3
  6. package/dist/components/InputBox.js +11 -9
  7. package/dist/components/LoadingIndicator.d.ts +1 -2
  8. package/dist/components/LoadingIndicator.js +2 -2
  9. package/dist/components/Markdown.js +13 -16
  10. package/dist/components/MessageList.d.ts +2 -1
  11. package/dist/components/MessageList.js +2 -2
  12. package/dist/components/StatusLine.d.ts +0 -2
  13. package/dist/components/StatusLine.js +6 -6
  14. package/dist/components/TaskList.js +2 -1
  15. package/dist/components/ToolDisplay.d.ts +1 -0
  16. package/dist/components/ToolDisplay.js +17 -9
  17. package/dist/constants/commands.js +0 -6
  18. package/dist/contexts/useChat.d.ts +3 -5
  19. package/dist/contexts/useChat.js +242 -82
  20. package/dist/daemon-cli.d.ts +10 -0
  21. package/dist/daemon-cli.js +15 -0
  22. package/dist/hooks/useInputManager.js +99 -22
  23. package/dist/index.js +10 -0
  24. package/dist/managers/inputHandlers.js +50 -22
  25. package/dist/managers/inputReducer.d.ts +12 -2
  26. package/dist/managers/inputReducer.js +57 -9
  27. package/dist/stdio/agentBridge.d.ts +23 -0
  28. package/dist/stdio/agentBridge.js +126 -16
  29. package/dist/stdio/daemonServer.d.ts +67 -0
  30. package/dist/stdio/daemonServer.js +191 -0
  31. package/dist/stdio/index.d.ts +2 -0
  32. package/dist/stdio/index.js +2 -0
  33. package/dist/stdio/jsonRpcConnection.d.ts +30 -0
  34. package/dist/stdio/jsonRpcConnection.js +127 -0
  35. package/dist/stdio/protocol.d.ts +2 -2
  36. package/dist/stdio/stdioServer.d.ts +2 -7
  37. package/dist/stdio/stdioServer.js +9 -100
  38. package/dist/utils/bracketedPaste.d.ts +39 -0
  39. package/dist/utils/bracketedPaste.js +122 -0
  40. package/dist/utils/markdownTable.d.ts +34 -0
  41. package/dist/utils/markdownTable.js +302 -0
  42. package/dist/utils/throttle.d.ts +3 -3
  43. package/package.json +4 -2
  44. package/src/cli.tsx +20 -1
  45. package/src/components/App.tsx +5 -0
  46. package/src/components/BtwDisplay.tsx +36 -12
  47. package/src/components/ChatInterface.tsx +25 -12
  48. package/src/components/InputBox.tsx +10 -18
  49. package/src/components/LoadingIndicator.tsx +1 -4
  50. package/src/components/Markdown.tsx +15 -18
  51. package/src/components/MessageList.tsx +6 -0
  52. package/src/components/StatusLine.tsx +0 -10
  53. package/src/components/TaskList.tsx +2 -1
  54. package/src/components/ToolDisplay.tsx +17 -6
  55. package/src/constants/commands.ts +0 -6
  56. package/src/contexts/useChat.tsx +310 -95
  57. package/src/daemon-cli.ts +17 -0
  58. package/src/hooks/useInputManager.ts +108 -22
  59. package/src/index.ts +12 -0
  60. package/src/managers/inputHandlers.ts +49 -22
  61. package/src/managers/inputReducer.ts +66 -11
  62. package/src/stdio/agentBridge.ts +188 -17
  63. package/src/stdio/daemonServer.ts +212 -0
  64. package/src/stdio/index.ts +2 -0
  65. package/src/stdio/jsonRpcConnection.ts +160 -0
  66. package/src/stdio/protocol.ts +5 -2
  67. package/src/stdio/stdioServer.ts +14 -120
  68. package/src/utils/bracketedPaste.ts +170 -0
  69. package/src/utils/markdownTable.ts +359 -0
  70. package/src/utils/throttle.ts +8 -8
@@ -22,27 +22,104 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
22
22
  const [isExpanded, setIsExpanded] = useState(false);
23
23
  const isExpandedRef = useRef(isExpanded);
24
24
  const [isTaskListVisible, setIsTaskListVisible] = useState(true);
25
+ const [isBtwActive, setIsBtwActive] = useState(false);
25
26
  const [messages, setMessages] = useState([]);
26
27
  const [latestTotalTokens, setLatestTotalTokens] = useState(0);
27
28
  const [maxInputTokens, setMaxInputTokens] = useState(200000);
28
- const throttledSetMessages = useMemo(() => throttle(() => {
29
- if (!isExpandedRef.current && agentRef.current) {
30
- const msgs = [...agentRef.current.messages];
31
- setMessages(msgs);
32
- setLatestTotalTokens(extractLatestTotalTokens(msgs));
33
- }
34
- }, 500, { leading: true, trailing: true }), []);
29
+ // Throttled incremental streaming updaters 500ms leading+trailing, the same interval
30
+ // as the pre-incremental throttledSetMessages. `stage === "end"` flushes the final
31
+ // update immediately so completion results are never delayed.
32
+ const throttledContentUpdate = useMemo(() => throttle((params) => {
33
+ const { messageId, accumulated, stage } = params;
34
+ setMessages((prev) => prev.map((m) => {
35
+ if (m.id !== messageId)
36
+ return m;
37
+ const textBlockIndex = m.blocks.findIndex((b) => b.type === "text");
38
+ if (textBlockIndex === -1) {
39
+ return {
40
+ ...m,
41
+ blocks: [
42
+ ...m.blocks,
43
+ { type: "text", content: accumulated, stage },
44
+ ],
45
+ };
46
+ }
47
+ return {
48
+ ...m,
49
+ blocks: m.blocks.map((b, idx) => idx === textBlockIndex && b.type === "text"
50
+ ? { ...b, content: accumulated, stage }
51
+ : b),
52
+ };
53
+ }));
54
+ }, 500), []);
55
+ const throttledReasoningUpdate = useMemo(() => throttle((params) => {
56
+ const { messageId, accumulated, stage } = params;
57
+ setMessages((prev) => prev.map((m) => {
58
+ if (m.id !== messageId)
59
+ return m;
60
+ const reasoningBlockIndex = m.blocks.findIndex((b) => b.type === "reasoning");
61
+ if (reasoningBlockIndex === -1) {
62
+ return {
63
+ ...m,
64
+ blocks: [
65
+ ...m.blocks,
66
+ { type: "reasoning", content: accumulated, stage },
67
+ ],
68
+ };
69
+ }
70
+ return {
71
+ ...m,
72
+ blocks: m.blocks.map((b, idx) => idx === reasoningBlockIndex && b.type === "reasoning"
73
+ ? { ...b, content: accumulated, stage }
74
+ : b),
75
+ };
76
+ }));
77
+ }, 500), []);
78
+ const throttledToolBlockUpdate = useMemo(() => throttle((params) => {
79
+ const { messageId, id: toolBlockId, ...updates } = params;
80
+ setMessages((prev) => prev.map((m) => {
81
+ if (m.id !== messageId)
82
+ return m;
83
+ const toolBlockIndex = m.blocks.findIndex((b) => b.type === "tool" && b.id === toolBlockId);
84
+ if (toolBlockIndex === -1) {
85
+ return {
86
+ ...m,
87
+ blocks: [
88
+ ...m.blocks,
89
+ {
90
+ type: "tool",
91
+ id: toolBlockId,
92
+ name: updates.name || "",
93
+ stage: updates.stage || "start",
94
+ parameters: updates.parameters || "",
95
+ result: updates.result || "",
96
+ ...updates,
97
+ },
98
+ ],
99
+ };
100
+ }
101
+ return {
102
+ ...m,
103
+ blocks: m.blocks.map((b, idx) => idx === toolBlockIndex && b.type === "tool"
104
+ ? { ...b, ...updates }
105
+ : b),
106
+ };
107
+ }));
108
+ }, 500), []);
35
109
  useEffect(() => {
36
110
  isExpandedRef.current = isExpanded;
37
111
  if (isExpanded) {
38
- throttledSetMessages.cancel();
112
+ // Cancel pending throttled updates so the frozen expanded view isn't overwritten
113
+ throttledContentUpdate.cancel();
114
+ throttledReasoningUpdate.cancel();
115
+ throttledToolBlockUpdate.cancel();
39
116
  }
40
- }, [isExpanded, throttledSetMessages]);
41
- useEffect(() => {
42
- return () => {
43
- throttledSetMessages.cancel();
44
- };
45
- }, [throttledSetMessages]);
117
+ }, [
118
+ isExpanded,
119
+ throttledContentUpdate,
120
+ throttledReasoningUpdate,
121
+ throttledToolBlockUpdate,
122
+ ]);
46
123
  const [isLoading, setIsLoading] = useState(false);
47
124
  const [sessionId, setSessionId] = useState("");
48
125
  const [isCommandRunning, setIsCommandRunning] = useState(false);
@@ -50,29 +127,6 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
50
127
  const [currentModel, setCurrentModelState] = useState("");
51
128
  const [configuredModels, setConfiguredModels] = useState([]);
52
129
  const [queuedMessages, setQueuedMessages] = useState([]);
53
- const [isGoalActive, setIsGoalActive] = useState(false);
54
- const [goalElapsed, setGoalElapsed] = useState();
55
- const [isGoalEvaluating, setIsGoalEvaluating] = useState(false);
56
- const goalStartedAt = useRef(null);
57
- // Update goal elapsed time every 30s while active
58
- useEffect(() => {
59
- if (!isGoalActive || goalStartedAt.current === null)
60
- return;
61
- const formatElapsed = (ms) => {
62
- const minutes = Math.floor(ms / 60000);
63
- if (minutes < 1)
64
- return "<1m";
65
- if (minutes < 60)
66
- return `${minutes}m`;
67
- const hours = Math.floor(minutes / 60);
68
- const remainingMin = minutes % 60;
69
- return `${hours}h${remainingMin}m`;
70
- };
71
- const update = () => setGoalElapsed(formatElapsed(Date.now() - goalStartedAt.current));
72
- update();
73
- const timer = setInterval(update, 30000);
74
- return () => clearInterval(timer);
75
- }, [isGoalActive]);
76
130
  // MCP State
77
131
  const [mcpServerStatuses, setMcpServerStatuses] = useState([]);
78
132
  // Background tasks state
@@ -119,6 +173,16 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
119
173
  // Status metadata state
120
174
  const [workingDirectory, setWorkingDirectory] = useState("");
121
175
  const agentRef = useRef(null);
176
+ // Full-list refresh — one-shot pull from the agent, used only for structural
177
+ // changes (compact/clear/rewind/collapse/init). Streaming updates flow through
178
+ // the incremental callbacks in initializeAgent below.
179
+ const refreshMessages = useCallback(() => {
180
+ if (!isExpandedRef.current && agentRef.current) {
181
+ const msgs = [...agentRef.current.messages];
182
+ setMessages(msgs);
183
+ setLatestTotalTokens(extractLatestTotalTokens(msgs));
184
+ }
185
+ }, []);
122
186
  // Permission confirmation methods with queue support
123
187
  const showConfirmation = useCallback(async (toolName, toolInput, suggestedPrefix, hidePersistentOption, planContent) => {
124
188
  return new Promise((resolve, reject) => {
@@ -139,8 +203,123 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
139
203
  const initializeAgent = useCallback(async (restoreSessionIdOverride) => {
140
204
  const effectiveRestoreSessionId = restoreSessionIdOverride ?? restoreSessionId;
141
205
  const callbacks = {
142
- onMessagesChange: () => {
143
- throttledSetMessages();
206
+ // ── Incremental message updates (no full-list pushes) ──────
207
+ onUserMessageAdded: () => {
208
+ if (isExpandedRef.current || !agentRef.current)
209
+ return;
210
+ const msgs = agentRef.current.messages;
211
+ const last = msgs[msgs.length - 1];
212
+ if (!last || last.role !== "user")
213
+ return;
214
+ setMessages((prev) => prev.some((m) => m.id === last.id) ? prev : [...prev, last]);
215
+ },
216
+ onAssistantMessageAdded: (messageId) => {
217
+ if (isExpandedRef.current || !agentRef.current)
218
+ return;
219
+ const msg = agentRef.current.messages.find((m) => m.id === messageId);
220
+ if (!msg)
221
+ return;
222
+ setMessages((prev) => prev.some((m) => m.id === messageId) ? prev : [...prev, msg]);
223
+ },
224
+ onAssistantContentUpdated: (params) => {
225
+ if (isExpandedRef.current)
226
+ return;
227
+ throttledContentUpdate(params);
228
+ if (params.stage === "end")
229
+ throttledContentUpdate.flush();
230
+ },
231
+ onAssistantReasoningUpdated: (params) => {
232
+ if (isExpandedRef.current)
233
+ return;
234
+ throttledReasoningUpdate(params);
235
+ if (params.stage === "end")
236
+ throttledReasoningUpdate.flush();
237
+ },
238
+ onToolBlockUpdated: (params) => {
239
+ if (isExpandedRef.current)
240
+ return;
241
+ throttledToolBlockUpdate(params);
242
+ if (params.stage === "end")
243
+ throttledToolBlockUpdate.flush();
244
+ },
245
+ onErrorBlockAdded: (error) => {
246
+ if (isExpandedRef.current)
247
+ return;
248
+ setMessages((prev) => {
249
+ // Append to the last assistant message, or create one if none exists
250
+ for (let i = prev.length - 1; i >= 0; i--) {
251
+ if (prev[i].role === "assistant") {
252
+ return prev.map((m, idx) => idx === i
253
+ ? {
254
+ ...m,
255
+ blocks: [
256
+ ...m.blocks,
257
+ { type: "error", content: error },
258
+ ],
259
+ }
260
+ : m);
261
+ }
262
+ }
263
+ return [
264
+ ...prev,
265
+ {
266
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
267
+ role: "assistant",
268
+ timestamp: new Date().toISOString(),
269
+ blocks: [{ type: "error", content: error }],
270
+ },
271
+ ];
272
+ });
273
+ },
274
+ onAddBangMessage: (command, messageId) => {
275
+ if (isExpandedRef.current)
276
+ return;
277
+ setMessages((prev) => prev.some((m) => m.id === messageId)
278
+ ? prev
279
+ : [
280
+ ...prev,
281
+ {
282
+ id: messageId,
283
+ role: "user",
284
+ timestamp: new Date().toISOString(),
285
+ blocks: [
286
+ {
287
+ type: "bang",
288
+ command,
289
+ output: "",
290
+ stage: "running",
291
+ exitCode: null,
292
+ },
293
+ ],
294
+ },
295
+ ]);
296
+ },
297
+ onUpdateBangMessage: (command, output, messageId) => {
298
+ if (isExpandedRef.current)
299
+ return;
300
+ setMessages((prev) => prev.map((m) => m.id === messageId
301
+ ? {
302
+ ...m,
303
+ blocks: m.blocks.map((b, idx) => idx === m.blocks.length - 1 && b.type === "bang"
304
+ ? { ...b, command, output }
305
+ : b),
306
+ }
307
+ : m));
308
+ },
309
+ onCompleteBangMessage: (command, exitCode, messageId) => {
310
+ if (isExpandedRef.current)
311
+ return;
312
+ setMessages((prev) => prev.map((m) => m.id === messageId
313
+ ? {
314
+ ...m,
315
+ blocks: m.blocks.map((b, idx) => idx === m.blocks.length - 1 && b.type === "bang"
316
+ ? { ...b, command, exitCode, stage: "end" }
317
+ : b),
318
+ }
319
+ : m));
320
+ },
321
+ onLatestTotalTokensChange: (tokens) => {
322
+ setLatestTotalTokens(tokens);
144
323
  },
145
324
  onMcpServersChange: (servers) => {
146
325
  setMcpServerStatuses([...servers]);
@@ -185,19 +364,6 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
185
364
  onQueuedMessagesChange: (messages) => {
186
365
  setQueuedMessages([...messages]);
187
366
  },
188
- onGoalStateChange: (active, _condition, elapsed) => {
189
- setIsGoalActive(active);
190
- if (active) {
191
- goalStartedAt.current = Date.now();
192
- }
193
- else {
194
- goalStartedAt.current = null;
195
- }
196
- setGoalElapsed(elapsed);
197
- },
198
- onGoalEvaluating: (evaluating) => {
199
- setIsGoalEvaluating(evaluating);
200
- },
201
367
  };
202
368
  try {
203
369
  // Create the permission callback inside the try block to access showConfirmation
@@ -283,7 +449,10 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
283
449
  originalCwd,
284
450
  model,
285
451
  initialPermissionMode,
286
- throttledSetMessages,
452
+ refreshMessages,
453
+ throttledContentUpdate,
454
+ throttledReasoningUpdate,
455
+ throttledToolBlockUpdate,
287
456
  mcpServers,
288
457
  ]);
289
458
  // Recreate agent (e.g. after plugin install) — destroys current agent and reinitializes
@@ -318,6 +487,9 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
318
487
  // Cleanup on unmount
319
488
  useEffect(() => {
320
489
  return () => {
490
+ throttledContentUpdate.cancel();
491
+ throttledReasoningUpdate.cancel();
492
+ throttledToolBlockUpdate.cancel();
321
493
  if (agentRef.current) {
322
494
  try {
323
495
  // Display usage summary before cleanup
@@ -331,7 +503,11 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
331
503
  agentRef.current.destroy();
332
504
  }
333
505
  };
334
- }, []);
506
+ }, [
507
+ throttledContentUpdate,
508
+ throttledReasoningUpdate,
509
+ throttledToolBlockUpdate,
510
+ ]);
335
511
  // Trigger WorktreeRemove hook BEFORE agent destruction
336
512
  const triggerWorktreeRemoveHook = useCallback(async (worktreePath) => {
337
513
  await agentRef.current?.triggerWorktreeRemoveHook(worktreePath);
@@ -368,30 +544,20 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
368
544
  console.error("Failed to send message:", error);
369
545
  }
370
546
  }, []);
371
- const askBtw = useCallback(async (question) => {
547
+ const askBtw = useCallback(async (question, abortSignal, onContent) => {
372
548
  if (!agentRef.current) {
373
549
  throw new Error("Agent not initialized");
374
550
  }
375
- return await agentRef.current.askBtw(question);
551
+ return await agentRef.current.askBtw(question, abortSignal, onContent);
376
552
  }, []);
377
553
  const clearMessages = useCallback(async () => {
378
554
  await agentRef.current?.clearMessages();
379
- }, []);
555
+ refreshMessages();
556
+ }, [refreshMessages]);
380
557
  const compact = useCallback(async (instructions) => {
381
558
  await agentRef.current?.compact(instructions);
382
- }, []);
383
- const goalCommand = useCallback(async (args) => {
384
- const trimmed = args?.trim() ?? "";
385
- if (!trimmed) {
386
- await agentRef.current?.showGoalStatus();
387
- }
388
- else if (["clear", "stop", "off", "reset", "none", "cancel"].includes(trimmed)) {
389
- await agentRef.current?.clearGoal();
390
- }
391
- else {
392
- await agentRef.current?.setGoal(trimmed);
393
- }
394
- }, []);
559
+ refreshMessages();
560
+ }, [refreshMessages]);
395
561
  // Unified interrupt method, interrupt both AI messages and command execution
396
562
  const abortMessage = useCallback(() => {
397
563
  agentRef.current?.abortMessage();
@@ -484,13 +650,14 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
484
650
  if (agentRef.current) {
485
651
  try {
486
652
  await agentRef.current.truncateHistory(index);
653
+ refreshMessages();
487
654
  requestRemount();
488
655
  }
489
656
  catch (error) {
490
657
  logger.error("Failed to rewind:", error);
491
658
  }
492
659
  }
493
- }, [requestRemount]);
660
+ }, [requestRemount, refreshMessages]);
494
661
  const getFullMessageThread = useCallback(async () => {
495
662
  if (agentRef.current) {
496
663
  return await agentRef.current.getFullMessageThread();
@@ -530,16 +697,11 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
530
697
  isExpandedRef.current = nextExpanded;
531
698
  if (nextExpanded) {
532
699
  // Transitioning to EXPANDED: Freeze the current view
533
- // Cancel any pending throttled updates to avoid overwriting the frozen state
534
- throttledSetMessages.cancel();
700
+ // Incremental updates are skipped while expanded (isExpandedRef guard)
535
701
  }
536
702
  else {
537
703
  // Transitioning to COLLAPSED: Restore from agent's actual state
538
- if (agentRef.current) {
539
- const msgs = [...agentRef.current.messages];
540
- setMessages(msgs);
541
- setLatestTotalTokens(extractLatestTotalTokens(msgs));
542
- }
704
+ refreshMessages();
543
705
  }
544
706
  // Force remount directly (bypass throttle) to ensure Static items re-render
545
707
  // The throttled requestRemount can be dropped if pressed too quickly after
@@ -563,13 +725,14 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
563
725
  isExpanded,
564
726
  isTaskListVisible,
565
727
  setIsTaskListVisible,
728
+ isBtwActive,
729
+ setIsBtwActive,
566
730
  queuedMessages,
567
731
  sessionId,
568
732
  sendMessage,
569
733
  askBtw,
570
734
  clearMessages,
571
735
  compact,
572
- goalCommand,
573
736
  abortMessage,
574
737
  recallQueuedMessage,
575
738
  removeQueuedMessageById,
@@ -612,9 +775,6 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
612
775
  workdir,
613
776
  recreateAgent,
614
777
  triggerWorktreeRemoveHook,
615
- isGoalActive,
616
- goalElapsed,
617
- isGoalEvaluating,
618
778
  };
619
779
  return (_jsx(ChatContext.Provider, { value: contextValue, children: children }));
620
780
  };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * daemon-cli.ts — Entry point for `wave --daemon <socket-path>` mode.
3
+ *
4
+ * Starts a DaemonServer that serves JSON-RPC over a unix socket. The desktop
5
+ * app launches this on a remote host via nohup/setsid and tunnels the socket
6
+ * back with `ssh -L`; multiple attach/detach cycles share one process, so
7
+ * sessions and pending permissions survive client disconnects. The net server
8
+ * keeps the process alive — there is no stdin to wait on.
9
+ */
10
+ export declare function startDaemonCli(socketPath: string): Promise<void>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * daemon-cli.ts — Entry point for `wave --daemon <socket-path>` mode.
3
+ *
4
+ * Starts a DaemonServer that serves JSON-RPC over a unix socket. The desktop
5
+ * app launches this on a remote host via nohup/setsid and tunnels the socket
6
+ * back with `ssh -L`; multiple attach/detach cycles share one process, so
7
+ * sessions and pending permissions survive client disconnects. The net server
8
+ * keeps the process alive — there is no stdin to wait on.
9
+ */
10
+ import { DaemonServer } from "./stdio/daemonServer.js";
11
+ export async function startDaemonCli(socketPath) {
12
+ const server = new DaemonServer({ socketPath });
13
+ await server.start();
14
+ // Ready — any error that follows goes to the daemon log via stderr.
15
+ }
@@ -1,10 +1,20 @@
1
- import { useEffect, useReducer, useCallback } from "react";
2
- import { inputReducer, initialState, ESC_DOUBLE_PRESS_TIMEOUT_MS, } from "../managers/inputReducer.js";
1
+ import { useEffect, useReducer, useCallback, useRef } from "react";
2
+ import { inputReducer, initialState, ESC_DOUBLE_PRESS_TIMEOUT_MS, btwOverlayActiveRef, } from "../managers/inputReducer.js";
3
+ import { createBracketedPasteDetector } from "../utils/bracketedPaste.js";
3
4
  import { searchFiles as searchFilesUtil, PromptHistoryManager, } from "wave-agent-sdk";
4
5
  import * as handlers from "../managers/inputHandlers.js";
5
6
  export const useInputManager = (callbacks = {}) => {
6
7
  const [state, dispatch] = useReducer(inputReducer, initialState);
7
- const { onInputTextChange, onCursorPositionChange, onFileSelectorStateChange, onCommandSelectorStateChange, onHistorySearchStateChange, onBackgroundTaskManagerStateChange, onMcpManagerStateChange, onRewindManagerStateChange, onHelpStateChange, onStatusCommandStateChange, onPluginManagerStateChange, onModelSelectorStateChange, onWorkflowManagerStateChange, onImagesStateChange, onSendMessage, onHasSlashCommand, onAbortMessage, onBackgroundCurrentTask, onPermissionModeChange, onAskBtw, sessionId, workdir, getFullMessageThread, logger, hasQueuedMessages: hasQueuedMessagesProp, onRecallQueuedMessage, onClearMessages, onCompact, onGoalCommand, isIdle: isIdleProp, } = callbacks;
8
+ // Detects bracketed paste (DECSET 2004) markers so pasted text is inserted
9
+ // without triggering submit — a pasted trailing \r must not be treated as
10
+ // Enter (see utils/bracketedPaste.ts).
11
+ const pasteDetectorRef = useRef(createBracketedPasteDetector());
12
+ // Abort plumbing for the /btw side question: ABORT_BTW (set when the overlay
13
+ // is dismissed while loading) aborts the in-flight onAskBtw call; the
14
+ // dismissed ref suppresses the late SET_BTW_STATE dispatch / error logging.
15
+ const btwAbortRef = useRef(null);
16
+ const btwDismissedRef = useRef(false);
17
+ const { onInputTextChange, onCursorPositionChange, onFileSelectorStateChange, onCommandSelectorStateChange, onHistorySearchStateChange, onBackgroundTaskManagerStateChange, onMcpManagerStateChange, onRewindManagerStateChange, onHelpStateChange, onStatusCommandStateChange, onPluginManagerStateChange, onModelSelectorStateChange, onWorkflowManagerStateChange, onImagesStateChange, onSendMessage, onHasSlashCommand, onAbortMessage, onBackgroundCurrentTask, onPermissionModeChange, onAskBtw, sessionId, workdir, getFullMessageThread, logger, hasQueuedMessages: hasQueuedMessagesProp, onRecallQueuedMessage, onClearMessages, onCompact, isIdle: isIdleProp, } = callbacks;
8
18
  // Handle debounced file search
9
19
  useEffect(() => {
10
20
  if (state.showFileSelector) {
@@ -67,25 +77,49 @@ export const useInputManager = (callbacks = {}) => {
67
77
  case "BACKGROUND_CURRENT_TASK":
68
78
  onBackgroundCurrentTask?.();
69
79
  break;
70
- case "ASK_BTW":
80
+ case "ASK_BTW": {
81
+ const controller = new AbortController();
82
+ btwAbortRef.current = controller;
83
+ btwDismissedRef.current = false;
71
84
  try {
72
- const answer = await onAskBtw?.(effect.question);
73
- dispatch({
74
- type: "SET_BTW_STATE",
75
- payload: { answer, isLoading: false },
85
+ const answer = await onAskBtw?.(effect.question, controller.signal, (content) => {
86
+ // Stream partial answers into the overlay so the user sees
87
+ // the response grow in real time (same visual language as
88
+ // assistant text / thinking blocks) instead of a static
89
+ // "Answering..." indicator.
90
+ if (!btwDismissedRef.current) {
91
+ dispatch({
92
+ type: "SET_BTW_STATE",
93
+ payload: { answer: content, isLoading: true },
94
+ });
95
+ }
76
96
  });
97
+ if (!btwDismissedRef.current) {
98
+ dispatch({
99
+ type: "SET_BTW_STATE",
100
+ payload: { answer, isLoading: false },
101
+ });
102
+ }
77
103
  }
78
104
  catch (error) {
79
- console.error("Failed to ask side question:", error);
80
- dispatch({
81
- type: "SET_BTW_STATE",
82
- payload: {
83
- answer: "Error: Failed to get an answer for your side question.",
84
- isLoading: false,
85
- },
86
- });
105
+ if (!btwDismissedRef.current) {
106
+ console.error("Failed to ask side question:", error);
107
+ dispatch({
108
+ type: "SET_BTW_STATE",
109
+ payload: {
110
+ answer: "Error: Failed to get an answer for your side question.",
111
+ isLoading: false,
112
+ },
113
+ });
114
+ }
87
115
  }
88
116
  break;
117
+ }
118
+ case "ABORT_BTW":
119
+ btwDismissedRef.current = true;
120
+ btwAbortRef.current?.abort();
121
+ btwAbortRef.current = null;
122
+ break;
89
123
  case "PERMISSION_MODE_CHANGE":
90
124
  onPermissionModeChange?.(effect.mode);
91
125
  break;
@@ -155,6 +189,18 @@ export const useInputManager = (callbacks = {}) => {
155
189
  else if (command === "logout") {
156
190
  dispatch({ type: "SET_SHOW_LOGIN_COMMAND", payload: true });
157
191
  }
192
+ else if (command === "btw") {
193
+ // Bare /btw executed via the command selector — show usage
194
+ // (aligned with Claude Code's empty-args message).
195
+ dispatch({
196
+ type: "SET_BTW_STATE",
197
+ payload: {
198
+ question: "",
199
+ isLoading: false,
200
+ answer: "Usage: /btw <your question>",
201
+ },
202
+ });
203
+ }
158
204
  else if (command === "plugin") {
159
205
  dispatch({ type: "SET_SHOW_PLUGIN_MANAGER", payload: true });
160
206
  }
@@ -170,9 +216,6 @@ export const useInputManager = (callbacks = {}) => {
170
216
  else if (command === "compact") {
171
217
  await onCompact?.(effect.args);
172
218
  }
173
- else if (command === "goal") {
174
- await onGoalCommand?.(effect.args);
175
- }
176
219
  }
177
220
  break;
178
221
  case "RECALL_QUEUED_MESSAGE":
@@ -200,7 +243,6 @@ export const useInputManager = (callbacks = {}) => {
200
243
  onRecallQueuedMessage,
201
244
  onClearMessages,
202
245
  onCompact,
203
- onGoalCommand,
204
246
  ]);
205
247
  useEffect(() => {
206
248
  onFileSelectorStateChange?.(state.showFileSelector, state.filteredFiles, state.fileSearchQuery, state.atPosition);
@@ -253,7 +295,13 @@ export const useInputManager = (callbacks = {}) => {
253
295
  useEffect(() => {
254
296
  onImagesStateChange?.(state.attachedImages);
255
297
  }, [state.attachedImages, onImagesStateChange]);
256
- // /btw side question is handled via pendingEffect "ASK_BTW" above
298
+ // Keep the shared overlay-active flag in sync so App's Ctrl+C exit handler
299
+ // can defer to the /btw overlay (ink runs ALL useInput handlers per keypress
300
+ // with no propagation control).
301
+ useEffect(() => {
302
+ btwOverlayActiveRef.current =
303
+ state.btwState.question !== "" || state.btwState.answer !== undefined;
304
+ }, [state.btwState.question, state.btwState.answer]);
257
305
  // Methods
258
306
  const insertTextAtCursor = useCallback((text) => {
259
307
  dispatch({ type: "INSERT_TEXT", payload: text });
@@ -397,10 +445,39 @@ export const useInputManager = (callbacks = {}) => {
397
445
  dispatch({ type: "CLEAR_LONG_TEXT_MAP" });
398
446
  }, []);
399
447
  const handleInput = useCallback(async (input, key) => {
448
+ const result = pasteDetectorRef.current.process(input);
449
+ if (result.kind === "consume") {
450
+ // Content of an in-flight bracketed paste (or an empty paste):
451
+ // hold it, never submit or insert prematurely.
452
+ return true;
453
+ }
454
+ if (result.kind === "paste") {
455
+ if (result.leadingInput) {
456
+ dispatch({
457
+ type: "HANDLE_KEY",
458
+ payload: {
459
+ input: result.leadingInput,
460
+ key,
461
+ hasSlashCommand: (cmd) => !!onHasSlashCommand?.(cmd),
462
+ hasQueuedMessages: hasQueuedMessagesProp ?? false,
463
+ isIdle: isIdleProp ?? false,
464
+ },
465
+ });
466
+ }
467
+ if (result.text !== "") {
468
+ // Insert-only: \r → \n normalizes CRLF terminals, matching the
469
+ // canonical paste path (inputHandlers.handlePasteInput).
470
+ dispatch({
471
+ type: "INSERT_TEXT_WITH_PLACEHOLDER",
472
+ payload: result.text.replace(/\r/g, "\n"),
473
+ });
474
+ }
475
+ return true;
476
+ }
400
477
  dispatch({
401
478
  type: "HANDLE_KEY",
402
479
  payload: {
403
- input,
480
+ input: result.input,
404
481
  key,
405
482
  hasSlashCommand: (cmd) => !!onHasSlashCommand?.(cmd),
406
483
  hasQueuedMessages: hasQueuedMessagesProp ?? false,
package/dist/index.js CHANGED
@@ -44,6 +44,11 @@ export async function main() {
44
44
  type: "boolean",
45
45
  default: false,
46
46
  global: false,
47
+ })
48
+ .option("daemon", {
49
+ description: "Start as a background daemon (JSON-RPC over a unix socket at PATH)",
50
+ type: "string",
51
+ global: false,
47
52
  })
48
53
  .option("show-stats", {
49
54
  description: "Show timing and usage statistics in print mode",
@@ -300,6 +305,11 @@ export async function main() {
300
305
  const { startStdioCli } = await import("./stdio-cli.js");
301
306
  return startStdioCli();
302
307
  }
308
+ // Handle daemon mode (remote background sessions)
309
+ if (typeof argv.daemon === "string") {
310
+ const { startDaemonCli } = await import("./daemon-cli.js");
311
+ return startDaemonCli(argv.daemon);
312
+ }
303
313
  await startCli({
304
314
  restoreSessionId: argv.restore,
305
315
  continueLastSession: argv.continue,