wave-code 1.2.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 +1738 -893
- package/package.json +6 -3
- package/src/cli.tsx +9 -0
- package/src/commands/plugin/install.ts +6 -7
- package/src/commands/plugin/uninstall.ts +11 -5
- package/src/commands/update.ts +62 -22
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/ChatInterface.tsx +1 -0
- package/src/components/ConfirmationDetails.tsx +51 -16
- package/src/components/ConfirmationSelector.tsx +39 -0
- package/src/components/InputBox.tsx +41 -2
- package/src/components/Markdown.tsx +21 -8
- package/src/components/MarketplaceDetail.tsx +10 -27
- package/src/components/MarketplaceView.tsx +17 -3
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/Notifications.tsx +7 -0
- package/src/components/PlanView.tsx +22 -17
- package/src/components/PluginManagerShell.tsx +3 -3
- package/src/components/PluginManagerTypes.ts +2 -1
- package/src/components/ResumeCommand.tsx +84 -0
- package/src/components/ScrolledContent.tsx +71 -0
- package/src/components/SessionSelector.tsx +184 -6
- package/src/components/SessionSelectorFlow.tsx +246 -0
- package/src/components/WorkflowManager.tsx +38 -138
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +24 -0
- package/src/contexts/useChat.tsx +517 -155
- package/src/daemon/commands.ts +582 -50
- package/src/daemon-cli.ts +12 -1
- package/src/hooks/useInputManager.ts +20 -0
- package/src/hooks/usePluginManager.ts +104 -78
- package/src/hooks/useRemoteControl.ts +324 -0
- package/src/index.ts +97 -8
- package/src/managers/inputHandlers.ts +16 -9
- package/src/managers/inputReducer.ts +30 -9
- package/src/print-cli.ts +5 -0
- package/src/reducers/confirmationReducer.ts +20 -1
- package/src/reducers/selectorReducer.ts +3 -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/session-selector-cli.tsx +9 -255
- package/src/stdio/agentBridge.ts +1001 -165
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -106
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio-cli.ts +16 -1
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/sessionOwnership.ts +62 -0
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/components/PlanDisplay.tsx +0 -18
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,
|
|
@@ -31,14 +34,67 @@ import {
|
|
|
31
34
|
type WorktreeSession,
|
|
32
35
|
OPERATION_CANCELLED_BY_USER,
|
|
33
36
|
extractLatestTotalTokens,
|
|
37
|
+
PLUGIN_CHANGE_PENDING_MESSAGE,
|
|
38
|
+
PLUGIN_RELOADED_MESSAGE,
|
|
34
39
|
} from "wave-agent-sdk";
|
|
40
|
+
import { toString as qrToString } from "qrcode";
|
|
35
41
|
import { logger } from "../utils/logger.js";
|
|
36
42
|
import { displayUsageSummary } from "../utils/usageSummary.js";
|
|
37
43
|
import { expandLongTextPlaceholders } from "../managers/inputHandlers.js";
|
|
44
|
+
import { remoteNotifications } from "../remote/remoteNotifications.js";
|
|
45
|
+
import type { RemoteNotify } from "../remote/remoteNotifications.js";
|
|
38
46
|
import { readFile } from "node:fs/promises";
|
|
39
47
|
|
|
40
48
|
import { BaseAppProps } from "../types.js";
|
|
41
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
|
+
|
|
42
98
|
// Main Chat Context
|
|
43
99
|
export interface PlanViewData {
|
|
44
100
|
path?: string;
|
|
@@ -77,6 +133,17 @@ export interface ChatContextType {
|
|
|
77
133
|
clearMessages: () => Promise<void>;
|
|
78
134
|
compact: (instructions?: string) => Promise<void>;
|
|
79
135
|
addDir: (args?: string) => Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* Set the current session's custom title (`/rename <title>`). A blank title
|
|
138
|
+
* only prints the usage hint — no AI-generated titles (spec: session-management.md).
|
|
139
|
+
*/
|
|
140
|
+
renameSession: (args?: string) => Promise<void>;
|
|
141
|
+
/**
|
|
142
|
+
* Switch to another conversation in-process (`/resume`). Pass `resumeWorkdir`
|
|
143
|
+
* when the target session lives in a sibling worktree of the same repo — the
|
|
144
|
+
* session then moves into that directory too.
|
|
145
|
+
*/
|
|
146
|
+
resumeSession: (sessionId: string, resumeWorkdir?: string) => Promise<void>;
|
|
80
147
|
abortMessage: () => void;
|
|
81
148
|
recallQueuedMessage: () => QueuedMessage | null;
|
|
82
149
|
removeQueuedMessageById: (id: string) => boolean;
|
|
@@ -119,6 +186,19 @@ export interface ChatContextType {
|
|
|
119
186
|
// Permission functionality
|
|
120
187
|
permissionMode: PermissionMode;
|
|
121
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>;
|
|
122
202
|
// /plan overlay state + handler
|
|
123
203
|
planView: PlanViewData | null;
|
|
124
204
|
setPlanView: (view: PlanViewData | null) => void;
|
|
@@ -131,6 +211,7 @@ export interface ChatContextType {
|
|
|
131
211
|
input?: Record<string, unknown>;
|
|
132
212
|
suggestedPrefix?: string;
|
|
133
213
|
hidePersistentOption?: boolean;
|
|
214
|
+
outsideSafeZoneDirectory?: string;
|
|
134
215
|
planContent?: string;
|
|
135
216
|
permissionMode?: PermissionMode;
|
|
136
217
|
warning?: string;
|
|
@@ -143,6 +224,7 @@ export interface ChatContextType {
|
|
|
143
224
|
planContent?: string,
|
|
144
225
|
permissionMode?: PermissionMode,
|
|
145
226
|
warning?: string,
|
|
227
|
+
outsideSafeZoneDirectory?: string,
|
|
146
228
|
) => Promise<PermissionDecision>;
|
|
147
229
|
hideConfirmation: () => void;
|
|
148
230
|
handleConfirmationDecision: (decision: PermissionDecision) => void;
|
|
@@ -164,8 +246,10 @@ export interface ChatContextType {
|
|
|
164
246
|
workingDirectory: string;
|
|
165
247
|
version?: string;
|
|
166
248
|
workdir?: string;
|
|
167
|
-
//
|
|
168
|
-
|
|
249
|
+
// Plugin in-place reload (docs/specs/ecosystem/plugin.md「插件变更的就地重载」)
|
|
250
|
+
reloadPlugins: () => Promise<void>;
|
|
251
|
+
notifyPluginChange: () => void;
|
|
252
|
+
pluginHint: string | null;
|
|
169
253
|
}
|
|
170
254
|
|
|
171
255
|
const ChatContext = createContext<ChatContextType | null>(null);
|
|
@@ -332,81 +416,6 @@ const applyToolBlockUpdate = (
|
|
|
332
416
|
});
|
|
333
417
|
};
|
|
334
418
|
|
|
335
|
-
/**
|
|
336
|
-
* Single throttled updater entry for ALL message-state updates. The one
|
|
337
|
-
* 500ms window-concat window replaces the previous three-channel throttles
|
|
338
|
-
* (content/reasoning/tool), so every message-state update — including the
|
|
339
|
-
* tool `running` stage's high-frequency `shortResult`/`result` updates from
|
|
340
|
-
* bash — coalesces into the same window.
|
|
341
|
-
*
|
|
342
|
-
* Semantics: leading edge applies immediately and opens a window; updates
|
|
343
|
-
* arriving within the window are queued in arrival order (FIFO) and applied
|
|
344
|
-
* at the trailing edge as ONE composed updater, so no update is lost and no
|
|
345
|
-
* update is reordered. `flush` applies queued updates immediately (used by
|
|
346
|
-
* end signals and one-shot structural updates); `cancel` drops queued
|
|
347
|
-
* updates (used by refreshMessages after pulling the authoritative snapshot —
|
|
348
|
-
* the queued updates are already contained in it).
|
|
349
|
-
*
|
|
350
|
-
* The FIFO queue structurally replaces the old tool throttle's "drop a
|
|
351
|
-
* tool's buffered streaming deltas when running arrives" logic: a running
|
|
352
|
-
* updater is queued BEHIND the streaming chunks that preceded it and applies
|
|
353
|
-
* after them, so a stale streaming event can never flush after running and
|
|
354
|
-
* regress the stage (yellow dot -> gray). See
|
|
355
|
-
* docs/specs/core/stream-content-updates.md.
|
|
356
|
-
*/
|
|
357
|
-
export function createThrottledUpdater<T>(
|
|
358
|
-
apply: (updater: (prev: T) => T) => void,
|
|
359
|
-
wait: number,
|
|
360
|
-
): {
|
|
361
|
-
(updater: (prev: T) => T): void;
|
|
362
|
-
cancel: () => void;
|
|
363
|
-
flush: () => void;
|
|
364
|
-
} {
|
|
365
|
-
let timer: NodeJS.Timeout | null = null;
|
|
366
|
-
let queued: Array<(prev: T) => T> = [];
|
|
367
|
-
|
|
368
|
-
const fire = () => {
|
|
369
|
-
if (queued.length === 0) return;
|
|
370
|
-
const batch = queued;
|
|
371
|
-
queued = [];
|
|
372
|
-
// Compose the whole batch into a single updater applied in FIFO order —
|
|
373
|
-
// one state commit per trailing edge, no update dropped, no reordering.
|
|
374
|
-
apply((prev) => batch.reduce((acc, upd) => upd(acc), prev));
|
|
375
|
-
};
|
|
376
|
-
|
|
377
|
-
const throttled = (updater: (prev: T) => T) => {
|
|
378
|
-
if (timer) {
|
|
379
|
-
queued.push(updater);
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
// Leading edge: apply immediately, then open a window whose trailing
|
|
383
|
-
// edge only carries updates arriving within the window.
|
|
384
|
-
apply(updater);
|
|
385
|
-
timer = setTimeout(() => {
|
|
386
|
-
timer = null;
|
|
387
|
-
fire();
|
|
388
|
-
}, wait);
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
throttled.cancel = () => {
|
|
392
|
-
if (timer) {
|
|
393
|
-
clearTimeout(timer);
|
|
394
|
-
timer = null;
|
|
395
|
-
}
|
|
396
|
-
queued = [];
|
|
397
|
-
};
|
|
398
|
-
|
|
399
|
-
throttled.flush = () => {
|
|
400
|
-
if (timer) {
|
|
401
|
-
clearTimeout(timer);
|
|
402
|
-
timer = null;
|
|
403
|
-
}
|
|
404
|
-
fire();
|
|
405
|
-
};
|
|
406
|
-
|
|
407
|
-
return throttled;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
419
|
export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
411
420
|
children,
|
|
412
421
|
bypassPermissions,
|
|
@@ -437,26 +446,16 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
437
446
|
const [latestTotalTokens, setLatestTotalTokens] = useState(0);
|
|
438
447
|
const [maxInputTokens, setMaxInputTokens] = useState(200000);
|
|
439
448
|
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
// window. See createThrottledUpdater + docs/specs/core/stream-content-updates.md.
|
|
447
|
-
const updateMessages = useMemo(
|
|
448
|
-
() =>
|
|
449
|
-
createThrottledUpdater<Message[]>((updater) => setMessages(updater), 500),
|
|
450
|
-
[],
|
|
451
|
-
);
|
|
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 会议).
|
|
452
455
|
|
|
453
456
|
useEffect(() => {
|
|
454
457
|
isExpandedRef.current = isExpanded;
|
|
455
|
-
|
|
456
|
-
// Cancel pending throttled updates so the frozen expanded view isn't overwritten
|
|
457
|
-
updateMessages.cancel();
|
|
458
|
-
}
|
|
459
|
-
}, [isExpanded, updateMessages]);
|
|
458
|
+
}, [isExpanded]);
|
|
460
459
|
|
|
461
460
|
const [isLoading, setIsLoading] = useState(false);
|
|
462
461
|
const [sessionId, setSessionId] = useState("");
|
|
@@ -512,6 +511,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
512
511
|
input?: Record<string, unknown>;
|
|
513
512
|
suggestedPrefix?: string;
|
|
514
513
|
hidePersistentOption?: boolean;
|
|
514
|
+
outsideSafeZoneDirectory?: string;
|
|
515
515
|
planContent?: string;
|
|
516
516
|
permissionMode?: PermissionMode;
|
|
517
517
|
warning?: string;
|
|
@@ -520,10 +520,12 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
520
520
|
>();
|
|
521
521
|
const [confirmationQueue, setConfirmationQueue] = useState<
|
|
522
522
|
Array<{
|
|
523
|
+
requestId: string;
|
|
523
524
|
toolName: string;
|
|
524
525
|
toolInput?: Record<string, unknown>;
|
|
525
526
|
suggestedPrefix?: string;
|
|
526
527
|
hidePersistentOption?: boolean;
|
|
528
|
+
outsideSafeZoneDirectory?: string;
|
|
527
529
|
planContent?: string;
|
|
528
530
|
permissionMode?: PermissionMode;
|
|
529
531
|
warning?: string;
|
|
@@ -532,10 +534,12 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
532
534
|
}>
|
|
533
535
|
>([]);
|
|
534
536
|
const [currentConfirmation, setCurrentConfirmation] = useState<{
|
|
537
|
+
requestId: string;
|
|
535
538
|
toolName: string;
|
|
536
539
|
toolInput?: Record<string, unknown>;
|
|
537
540
|
suggestedPrefix?: string;
|
|
538
541
|
hidePersistentOption?: boolean;
|
|
542
|
+
outsideSafeZoneDirectory?: string;
|
|
539
543
|
planContent?: string;
|
|
540
544
|
permissionMode?: PermissionMode;
|
|
541
545
|
warning?: string;
|
|
@@ -543,6 +547,37 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
543
547
|
reject: () => void;
|
|
544
548
|
} | null>(null);
|
|
545
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
|
+
|
|
546
581
|
// Remount state
|
|
547
582
|
const [remountKey, setRemountKey] = useState(0);
|
|
548
583
|
|
|
@@ -560,6 +595,14 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
560
595
|
|
|
561
596
|
const agentRef = useRef<Agent | null>(null);
|
|
562
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
|
+
|
|
563
606
|
// Full-list refresh — one-shot pull from the agent, used only for structural
|
|
564
607
|
// changes (compact/clear/rewind/collapse/init). Streaming updates flow through
|
|
565
608
|
// the incremental callbacks in initializeAgent below.
|
|
@@ -568,19 +611,14 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
568
611
|
// Pull the full UI display stream (keeps pre-compaction history);
|
|
569
612
|
// `agent.messages` would only expose the folded API context.
|
|
570
613
|
const msgs = agentRef.current.displayMessages.map(snapshotMessage);
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
// from re-appending the pre-refresh chunk on top of the snapshot
|
|
576
|
-
// (first-word duplication). Updates arriving after the pull start fresh
|
|
577
|
-
// windows and append on top of the snapshot. See
|
|
578
|
-
// docs/specs/core/stream-content-updates.md.
|
|
579
|
-
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.
|
|
580
618
|
setMessages(msgs);
|
|
581
619
|
setLatestTotalTokens(extractLatestTotalTokens(msgs));
|
|
582
620
|
}
|
|
583
|
-
}, [
|
|
621
|
+
}, []);
|
|
584
622
|
|
|
585
623
|
// Permission confirmation methods with queue support
|
|
586
624
|
const showConfirmation = useCallback(
|
|
@@ -592,25 +630,57 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
592
630
|
planContent?: string,
|
|
593
631
|
permissionMode?: PermissionMode,
|
|
594
632
|
warning?: string,
|
|
633
|
+
outsideSafeZoneDirectory?: string,
|
|
595
634
|
): Promise<PermissionDecision> => {
|
|
635
|
+
const requestId = `cli_perm_${++permissionCounterRef.current}`;
|
|
596
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();
|
|
597
665
|
const queueItem = {
|
|
666
|
+
requestId,
|
|
598
667
|
toolName,
|
|
599
668
|
toolInput,
|
|
600
669
|
suggestedPrefix,
|
|
601
670
|
hidePersistentOption,
|
|
671
|
+
outsideSafeZoneDirectory,
|
|
602
672
|
planContent,
|
|
603
673
|
permissionMode,
|
|
604
674
|
warning,
|
|
605
|
-
resolver:
|
|
606
|
-
reject,
|
|
675
|
+
resolver: settle,
|
|
676
|
+
reject: cancel,
|
|
607
677
|
};
|
|
608
678
|
|
|
609
679
|
setConfirmationQueue((prev) => [...prev, queueItem]);
|
|
610
680
|
// processNextConfirmation will be called via useEffect
|
|
611
681
|
});
|
|
612
682
|
},
|
|
613
|
-
[],
|
|
683
|
+
[syncRemotePending],
|
|
614
684
|
);
|
|
615
685
|
|
|
616
686
|
// Initialize AI manager
|
|
@@ -619,10 +689,23 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
619
689
|
const effectiveRestoreSessionId =
|
|
620
690
|
restoreSessionIdOverride ?? restoreSessionId;
|
|
621
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
|
+
|
|
622
703
|
const callbacks: AgentCallbacks = {
|
|
623
704
|
// ── Incremental message updates (no full-list pushes) ──────
|
|
624
|
-
//
|
|
625
|
-
//
|
|
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.
|
|
626
709
|
onUserMessageAdded: () => {
|
|
627
710
|
if (isExpandedRef.current || !agentRef.current) return;
|
|
628
711
|
const msgs = agentRef.current.messages;
|
|
@@ -632,12 +715,11 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
632
715
|
// the batch flush, so evaluating snapshotMessage inside the updater
|
|
633
716
|
// would read the SDK message after its in-place mutations.
|
|
634
717
|
const snapshot = snapshotMessage(last);
|
|
635
|
-
|
|
718
|
+
// One-shot structural update — applied directly (a window would
|
|
719
|
+
// only delay the message card, no coalescing value).
|
|
720
|
+
setMessages((prev) =>
|
|
636
721
|
prev.some((m) => m.id === last.id) ? prev : [...prev, snapshot],
|
|
637
722
|
);
|
|
638
|
-
// One-shot structural update — flush immediately so the message
|
|
639
|
-
// card appears at once (queuing it gains no coalescing).
|
|
640
|
-
updateMessages.flush();
|
|
641
723
|
},
|
|
642
724
|
onAssistantMessageAdded: (messageId: string) => {
|
|
643
725
|
if (isExpandedRef.current || !agentRef.current) return;
|
|
@@ -647,29 +729,43 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
647
729
|
// fires this callback BEFORE the first delta writes into the shared
|
|
648
730
|
// message, so capturing here copies the pre-mutation (empty) blocks.
|
|
649
731
|
const snapshot = snapshotMessage(msg);
|
|
650
|
-
|
|
732
|
+
setMessages((prev) =>
|
|
651
733
|
prev.some((m) => m.id === messageId) ? prev : [...prev, snapshot],
|
|
652
734
|
);
|
|
653
|
-
|
|
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));
|
|
654
742
|
},
|
|
655
743
|
onAssistantContentUpdated: (params) => {
|
|
744
|
+
remoteStream?.onAssistantContentUpdated(params);
|
|
656
745
|
if (isExpandedRef.current) return;
|
|
657
|
-
|
|
658
|
-
|
|
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));
|
|
659
750
|
},
|
|
660
751
|
onAssistantReasoningUpdated: (params) => {
|
|
752
|
+
remoteStream?.onAssistantReasoningUpdated(params);
|
|
661
753
|
if (isExpandedRef.current) return;
|
|
662
|
-
|
|
663
|
-
if (params.stage === "end") updateMessages.flush();
|
|
754
|
+
setMessages((prev) => applyReasoningDelta(prev, params));
|
|
664
755
|
},
|
|
665
756
|
onToolBlockUpdated: (params) => {
|
|
757
|
+
remoteStream?.onToolBlockUpdated(params);
|
|
666
758
|
if (isExpandedRef.current) return;
|
|
667
|
-
|
|
668
|
-
|
|
759
|
+
// All stages (start/streaming/running/end) apply directly —
|
|
760
|
+
// `end` carries the authoritative final values.
|
|
761
|
+
setMessages((prev) => applyToolBlockUpdate(prev, params));
|
|
669
762
|
},
|
|
670
763
|
onErrorBlockAdded: (error: string) => {
|
|
764
|
+
remoteStream?.onErrorBlockAdded(error);
|
|
671
765
|
if (isExpandedRef.current) return;
|
|
672
|
-
|
|
766
|
+
// One-shot structural update — applied directly (errors must not be
|
|
767
|
+
// delayed by the tool-running window).
|
|
768
|
+
setMessages((prev) => {
|
|
673
769
|
// Append to the LAST message only if it is an assistant message
|
|
674
770
|
// (the current turn's in-flight reply). If the last message is a
|
|
675
771
|
// user message, the error belongs BELOW it — create a new
|
|
@@ -697,9 +793,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
697
793
|
},
|
|
698
794
|
];
|
|
699
795
|
});
|
|
700
|
-
// One-shot structural update — flush immediately (errors must not be
|
|
701
|
-
// delayed by a streaming window).
|
|
702
|
-
updateMessages.flush();
|
|
703
796
|
},
|
|
704
797
|
onLatestTotalTokensChange: (tokens) => {
|
|
705
798
|
setLatestTotalTokens(tokens);
|
|
@@ -775,6 +868,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
775
868
|
context.planContent,
|
|
776
869
|
context.permissionMode,
|
|
777
870
|
context.warning,
|
|
871
|
+
context.outsideSafeZoneDirectory,
|
|
778
872
|
);
|
|
779
873
|
} catch {
|
|
780
874
|
// If confirmation was cancelled or failed, deny the operation
|
|
@@ -805,6 +899,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
805
899
|
isNewWorktree: worktreeSession?.isNew,
|
|
806
900
|
model,
|
|
807
901
|
mcpServers,
|
|
902
|
+
// This session was created from the interactive CLI → `app.entrypoint`.
|
|
903
|
+
entrypoint: "cli",
|
|
808
904
|
});
|
|
809
905
|
|
|
810
906
|
agentRef.current = agent;
|
|
@@ -887,37 +983,54 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
887
983
|
originalCwd,
|
|
888
984
|
model,
|
|
889
985
|
initialPermissionMode,
|
|
890
|
-
updateMessages,
|
|
891
986
|
mcpServers,
|
|
892
987
|
],
|
|
893
988
|
);
|
|
894
989
|
|
|
895
|
-
//
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
990
|
+
// 插件变更落盘后只置一个「待应用」信号并提示一次,不再重建 Agent;真正换装由
|
|
991
|
+
// 用户敲 /reload-plugins 触发,在当前会话内完成(docs/specs/ecosystem/plugin.md
|
|
992
|
+
// 「插件变更的就地重载」:会…不重建、不打断回合、不新建会话)。
|
|
993
|
+
const [pluginHint, setPluginHint] = useState<string | null>(null);
|
|
994
|
+
const pluginHintTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
995
|
+
|
|
996
|
+
const showPluginHint = useCallback((message: string) => {
|
|
997
|
+
setPluginHint(message);
|
|
998
|
+
if (pluginHintTimer.current) {
|
|
999
|
+
clearTimeout(pluginHintTimer.current);
|
|
904
1000
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1001
|
+
// 一次性提示:数秒后自行消失,不留常驻标识(spec「插件变更提示」)。
|
|
1002
|
+
pluginHintTimer.current = setTimeout(() => setPluginHint(null), 8000);
|
|
1003
|
+
}, []);
|
|
1004
|
+
|
|
1005
|
+
const notifyPluginChange = useCallback(() => {
|
|
1006
|
+
showPluginHint(PLUGIN_CHANGE_PENDING_MESSAGE);
|
|
1007
|
+
}, [showPluginHint]);
|
|
1008
|
+
|
|
1009
|
+
const reloadPlugins = useCallback(async () => {
|
|
1010
|
+
const agent = agentRef.current;
|
|
1011
|
+
if (!agent) return;
|
|
1012
|
+
try {
|
|
1013
|
+
const result = await agent.reloadPlugins();
|
|
1014
|
+
// 换装后本进程各会话的能力已变,UI 侧的命令 / 子代理 / 技能列表同步重读。
|
|
1015
|
+
setSlashCommands(agent.getSlashCommands?.() || []);
|
|
1016
|
+
setAgentDefinitions(agent.getSubagentConfigurations?.() || []);
|
|
1017
|
+
setSkills(agent.getSkillMetadata?.() || []);
|
|
1018
|
+
if (result.failures.length > 0) {
|
|
1019
|
+
// 失败不回滚:如实提示,会话继续可用(spec 场景 11)。
|
|
1020
|
+
showPluginHint(
|
|
1021
|
+
`${PLUGIN_RELOADED_MESSAGE}部分插件加载失败:${result.failures
|
|
1022
|
+
.map((failure) => `${failure.path}: ${failure.error}`)
|
|
1023
|
+
.join(";")}`,
|
|
1024
|
+
);
|
|
1025
|
+
} else {
|
|
1026
|
+
showPluginHint(PLUGIN_RELOADED_MESSAGE);
|
|
1027
|
+
}
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
showPluginHint(
|
|
1030
|
+
`插件重载失败:${error instanceof Error ? error.message : String(error)}`,
|
|
1031
|
+
);
|
|
919
1032
|
}
|
|
920
|
-
}, [
|
|
1033
|
+
}, [showPluginHint]);
|
|
921
1034
|
|
|
922
1035
|
// Run initial agent creation
|
|
923
1036
|
useEffect(() => {
|
|
@@ -927,7 +1040,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
927
1040
|
// Cleanup on unmount
|
|
928
1041
|
useEffect(() => {
|
|
929
1042
|
return () => {
|
|
930
|
-
updateMessages.cancel();
|
|
931
1043
|
if (agentRef.current) {
|
|
932
1044
|
try {
|
|
933
1045
|
// Display usage summary before cleanup
|
|
@@ -941,7 +1053,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
941
1053
|
agentRef.current.destroy();
|
|
942
1054
|
}
|
|
943
1055
|
};
|
|
944
|
-
}, [
|
|
1056
|
+
}, []);
|
|
945
1057
|
|
|
946
1058
|
// Send message function (including judgment logic)
|
|
947
1059
|
const sendMessage = useCallback(
|
|
@@ -1061,6 +1173,90 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1061
1173
|
);
|
|
1062
1174
|
}, []);
|
|
1063
1175
|
|
|
1176
|
+
// /rename: set the current conversation's custom title. The title is stored
|
|
1177
|
+
// as a reserved `custom-title` entry appended to the session's own JSONL
|
|
1178
|
+
// file, so `wave -r` and every GUI host read the same value. A blank title
|
|
1179
|
+
// only prints the usage hint — no AI-generated titles (spec:
|
|
1180
|
+
// session-management.md "不做 AI 自动标题").
|
|
1181
|
+
const renameSession = useCallback(async (args?: string) => {
|
|
1182
|
+
const agent = agentRef.current;
|
|
1183
|
+
if (!agent) return;
|
|
1184
|
+
|
|
1185
|
+
const title = (args ?? "").trim();
|
|
1186
|
+
const showResult = (content: string) => {
|
|
1187
|
+
setMessages((prev) => [
|
|
1188
|
+
...prev,
|
|
1189
|
+
{
|
|
1190
|
+
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
1191
|
+
role: "assistant",
|
|
1192
|
+
timestamp: new Date().toISOString(),
|
|
1193
|
+
blocks: [{ type: "text", content, stage: "end" }],
|
|
1194
|
+
},
|
|
1195
|
+
]);
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
if (!title) {
|
|
1199
|
+
showResult("Usage: /rename <title>");
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
try {
|
|
1204
|
+
await agent.renameSession(title);
|
|
1205
|
+
showResult(`Conversation renamed to "${title}".`);
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
showResult(
|
|
1208
|
+
`Failed to rename conversation: ${
|
|
1209
|
+
error instanceof Error ? error.message : String(error)
|
|
1210
|
+
}`,
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
}, []);
|
|
1214
|
+
|
|
1215
|
+
// /resume: switch to another conversation without leaving the process. The
|
|
1216
|
+
// session id and its on-disk project directory move together, so the next
|
|
1217
|
+
// message is appended to the target's own transcript. A failed restore leaves
|
|
1218
|
+
// the current session untouched and only surfaces a notice.
|
|
1219
|
+
const resumeSession = useCallback(
|
|
1220
|
+
async (sessionId: string, resumeWorkdir?: string) => {
|
|
1221
|
+
const agent = agentRef.current;
|
|
1222
|
+
if (!agent) return;
|
|
1223
|
+
|
|
1224
|
+
try {
|
|
1225
|
+
await agent.restoreSession(
|
|
1226
|
+
sessionId,
|
|
1227
|
+
resumeWorkdir ? { workdir: resumeWorkdir } : undefined,
|
|
1228
|
+
);
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
// UI-only notice (same display path as /add-dir): nothing was switched,
|
|
1231
|
+
// so nothing about the failed target is persisted.
|
|
1232
|
+
setMessages((prev) => [
|
|
1233
|
+
...prev,
|
|
1234
|
+
{
|
|
1235
|
+
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
1236
|
+
role: "assistant",
|
|
1237
|
+
timestamp: new Date().toISOString(),
|
|
1238
|
+
blocks: [
|
|
1239
|
+
{
|
|
1240
|
+
type: "error",
|
|
1241
|
+
content: `Failed to resume conversation: ${
|
|
1242
|
+
error instanceof Error ? error.message : String(error)
|
|
1243
|
+
}`,
|
|
1244
|
+
},
|
|
1245
|
+
],
|
|
1246
|
+
},
|
|
1247
|
+
]);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
setSessionId(agent.sessionId);
|
|
1252
|
+
setWorkingDirectory(agent.workingDirectory);
|
|
1253
|
+
setIsLoading(agent.isLoading);
|
|
1254
|
+
refreshMessages();
|
|
1255
|
+
forceRemount();
|
|
1256
|
+
},
|
|
1257
|
+
[forceRemount, refreshMessages],
|
|
1258
|
+
);
|
|
1259
|
+
|
|
1064
1260
|
// Unified interrupt method, interrupt both AI messages and command execution
|
|
1065
1261
|
const abortMessage = useCallback(() => {
|
|
1066
1262
|
agentRef.current?.abortMessage();
|
|
@@ -1170,6 +1366,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1170
1366
|
input: next.toolInput,
|
|
1171
1367
|
suggestedPrefix: next.suggestedPrefix,
|
|
1172
1368
|
hidePersistentOption: next.hidePersistentOption,
|
|
1369
|
+
outsideSafeZoneDirectory: next.outsideSafeZoneDirectory,
|
|
1173
1370
|
planContent: next.planContent,
|
|
1174
1371
|
permissionMode: next.permissionMode,
|
|
1175
1372
|
warning: next.warning,
|
|
@@ -1208,6 +1405,165 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1208
1405
|
hideConfirmation();
|
|
1209
1406
|
}, [currentConfirmation, hideConfirmation]);
|
|
1210
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
|
+
|
|
1211
1567
|
const backgroundCurrentTask = useCallback(() => {
|
|
1212
1568
|
agentRef.current?.backgroundCurrentTask();
|
|
1213
1569
|
}, []);
|
|
@@ -1257,7 +1613,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1257
1613
|
|
|
1258
1614
|
const setModel = useCallback((model: string) => {
|
|
1259
1615
|
if (agentRef.current) {
|
|
1260
|
-
agentRef.current.setModel(model);
|
|
1616
|
+
void agentRef.current.setModel(model);
|
|
1261
1617
|
setCurrentModelState(model);
|
|
1262
1618
|
}
|
|
1263
1619
|
}, []);
|
|
@@ -1307,6 +1663,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1307
1663
|
clearMessages,
|
|
1308
1664
|
compact,
|
|
1309
1665
|
addDir,
|
|
1666
|
+
renameSession,
|
|
1667
|
+
resumeSession,
|
|
1310
1668
|
abortMessage,
|
|
1311
1669
|
recallQueuedMessage,
|
|
1312
1670
|
removeQueuedMessageById,
|
|
@@ -1334,6 +1692,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1334
1692
|
hooks,
|
|
1335
1693
|
permissionMode,
|
|
1336
1694
|
setPermissionMode,
|
|
1695
|
+
remoteControl,
|
|
1696
|
+
remoteControlCommand,
|
|
1337
1697
|
planView,
|
|
1338
1698
|
setPlanView,
|
|
1339
1699
|
handlePlanCommand,
|
|
@@ -1355,7 +1715,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1355
1715
|
workingDirectory,
|
|
1356
1716
|
version,
|
|
1357
1717
|
workdir,
|
|
1358
|
-
|
|
1718
|
+
reloadPlugins,
|
|
1719
|
+
notifyPluginChange,
|
|
1720
|
+
pluginHint,
|
|
1359
1721
|
};
|
|
1360
1722
|
|
|
1361
1723
|
return (
|