wave-code 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle/wave.mjs +1275 -895
- package/package.json +4 -2
- package/src/commands/update.ts +62 -22
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/ConfirmationDetails.tsx +17 -3
- package/src/components/InputBox.tsx +2 -0
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/WorkflowManager.tsx +38 -138
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +359 -127
- package/src/daemon/commands.ts +13 -4
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +4 -0
- package/src/hooks/useRemoteControl.ts +324 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +2 -0
- package/src/managers/inputReducer.ts +1 -0
- package/src/print-cli.ts +5 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +97 -0
- package/src/remote/notificationCoalescer.ts +174 -0
- package/src/remote/relayClient.ts +668 -0
- package/src/remote/remoteControlCliHost.ts +391 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +334 -0
- package/src/remote/remoteControlService.ts +799 -0
- package/src/remote/remoteControlSink.ts +77 -0
- package/src/remote/remoteNotifications.ts +194 -0
- package/src/stdio/agentBridge.ts +608 -74
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -115
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio-cli.ts +11 -2
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
package/src/contexts/useChat.tsx
CHANGED
|
@@ -5,10 +5,13 @@ import React, {
|
|
|
5
5
|
useRef,
|
|
6
6
|
useEffect,
|
|
7
7
|
useState,
|
|
8
|
-
useMemo,
|
|
9
8
|
} from "react";
|
|
10
9
|
import { useInput, useStdout } from "ink";
|
|
11
10
|
import { useAppConfig } from "./useAppConfig.js";
|
|
11
|
+
import {
|
|
12
|
+
useRemoteControl,
|
|
13
|
+
type RemoteControlApi,
|
|
14
|
+
} from "../hooks/useRemoteControl.js";
|
|
12
15
|
import type {
|
|
13
16
|
Message,
|
|
14
17
|
McpServerStatus,
|
|
@@ -34,13 +37,64 @@ import {
|
|
|
34
37
|
PLUGIN_CHANGE_PENDING_MESSAGE,
|
|
35
38
|
PLUGIN_RELOADED_MESSAGE,
|
|
36
39
|
} from "wave-agent-sdk";
|
|
40
|
+
import { toString as qrToString } from "qrcode";
|
|
37
41
|
import { logger } from "../utils/logger.js";
|
|
38
42
|
import { displayUsageSummary } from "../utils/usageSummary.js";
|
|
39
43
|
import { expandLongTextPlaceholders } from "../managers/inputHandlers.js";
|
|
44
|
+
import { remoteNotifications } from "../remote/remoteNotifications.js";
|
|
45
|
+
import type { RemoteNotify } from "../remote/remoteNotifications.js";
|
|
40
46
|
import { readFile } from "node:fs/promises";
|
|
41
47
|
|
|
42
48
|
import { BaseAppProps } from "../types.js";
|
|
43
49
|
|
|
50
|
+
/** How long `/remote-control` waits for the relay's registration round trip. */
|
|
51
|
+
const REMOTE_PAGE_WAIT_MS = 5_000;
|
|
52
|
+
const REMOTE_PAGE_POLL_MS = 250;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Wait for the page address. It is assembled from this machine's channel key
|
|
56
|
+
* after the relay registers it (an HTTP call) — nothing is pushed down the
|
|
57
|
+
* WebSocket — so the first snapshot may not carry it yet.
|
|
58
|
+
*/
|
|
59
|
+
async function waitForRemotePageUrl(
|
|
60
|
+
api: RemoteControlApi,
|
|
61
|
+
): Promise<string | undefined> {
|
|
62
|
+
const deadline = Date.now() + REMOTE_PAGE_WAIT_MS;
|
|
63
|
+
while (Date.now() < deadline) {
|
|
64
|
+
const snapshot = api.getSnapshot();
|
|
65
|
+
if (snapshot.pageUrl) return snapshot.pageUrl;
|
|
66
|
+
if (snapshot.state === "error") return undefined;
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, REMOTE_PAGE_POLL_MS));
|
|
68
|
+
}
|
|
69
|
+
return api.getSnapshot().pageUrl;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The result block a TUI session shows: the same address and the same QR that
|
|
74
|
+
* `wave remote-control on` prints, because that pair is the whole contract with
|
|
75
|
+
* the phone — the two entry points must not diverge on how it is rendered.
|
|
76
|
+
*/
|
|
77
|
+
async function describeRemotePage(pageUrl: string): Promise<string> {
|
|
78
|
+
const lines = [
|
|
79
|
+
"Remote control is on — this terminal's session is now served to your phone.",
|
|
80
|
+
`Page: ${pageUrl}`,
|
|
81
|
+
];
|
|
82
|
+
try {
|
|
83
|
+
lines.push(
|
|
84
|
+
"",
|
|
85
|
+
await qrToString(pageUrl, {
|
|
86
|
+
type: "utf8",
|
|
87
|
+
errorCorrectionLevel: "L",
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
// The address above already carries the contract; a missing QR is not worth
|
|
92
|
+
// failing the command over.
|
|
93
|
+
}
|
|
94
|
+
lines.push("", "Turn it off with /remote-control off");
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
44
98
|
// Main Chat Context
|
|
45
99
|
export interface PlanViewData {
|
|
46
100
|
path?: string;
|
|
@@ -132,6 +186,19 @@ export interface ChatContextType {
|
|
|
132
186
|
// Permission functionality
|
|
133
187
|
permissionMode: PermissionMode;
|
|
134
188
|
setPermissionMode: (mode: PermissionMode) => void;
|
|
189
|
+
/**
|
|
190
|
+
* Remote control for **this** foreground session. The process the user is
|
|
191
|
+
* typing in is the one that dials out — not the daemon, which cannot reach a
|
|
192
|
+
* session that only exists here.
|
|
193
|
+
*/
|
|
194
|
+
remoteControl: RemoteControlApi;
|
|
195
|
+
/**
|
|
196
|
+
* `/remote-control [on|off|status] [name]` — the TUI-side switch for the hook
|
|
197
|
+
* above. Its CLI namesake talks to the daemon instead; the two are not
|
|
198
|
+
* interchangeable, because a session that lives in this process is reachable
|
|
199
|
+
* only from this process.
|
|
200
|
+
*/
|
|
201
|
+
remoteControlCommand: (args?: string) => Promise<void>;
|
|
135
202
|
// /plan overlay state + handler
|
|
136
203
|
planView: PlanViewData | null;
|
|
137
204
|
setPlanView: (view: PlanViewData | null) => void;
|
|
@@ -349,81 +416,6 @@ const applyToolBlockUpdate = (
|
|
|
349
416
|
});
|
|
350
417
|
};
|
|
351
418
|
|
|
352
|
-
/**
|
|
353
|
-
* Single throttled updater entry for ALL message-state updates. The one
|
|
354
|
-
* 500ms window-concat window replaces the previous three-channel throttles
|
|
355
|
-
* (content/reasoning/tool), so every message-state update — including the
|
|
356
|
-
* tool `running` stage's high-frequency `shortResult`/`result` updates from
|
|
357
|
-
* bash — coalesces into the same window.
|
|
358
|
-
*
|
|
359
|
-
* Semantics: leading edge applies immediately and opens a window; updates
|
|
360
|
-
* arriving within the window are queued in arrival order (FIFO) and applied
|
|
361
|
-
* at the trailing edge as ONE composed updater, so no update is lost and no
|
|
362
|
-
* update is reordered. `flush` applies queued updates immediately (used by
|
|
363
|
-
* end signals and one-shot structural updates); `cancel` drops queued
|
|
364
|
-
* updates (used by refreshMessages after pulling the authoritative snapshot —
|
|
365
|
-
* the queued updates are already contained in it).
|
|
366
|
-
*
|
|
367
|
-
* The FIFO queue structurally replaces the old tool throttle's "drop a
|
|
368
|
-
* tool's buffered streaming deltas when running arrives" logic: a running
|
|
369
|
-
* updater is queued BEHIND the streaming chunks that preceded it and applies
|
|
370
|
-
* after them, so a stale streaming event can never flush after running and
|
|
371
|
-
* regress the stage (yellow dot -> gray). See
|
|
372
|
-
* docs/specs/core/stream-content-updates.md.
|
|
373
|
-
*/
|
|
374
|
-
export function createThrottledUpdater<T>(
|
|
375
|
-
apply: (updater: (prev: T) => T) => void,
|
|
376
|
-
wait: number,
|
|
377
|
-
): {
|
|
378
|
-
(updater: (prev: T) => T): void;
|
|
379
|
-
cancel: () => void;
|
|
380
|
-
flush: () => void;
|
|
381
|
-
} {
|
|
382
|
-
let timer: NodeJS.Timeout | null = null;
|
|
383
|
-
let queued: Array<(prev: T) => T> = [];
|
|
384
|
-
|
|
385
|
-
const fire = () => {
|
|
386
|
-
if (queued.length === 0) return;
|
|
387
|
-
const batch = queued;
|
|
388
|
-
queued = [];
|
|
389
|
-
// Compose the whole batch into a single updater applied in FIFO order —
|
|
390
|
-
// one state commit per trailing edge, no update dropped, no reordering.
|
|
391
|
-
apply((prev) => batch.reduce((acc, upd) => upd(acc), prev));
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
const throttled = (updater: (prev: T) => T) => {
|
|
395
|
-
if (timer) {
|
|
396
|
-
queued.push(updater);
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
// Leading edge: apply immediately, then open a window whose trailing
|
|
400
|
-
// edge only carries updates arriving within the window.
|
|
401
|
-
apply(updater);
|
|
402
|
-
timer = setTimeout(() => {
|
|
403
|
-
timer = null;
|
|
404
|
-
fire();
|
|
405
|
-
}, wait);
|
|
406
|
-
};
|
|
407
|
-
|
|
408
|
-
throttled.cancel = () => {
|
|
409
|
-
if (timer) {
|
|
410
|
-
clearTimeout(timer);
|
|
411
|
-
timer = null;
|
|
412
|
-
}
|
|
413
|
-
queued = [];
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
throttled.flush = () => {
|
|
417
|
-
if (timer) {
|
|
418
|
-
clearTimeout(timer);
|
|
419
|
-
timer = null;
|
|
420
|
-
}
|
|
421
|
-
fire();
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
return throttled;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
419
|
export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
428
420
|
children,
|
|
429
421
|
bypassPermissions,
|
|
@@ -454,26 +446,16 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
454
446
|
const [latestTotalTokens, setLatestTotalTokens] = useState(0);
|
|
455
447
|
const [maxInputTokens, setMaxInputTokens] = useState(200000);
|
|
456
448
|
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
// window. See createThrottledUpdater + docs/specs/core/stream-content-updates.md.
|
|
464
|
-
const updateMessages = useMemo(
|
|
465
|
-
() =>
|
|
466
|
-
createThrottledUpdater<Message[]>((updater) => setMessages(updater), 500),
|
|
467
|
-
[],
|
|
468
|
-
);
|
|
449
|
+
// All message-state updates apply IMMEDIATELY via setMessages — no data-layer
|
|
450
|
+
// throttling (removed 2026-10-15 after observing fully-immediate rendering in
|
|
451
|
+
// a real terminal with no flicker: Ink's log-update only rewrites the dynamic
|
|
452
|
+
// zone tail and its built-in maxFps:30 coalesces short-result line-count
|
|
453
|
+
// oscillation). Arrival order = application order. See
|
|
454
|
+
// docs/specs/core/stream-content-updates.md (2026-10-15 会议).
|
|
469
455
|
|
|
470
456
|
useEffect(() => {
|
|
471
457
|
isExpandedRef.current = isExpanded;
|
|
472
|
-
|
|
473
|
-
// Cancel pending throttled updates so the frozen expanded view isn't overwritten
|
|
474
|
-
updateMessages.cancel();
|
|
475
|
-
}
|
|
476
|
-
}, [isExpanded, updateMessages]);
|
|
458
|
+
}, [isExpanded]);
|
|
477
459
|
|
|
478
460
|
const [isLoading, setIsLoading] = useState(false);
|
|
479
461
|
const [sessionId, setSessionId] = useState("");
|
|
@@ -538,6 +520,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
538
520
|
>();
|
|
539
521
|
const [confirmationQueue, setConfirmationQueue] = useState<
|
|
540
522
|
Array<{
|
|
523
|
+
requestId: string;
|
|
541
524
|
toolName: string;
|
|
542
525
|
toolInput?: Record<string, unknown>;
|
|
543
526
|
suggestedPrefix?: string;
|
|
@@ -551,6 +534,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
551
534
|
}>
|
|
552
535
|
>([]);
|
|
553
536
|
const [currentConfirmation, setCurrentConfirmation] = useState<{
|
|
537
|
+
requestId: string;
|
|
554
538
|
toolName: string;
|
|
555
539
|
toolInput?: Record<string, unknown>;
|
|
556
540
|
suggestedPrefix?: string;
|
|
@@ -563,6 +547,37 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
563
547
|
reject: () => void;
|
|
564
548
|
} | null>(null);
|
|
565
549
|
|
|
550
|
+
/**
|
|
551
|
+
* The same approvals, keyed by id, so a phone can answer them.
|
|
552
|
+
*
|
|
553
|
+
* The queue above is the terminal's view of "what am I waiting on"; this map
|
|
554
|
+
* is the phone's. Both resolve the same promise, so whichever side answers
|
|
555
|
+
* first wins — that is the whole of the concurrency rule (spec: 前台 CLI
|
|
556
|
+
* 故事场景 5), and it needs no arbitration beyond "delete on settle".
|
|
557
|
+
*/
|
|
558
|
+
const remotePendingRef = useRef<
|
|
559
|
+
Map<
|
|
560
|
+
string,
|
|
561
|
+
{
|
|
562
|
+
context: ToolPermissionContext;
|
|
563
|
+
settle: (decision: PermissionDecision) => void;
|
|
564
|
+
}
|
|
565
|
+
>
|
|
566
|
+
>(new Map());
|
|
567
|
+
const [remotePending, setRemotePending] = useState<
|
|
568
|
+
Array<{ requestId: string; context: ToolPermissionContext }>
|
|
569
|
+
>([]);
|
|
570
|
+
const permissionCounterRef = useRef(0);
|
|
571
|
+
|
|
572
|
+
const syncRemotePending = useCallback(() => {
|
|
573
|
+
setRemotePending(
|
|
574
|
+
[...remotePendingRef.current.entries()].map(([requestId, entry]) => ({
|
|
575
|
+
requestId,
|
|
576
|
+
context: entry.context,
|
|
577
|
+
})),
|
|
578
|
+
);
|
|
579
|
+
}, []);
|
|
580
|
+
|
|
566
581
|
// Remount state
|
|
567
582
|
const [remountKey, setRemountKey] = useState(0);
|
|
568
583
|
|
|
@@ -580,6 +595,14 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
580
595
|
|
|
581
596
|
const agentRef = useRef<Agent | null>(null);
|
|
582
597
|
|
|
598
|
+
/**
|
|
599
|
+
* The notifier the remote path's content-carrying frames go out through (see
|
|
600
|
+
* `remoteNotifications`). A ref because the agent callbacks are built in
|
|
601
|
+
* `initializeAgent` below, before this component has the hook that owns the
|
|
602
|
+
* connection.
|
|
603
|
+
*/
|
|
604
|
+
const remoteNotifyRef = useRef<RemoteNotify | undefined>(undefined);
|
|
605
|
+
|
|
583
606
|
// Full-list refresh — one-shot pull from the agent, used only for structural
|
|
584
607
|
// changes (compact/clear/rewind/collapse/init). Streaming updates flow through
|
|
585
608
|
// the incremental callbacks in initializeAgent below.
|
|
@@ -588,19 +611,14 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
588
611
|
// Pull the full UI display stream (keeps pre-compaction history);
|
|
589
612
|
// `agent.messages` would only expose the folded API context.
|
|
590
613
|
const msgs = agentRef.current.displayMessages.map(snapshotMessage);
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
// from re-appending the pre-refresh chunk on top of the snapshot
|
|
596
|
-
// (first-word duplication). Updates arriving after the pull start fresh
|
|
597
|
-
// windows and append on top of the snapshot. See
|
|
598
|
-
// docs/specs/core/stream-content-updates.md.
|
|
599
|
-
updateMessages.cancel();
|
|
614
|
+
// The snapshot makes the SDK state authoritative: with no throttle
|
|
615
|
+
// window there is nothing pending to cancel — every pre-pull update is
|
|
616
|
+
// already applied and contained in `msgs`, and post-pull deltas append
|
|
617
|
+
// on top of the snapshot. See docs/specs/core/stream-content-updates.md.
|
|
600
618
|
setMessages(msgs);
|
|
601
619
|
setLatestTotalTokens(extractLatestTotalTokens(msgs));
|
|
602
620
|
}
|
|
603
|
-
}, [
|
|
621
|
+
}, []);
|
|
604
622
|
|
|
605
623
|
// Permission confirmation methods with queue support
|
|
606
624
|
const showConfirmation = useCallback(
|
|
@@ -614,8 +632,38 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
614
632
|
warning?: string,
|
|
615
633
|
outsideSafeZoneDirectory?: string,
|
|
616
634
|
): Promise<PermissionDecision> => {
|
|
635
|
+
const requestId = `cli_perm_${++permissionCounterRef.current}`;
|
|
617
636
|
return new Promise<PermissionDecision>((resolve, reject) => {
|
|
637
|
+
const context: ToolPermissionContext = {
|
|
638
|
+
toolName,
|
|
639
|
+
...(toolInput ? { toolInput } : {}),
|
|
640
|
+
...(suggestedPrefix ? { suggestedPrefix } : {}),
|
|
641
|
+
...(hidePersistentOption !== undefined
|
|
642
|
+
? { hidePersistentOption }
|
|
643
|
+
: {}),
|
|
644
|
+
...(outsideSafeZoneDirectory ? { outsideSafeZoneDirectory } : {}),
|
|
645
|
+
...(planContent ? { planContent } : {}),
|
|
646
|
+
// Required on the context, and the phone renders it: which mode this
|
|
647
|
+
// request is being judged under.
|
|
648
|
+
permissionMode: permissionMode ?? "default",
|
|
649
|
+
...(warning ? { warning } : {}),
|
|
650
|
+
};
|
|
651
|
+
// Settling through the map is what keeps the phone's list honest: an
|
|
652
|
+
// approval answered at the terminal must vanish from the phone too.
|
|
653
|
+
const settle = (decision: PermissionDecision) => {
|
|
654
|
+
remotePendingRef.current.delete(requestId);
|
|
655
|
+
syncRemotePending();
|
|
656
|
+
resolve(decision);
|
|
657
|
+
};
|
|
658
|
+
const cancel = () => {
|
|
659
|
+
remotePendingRef.current.delete(requestId);
|
|
660
|
+
syncRemotePending();
|
|
661
|
+
reject();
|
|
662
|
+
};
|
|
663
|
+
remotePendingRef.current.set(requestId, { context, settle });
|
|
664
|
+
syncRemotePending();
|
|
618
665
|
const queueItem = {
|
|
666
|
+
requestId,
|
|
619
667
|
toolName,
|
|
620
668
|
toolInput,
|
|
621
669
|
suggestedPrefix,
|
|
@@ -624,15 +672,15 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
624
672
|
planContent,
|
|
625
673
|
permissionMode,
|
|
626
674
|
warning,
|
|
627
|
-
resolver:
|
|
628
|
-
reject,
|
|
675
|
+
resolver: settle,
|
|
676
|
+
reject: cancel,
|
|
629
677
|
};
|
|
630
678
|
|
|
631
679
|
setConfirmationQueue((prev) => [...prev, queueItem]);
|
|
632
680
|
// processNextConfirmation will be called via useEffect
|
|
633
681
|
});
|
|
634
682
|
},
|
|
635
|
-
[],
|
|
683
|
+
[syncRemotePending],
|
|
636
684
|
);
|
|
637
685
|
|
|
638
686
|
// Initialize AI manager
|
|
@@ -641,10 +689,23 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
641
689
|
const effectiveRestoreSessionId =
|
|
642
690
|
restoreSessionIdOverride ?? restoreSessionId;
|
|
643
691
|
|
|
692
|
+
// The remote path's copy of the payload-carrying frames (see
|
|
693
|
+
// `remoteNotifications`: the streaming trio plus the error block), emitted
|
|
694
|
+
// *beside* the local state updates — never instead of them, and outside the
|
|
695
|
+
// `isExpanded` guards below, because the TUI's own view mode is not the
|
|
696
|
+
// phone's. Built only while a sink exists, so a process with remote control
|
|
697
|
+
// off allocates nothing per chunk.
|
|
698
|
+
const remoteNotify = remoteNotifyRef.current;
|
|
699
|
+
const remoteStream = remoteNotify
|
|
700
|
+
? remoteNotifications(remoteNotify)
|
|
701
|
+
: undefined;
|
|
702
|
+
|
|
644
703
|
const callbacks: AgentCallbacks = {
|
|
645
704
|
// ── Incremental message updates (no full-list pushes) ──────
|
|
646
|
-
//
|
|
647
|
-
//
|
|
705
|
+
// Every update applies immediately via setMessages — no data-layer
|
|
706
|
+
// throttling (removed 2026-10-15, see docs/specs/core/stream-content-updates.md).
|
|
707
|
+
// Arrival order = application order; terminal write frequency is
|
|
708
|
+
// bounded by Ink's built-in maxFps:30.
|
|
648
709
|
onUserMessageAdded: () => {
|
|
649
710
|
if (isExpandedRef.current || !agentRef.current) return;
|
|
650
711
|
const msgs = agentRef.current.messages;
|
|
@@ -654,12 +715,11 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
654
715
|
// the batch flush, so evaluating snapshotMessage inside the updater
|
|
655
716
|
// would read the SDK message after its in-place mutations.
|
|
656
717
|
const snapshot = snapshotMessage(last);
|
|
657
|
-
|
|
718
|
+
// One-shot structural update — applied directly (a window would
|
|
719
|
+
// only delay the message card, no coalescing value).
|
|
720
|
+
setMessages((prev) =>
|
|
658
721
|
prev.some((m) => m.id === last.id) ? prev : [...prev, snapshot],
|
|
659
722
|
);
|
|
660
|
-
// One-shot structural update — flush immediately so the message
|
|
661
|
-
// card appears at once (queuing it gains no coalescing).
|
|
662
|
-
updateMessages.flush();
|
|
663
723
|
},
|
|
664
724
|
onAssistantMessageAdded: (messageId: string) => {
|
|
665
725
|
if (isExpandedRef.current || !agentRef.current) return;
|
|
@@ -669,29 +729,43 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
669
729
|
// fires this callback BEFORE the first delta writes into the shared
|
|
670
730
|
// message, so capturing here copies the pre-mutation (empty) blocks.
|
|
671
731
|
const snapshot = snapshotMessage(msg);
|
|
672
|
-
|
|
732
|
+
setMessages((prev) =>
|
|
673
733
|
prev.some((m) => m.id === messageId) ? prev : [...prev, snapshot],
|
|
674
734
|
);
|
|
675
|
-
|
|
735
|
+
},
|
|
736
|
+
onAssistantMessageDiscarded: (messageId: string) => {
|
|
737
|
+
// The streamed attempt died mid-body and was re-issued without
|
|
738
|
+
// streaming; its half message is void (spec: 流式响应正文中途断连时
|
|
739
|
+
// 降级为非流式重发).
|
|
740
|
+
if (isExpandedRef.current) return;
|
|
741
|
+
setMessages((prev) => prev.filter((m) => m.id !== messageId));
|
|
676
742
|
},
|
|
677
743
|
onAssistantContentUpdated: (params) => {
|
|
744
|
+
remoteStream?.onAssistantContentUpdated(params);
|
|
678
745
|
if (isExpandedRef.current) return;
|
|
679
|
-
|
|
680
|
-
|
|
746
|
+
// Text delta: height grows monotonically → per-delta application.
|
|
747
|
+
// No flush on end: this channel has no pending updates, and the
|
|
748
|
+
// tool-running window must not be cut short by a text end.
|
|
749
|
+
setMessages((prev) => applyContentDelta(prev, params));
|
|
681
750
|
},
|
|
682
751
|
onAssistantReasoningUpdated: (params) => {
|
|
752
|
+
remoteStream?.onAssistantReasoningUpdated(params);
|
|
683
753
|
if (isExpandedRef.current) return;
|
|
684
|
-
|
|
685
|
-
if (params.stage === "end") updateMessages.flush();
|
|
754
|
+
setMessages((prev) => applyReasoningDelta(prev, params));
|
|
686
755
|
},
|
|
687
756
|
onToolBlockUpdated: (params) => {
|
|
757
|
+
remoteStream?.onToolBlockUpdated(params);
|
|
688
758
|
if (isExpandedRef.current) return;
|
|
689
|
-
|
|
690
|
-
|
|
759
|
+
// All stages (start/streaming/running/end) apply directly —
|
|
760
|
+
// `end` carries the authoritative final values.
|
|
761
|
+
setMessages((prev) => applyToolBlockUpdate(prev, params));
|
|
691
762
|
},
|
|
692
763
|
onErrorBlockAdded: (error: string) => {
|
|
764
|
+
remoteStream?.onErrorBlockAdded(error);
|
|
693
765
|
if (isExpandedRef.current) return;
|
|
694
|
-
|
|
766
|
+
// One-shot structural update — applied directly (errors must not be
|
|
767
|
+
// delayed by the tool-running window).
|
|
768
|
+
setMessages((prev) => {
|
|
695
769
|
// Append to the LAST message only if it is an assistant message
|
|
696
770
|
// (the current turn's in-flight reply). If the last message is a
|
|
697
771
|
// user message, the error belongs BELOW it — create a new
|
|
@@ -719,9 +793,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
719
793
|
},
|
|
720
794
|
];
|
|
721
795
|
});
|
|
722
|
-
// One-shot structural update — flush immediately (errors must not be
|
|
723
|
-
// delayed by a streaming window).
|
|
724
|
-
updateMessages.flush();
|
|
725
796
|
},
|
|
726
797
|
onLatestTotalTokensChange: (tokens) => {
|
|
727
798
|
setLatestTotalTokens(tokens);
|
|
@@ -828,6 +899,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
828
899
|
isNewWorktree: worktreeSession?.isNew,
|
|
829
900
|
model,
|
|
830
901
|
mcpServers,
|
|
902
|
+
// This session was created from the interactive CLI → `app.entrypoint`.
|
|
903
|
+
entrypoint: "cli",
|
|
831
904
|
});
|
|
832
905
|
|
|
833
906
|
agentRef.current = agent;
|
|
@@ -910,7 +983,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
910
983
|
originalCwd,
|
|
911
984
|
model,
|
|
912
985
|
initialPermissionMode,
|
|
913
|
-
updateMessages,
|
|
914
986
|
mcpServers,
|
|
915
987
|
],
|
|
916
988
|
);
|
|
@@ -968,7 +1040,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
968
1040
|
// Cleanup on unmount
|
|
969
1041
|
useEffect(() => {
|
|
970
1042
|
return () => {
|
|
971
|
-
updateMessages.cancel();
|
|
972
1043
|
if (agentRef.current) {
|
|
973
1044
|
try {
|
|
974
1045
|
// Display usage summary before cleanup
|
|
@@ -982,7 +1053,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
982
1053
|
agentRef.current.destroy();
|
|
983
1054
|
}
|
|
984
1055
|
};
|
|
985
|
-
}, [
|
|
1056
|
+
}, []);
|
|
986
1057
|
|
|
987
1058
|
// Send message function (including judgment logic)
|
|
988
1059
|
const sendMessage = useCallback(
|
|
@@ -1334,6 +1405,165 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1334
1405
|
hideConfirmation();
|
|
1335
1406
|
}, [currentConfirmation, hideConfirmation]);
|
|
1336
1407
|
|
|
1408
|
+
// Mirrors for the remote-control host: it outlives any single render, so it
|
|
1409
|
+
// must read the confirmation showing *now* rather than the one captured when
|
|
1410
|
+
// it was built.
|
|
1411
|
+
const currentConfirmationRef = useRef<typeof currentConfirmation>(null);
|
|
1412
|
+
useEffect(() => {
|
|
1413
|
+
currentConfirmationRef.current = currentConfirmation;
|
|
1414
|
+
}, [currentConfirmation]);
|
|
1415
|
+
const hideConfirmationRef = useRef(hideConfirmation);
|
|
1416
|
+
useEffect(() => {
|
|
1417
|
+
hideConfirmationRef.current = hideConfirmation;
|
|
1418
|
+
}, [hideConfirmation]);
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Answer an approval from the phone. It settles the same promise the terminal
|
|
1422
|
+
* is waiting on, so this advances the session exactly as pressing the key
|
|
1423
|
+
* would — and if that request is the one on screen, the dialog goes away.
|
|
1424
|
+
*/
|
|
1425
|
+
const respondPermission = useCallback(
|
|
1426
|
+
(requestId: string, decision: PermissionDecision) => {
|
|
1427
|
+
const entry = remotePendingRef.current.get(requestId);
|
|
1428
|
+
if (!entry) return;
|
|
1429
|
+
entry.settle(decision);
|
|
1430
|
+
if (currentConfirmationRef.current?.requestId === requestId) {
|
|
1431
|
+
hideConfirmationRef.current();
|
|
1432
|
+
}
|
|
1433
|
+
},
|
|
1434
|
+
[],
|
|
1435
|
+
);
|
|
1436
|
+
|
|
1437
|
+
const getAvailableTools = useCallback(
|
|
1438
|
+
() => agentRef.current?.getAvailableToolNames() ?? [],
|
|
1439
|
+
[],
|
|
1440
|
+
);
|
|
1441
|
+
|
|
1442
|
+
// Serve this foreground session to a phone, if the switch allows it. The hook
|
|
1443
|
+
// owns the outbound connection; it reads current state through a ref, so
|
|
1444
|
+
// passing the values here (rather than wiring callbacks into every setter) is
|
|
1445
|
+
// what keeps this the whole of the integration.
|
|
1446
|
+
const remoteControl = useRemoteControl({
|
|
1447
|
+
workdir: workdir ?? process.cwd(),
|
|
1448
|
+
sessionId,
|
|
1449
|
+
messages,
|
|
1450
|
+
isLoading,
|
|
1451
|
+
permissionMode,
|
|
1452
|
+
latestTotalTokens,
|
|
1453
|
+
maxInputTokens,
|
|
1454
|
+
// 手机侧会话列表的标题材料:`customTitle` 不走 React state(agent 自己存着,
|
|
1455
|
+
// 重命名后即刻生效),所以按「取当前值」的函数递给门面。
|
|
1456
|
+
customTitle: () => agentRef.current?.customTitle,
|
|
1457
|
+
hasRunningBackgroundWork: backgroundTasks.length > 0,
|
|
1458
|
+
hasPendingMessages: queuedMessages.length > 0,
|
|
1459
|
+
// 场景 26 的会话状态族:远控页按状态重读 `getSessionInfo`,这四个是它的
|
|
1460
|
+
// 当前值(回调本就把它们写进 state,这里原样递给门面即可)。
|
|
1461
|
+
tasks,
|
|
1462
|
+
backgroundTasks,
|
|
1463
|
+
isCompacting,
|
|
1464
|
+
isCommandRunning,
|
|
1465
|
+
queuedMessages,
|
|
1466
|
+
pending: remotePending,
|
|
1467
|
+
availableTools: getAvailableTools,
|
|
1468
|
+
sendMessage: (text) => sendMessage(text),
|
|
1469
|
+
abort: () => agentRef.current?.abortMessage(),
|
|
1470
|
+
setPermissionMode,
|
|
1471
|
+
respondPermission,
|
|
1472
|
+
// 会话自身的维护动作(重命名 / 压缩 / 指令清单):页面能直接用,且都由这一份
|
|
1473
|
+
// 会话状态来答,所以给的是「取当前值」的函数。重命名走 agent 而非 /rename 命令,
|
|
1474
|
+
// 是为了让失败以 RPC 错误回给页面(TUI 那条路径把错误渲染成一条消息)。
|
|
1475
|
+
renameSession: (title) => agentRef.current?.renameSession(title),
|
|
1476
|
+
compact: (instructions) => compact(instructions),
|
|
1477
|
+
slashCommands: () => agentRef.current?.getSlashCommands() ?? [],
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
// `initializeAgent` reads this ref when it runs, which is always in an effect
|
|
1481
|
+
// — after this render. `notify` is stable (the hook builds it once, over a sink
|
|
1482
|
+
// it creates once), so assigning it during render is enough to have it in place
|
|
1483
|
+
// before anything can call the agent.
|
|
1484
|
+
remoteNotifyRef.current = remoteControl.notify;
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* `/remote-control [on|off|status] [name]` — offer this terminal's session to
|
|
1488
|
+
* a phone. It is deliberately *not* a front end for `wave remote-control`:
|
|
1489
|
+
* that subcommand drives the daemon, and this session exists only in this
|
|
1490
|
+
* process, so the daemon could not serve it (spec: remote-control.md,
|
|
1491
|
+
* "前台 CLI 会话也能被手机接管").
|
|
1492
|
+
*/
|
|
1493
|
+
const remoteControlCommand = useCallback(
|
|
1494
|
+
async (args?: string) => {
|
|
1495
|
+
const showResult = (content: string) => {
|
|
1496
|
+
setMessages((prev) => [
|
|
1497
|
+
...prev,
|
|
1498
|
+
{
|
|
1499
|
+
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
1500
|
+
role: "assistant",
|
|
1501
|
+
timestamp: new Date().toISOString(),
|
|
1502
|
+
blocks: [{ type: "text", content, stage: "end" }],
|
|
1503
|
+
},
|
|
1504
|
+
]);
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
1508
|
+
const action = parts[0]?.toLowerCase() ?? "";
|
|
1509
|
+
if (
|
|
1510
|
+
action &&
|
|
1511
|
+
action !== "on" &&
|
|
1512
|
+
action !== "off" &&
|
|
1513
|
+
action !== "status"
|
|
1514
|
+
) {
|
|
1515
|
+
showResult("Usage: /remote-control [on|off|status] [name]");
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (!remoteControl.enabled) {
|
|
1520
|
+
showResult(
|
|
1521
|
+
'Remote control is unavailable in this process. Set "enableRemoteControl": true in ~/.wave/settings.json (or .wave/settings.json in the project), then restart the CLI — a process that is already running keeps the settings it started with.',
|
|
1522
|
+
);
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
if (action === "off") {
|
|
1527
|
+
remoteControl.disconnect();
|
|
1528
|
+
showResult(
|
|
1529
|
+
"Remote control is off — this terminal's session is no longer served.",
|
|
1530
|
+
);
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const current = remoteControl.getSnapshot();
|
|
1535
|
+
const serving =
|
|
1536
|
+
current.state === "online" || current.state === "connecting";
|
|
1537
|
+
// A bare `/remote-control` means "start serving" the first time and
|
|
1538
|
+
// "what is it doing?" afterwards, so a second one cannot silently dial
|
|
1539
|
+
// again and cannot hide an address the user still needs.
|
|
1540
|
+
if (action === "status" || (!action && serving)) {
|
|
1541
|
+
showResult(
|
|
1542
|
+
current.pageUrl
|
|
1543
|
+
? await describeRemotePage(current.pageUrl)
|
|
1544
|
+
: `Remote control status: ${current.state}${
|
|
1545
|
+
current.message ? ` — ${current.message}` : ""
|
|
1546
|
+
}`,
|
|
1547
|
+
);
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
remoteControl.connect(parts[1]);
|
|
1552
|
+
const pageUrl = await waitForRemotePageUrl(remoteControl);
|
|
1553
|
+
const after = remoteControl.getSnapshot();
|
|
1554
|
+
if (!pageUrl) {
|
|
1555
|
+
showResult(
|
|
1556
|
+
after.state === "error"
|
|
1557
|
+
? `Remote control failed: ${after.message ?? "unknown error"}`
|
|
1558
|
+
: "Connected to the relay, but no page address came back yet — run /remote-control status in a moment.",
|
|
1559
|
+
);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
showResult(await describeRemotePage(pageUrl));
|
|
1563
|
+
},
|
|
1564
|
+
[remoteControl],
|
|
1565
|
+
);
|
|
1566
|
+
|
|
1337
1567
|
const backgroundCurrentTask = useCallback(() => {
|
|
1338
1568
|
agentRef.current?.backgroundCurrentTask();
|
|
1339
1569
|
}, []);
|
|
@@ -1462,6 +1692,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1462
1692
|
hooks,
|
|
1463
1693
|
permissionMode,
|
|
1464
1694
|
setPermissionMode,
|
|
1695
|
+
remoteControl,
|
|
1696
|
+
remoteControlCommand,
|
|
1465
1697
|
planView,
|
|
1466
1698
|
setPlanView,
|
|
1467
1699
|
handlePlanCommand,
|