wave-code 1.3.0 → 1.5.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 +1382 -1131
- package/package.json +9 -7
- package/src/cli.tsx +14 -22
- package/src/commands/update.ts +62 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +34 -13
- package/src/components/ConfirmationSelector.tsx +31 -36
- package/src/components/DiffDisplay.tsx +1 -1
- package/src/components/HooksManager.tsx +3 -3
- package/src/components/InputBox.tsx +37 -11
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/RemoteControlView.tsx +175 -0
- package/src/components/ScrolledContent.tsx +15 -13
- package/src/components/SkillsManager.tsx +3 -3
- package/src/components/TaskList.tsx +3 -3
- package/src/components/ToolDisplay.tsx +3 -18
- package/src/components/WorkflowManager.tsx +150 -136
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +308 -154
- package/src/daemon/commands.ts +31 -22
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +25 -39
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +346 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +5 -0
- package/src/managers/inputReducer.ts +39 -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 +46 -0
- package/src/remote/notificationCoalescer.ts +192 -0
- package/src/remote/relayClient.ts +690 -0
- package/src/remote/remoteControlCliHost.ts +420 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +353 -0
- package/src/remote/remoteControlService.ts +822 -0
- package/src/remote/remoteControlSink.ts +78 -0
- package/src/remote/remoteNotifications.ts +200 -0
- package/src/stdio/agentBridge.ts +874 -106
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/hostSlashCommands.ts +209 -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/uiPayload.ts +65 -0
- package/src/stdio-cli.ts +11 -2
- package/src/types.ts +7 -0
- 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/utils/bracketedPaste.ts +0 -170
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,
|
|
@@ -37,6 +40,8 @@ import {
|
|
|
37
40
|
import { logger } from "../utils/logger.js";
|
|
38
41
|
import { displayUsageSummary } from "../utils/usageSummary.js";
|
|
39
42
|
import { expandLongTextPlaceholders } from "../managers/inputHandlers.js";
|
|
43
|
+
import { remoteNotifications } from "../remote/remoteNotifications.js";
|
|
44
|
+
import type { RemoteNotify } from "../remote/remoteNotifications.js";
|
|
40
45
|
import { readFile } from "node:fs/promises";
|
|
41
46
|
|
|
42
47
|
import { BaseAppProps } from "../types.js";
|
|
@@ -109,6 +114,10 @@ export interface ChatContextType {
|
|
|
109
114
|
// Workflow runs
|
|
110
115
|
workflowRuns: WorkflowRun[];
|
|
111
116
|
stopWorkflowRun: (runId: string) => void;
|
|
117
|
+
saveWorkflowScript: (
|
|
118
|
+
runId: string,
|
|
119
|
+
options: { name: string; scope: "project" | "user"; overwrite?: boolean },
|
|
120
|
+
) => Promise<{ name: string; path: string }>;
|
|
112
121
|
// Tasks
|
|
113
122
|
tasks: Task[];
|
|
114
123
|
getBackgroundTaskOutput: (taskId: string) => {
|
|
@@ -132,6 +141,12 @@ export interface ChatContextType {
|
|
|
132
141
|
// Permission functionality
|
|
133
142
|
permissionMode: PermissionMode;
|
|
134
143
|
setPermissionMode: (mode: PermissionMode) => void;
|
|
144
|
+
/**
|
|
145
|
+
* Remote control for **this** foreground session. The process the user is
|
|
146
|
+
* typing in is the one that dials out — not the daemon, which cannot reach a
|
|
147
|
+
* session that only exists here.
|
|
148
|
+
*/
|
|
149
|
+
remoteControl: RemoteControlApi;
|
|
135
150
|
// /plan overlay state + handler
|
|
136
151
|
planView: PlanViewData | null;
|
|
137
152
|
setPlanView: (view: PlanViewData | null) => void;
|
|
@@ -166,7 +181,7 @@ export interface ChatContextType {
|
|
|
166
181
|
backgroundCurrentTask: () => void;
|
|
167
182
|
// Remount functionality
|
|
168
183
|
remountKey: number;
|
|
169
|
-
forceRemount: () => void;
|
|
184
|
+
forceRemount: (options?: { preserveScrollback?: boolean }) => void;
|
|
170
185
|
// Rewind functionality
|
|
171
186
|
handleRewindSelect: (index: number) => Promise<void>;
|
|
172
187
|
getFullMessageThread: () => Promise<{
|
|
@@ -349,81 +364,6 @@ const applyToolBlockUpdate = (
|
|
|
349
364
|
});
|
|
350
365
|
};
|
|
351
366
|
|
|
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
367
|
export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
428
368
|
children,
|
|
429
369
|
bypassPermissions,
|
|
@@ -439,6 +379,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
439
379
|
version,
|
|
440
380
|
model,
|
|
441
381
|
mcpServers,
|
|
382
|
+
onClearOutput,
|
|
442
383
|
}) => {
|
|
443
384
|
const { restoreSessionId, continueLastSession } = useAppConfig();
|
|
444
385
|
const { stdout } = useStdout();
|
|
@@ -454,26 +395,16 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
454
395
|
const [latestTotalTokens, setLatestTotalTokens] = useState(0);
|
|
455
396
|
const [maxInputTokens, setMaxInputTokens] = useState(200000);
|
|
456
397
|
|
|
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
|
-
);
|
|
398
|
+
// All message-state updates apply IMMEDIATELY via setMessages — no data-layer
|
|
399
|
+
// throttling (removed 2026-10-15 after observing fully-immediate rendering in
|
|
400
|
+
// a real terminal with no flicker: Ink's log-update only rewrites the dynamic
|
|
401
|
+
// zone tail and its built-in maxFps:30 coalesces short-result line-count
|
|
402
|
+
// oscillation). Arrival order = application order. See
|
|
403
|
+
// docs/specs/core/stream-content-updates.md (2026-10-15 会议).
|
|
469
404
|
|
|
470
405
|
useEffect(() => {
|
|
471
406
|
isExpandedRef.current = isExpanded;
|
|
472
|
-
|
|
473
|
-
// Cancel pending throttled updates so the frozen expanded view isn't overwritten
|
|
474
|
-
updateMessages.cancel();
|
|
475
|
-
}
|
|
476
|
-
}, [isExpanded, updateMessages]);
|
|
407
|
+
}, [isExpanded]);
|
|
477
408
|
|
|
478
409
|
const [isLoading, setIsLoading] = useState(false);
|
|
479
410
|
const [sessionId, setSessionId] = useState("");
|
|
@@ -538,6 +469,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
538
469
|
>();
|
|
539
470
|
const [confirmationQueue, setConfirmationQueue] = useState<
|
|
540
471
|
Array<{
|
|
472
|
+
requestId: string;
|
|
541
473
|
toolName: string;
|
|
542
474
|
toolInput?: Record<string, unknown>;
|
|
543
475
|
suggestedPrefix?: string;
|
|
@@ -551,6 +483,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
551
483
|
}>
|
|
552
484
|
>([]);
|
|
553
485
|
const [currentConfirmation, setCurrentConfirmation] = useState<{
|
|
486
|
+
requestId: string;
|
|
554
487
|
toolName: string;
|
|
555
488
|
toolInput?: Record<string, unknown>;
|
|
556
489
|
suggestedPrefix?: string;
|
|
@@ -563,44 +496,96 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
563
496
|
reject: () => void;
|
|
564
497
|
} | null>(null);
|
|
565
498
|
|
|
499
|
+
/**
|
|
500
|
+
* The same approvals, keyed by id, so a phone can answer them.
|
|
501
|
+
*
|
|
502
|
+
* The queue above is the terminal's view of "what am I waiting on"; this map
|
|
503
|
+
* is the phone's. Both resolve the same promise, so whichever side answers
|
|
504
|
+
* first wins — that is the whole of the concurrency rule (spec: 前台 CLI
|
|
505
|
+
* 故事场景 5), and it needs no arbitration beyond "delete on settle".
|
|
506
|
+
*/
|
|
507
|
+
const remotePendingRef = useRef<
|
|
508
|
+
Map<
|
|
509
|
+
string,
|
|
510
|
+
{
|
|
511
|
+
context: ToolPermissionContext;
|
|
512
|
+
settle: (decision: PermissionDecision) => void;
|
|
513
|
+
}
|
|
514
|
+
>
|
|
515
|
+
>(new Map());
|
|
516
|
+
const [remotePending, setRemotePending] = useState<
|
|
517
|
+
Array<{ requestId: string; context: ToolPermissionContext }>
|
|
518
|
+
>([]);
|
|
519
|
+
const permissionCounterRef = useRef(0);
|
|
520
|
+
|
|
521
|
+
const syncRemotePending = useCallback(() => {
|
|
522
|
+
setRemotePending(
|
|
523
|
+
[...remotePendingRef.current.entries()].map(([requestId, entry]) => ({
|
|
524
|
+
requestId,
|
|
525
|
+
context: entry.context,
|
|
526
|
+
})),
|
|
527
|
+
);
|
|
528
|
+
}, []);
|
|
529
|
+
|
|
566
530
|
// Remount state
|
|
567
531
|
const [remountKey, setRemountKey] = useState(0);
|
|
568
532
|
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
// Static output
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
533
|
+
// Remount so Ink's append-only <Static> re-renders from scratch. Used on
|
|
534
|
+
// structural actions (/clear, session switch, rewind, ctrl-o) where the
|
|
535
|
+
// already-written Static output no longer matches the message list.
|
|
536
|
+
//
|
|
537
|
+
// The re-render duplicates whatever is still on screen, so the default erases
|
|
538
|
+
// the screen *and* the terminal scrollback. `/clear` passes
|
|
539
|
+
// `preserveScrollback`: it only wants a fresh conversation, and keeping the
|
|
540
|
+
// old one scrollable above is the point of the command.
|
|
541
|
+
const forceRemount = useCallback(
|
|
542
|
+
(options?: { preserveScrollback?: boolean }) => {
|
|
543
|
+
const remount = () => setRemountKey((prev) => prev + 1);
|
|
544
|
+
if (options?.preserveScrollback && onClearOutput) {
|
|
545
|
+
// ink's own clear erases just the frame it drew, leaving scrollback
|
|
546
|
+
// intact — and resets the row bookkeeping a hand-written escape
|
|
547
|
+
// sequence cannot reach.
|
|
548
|
+
onClearOutput();
|
|
549
|
+
remount();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
stdout?.write("\u001b[2J\u001b[3J\u001b[0;0H", remount);
|
|
553
|
+
},
|
|
554
|
+
[onClearOutput, stdout],
|
|
555
|
+
);
|
|
577
556
|
|
|
578
557
|
// Status metadata state
|
|
579
558
|
const [workingDirectory, setWorkingDirectory] = useState("");
|
|
580
559
|
|
|
581
560
|
const agentRef = useRef<Agent | null>(null);
|
|
582
561
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
562
|
+
/**
|
|
563
|
+
* The notifier the remote path's content-carrying frames go out through (see
|
|
564
|
+
* `remoteNotifications`). A ref because the agent callbacks are built in
|
|
565
|
+
* `initializeAgent` below, before this component has the hook that owns the
|
|
566
|
+
* connection.
|
|
567
|
+
*/
|
|
568
|
+
const remoteNotifyRef = useRef<RemoteNotify | undefined>(undefined);
|
|
569
|
+
|
|
570
|
+
// Full-list refresh — used only for structural changes (compact/clear/rewind/
|
|
571
|
+
// collapse/init). Streaming updates flow through the incremental callbacks in
|
|
572
|
+
// initializeAgent below.
|
|
586
573
|
const refreshMessages = useCallback(() => {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
// `agent.messages`
|
|
590
|
-
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
// docs/specs/core/stream-content-updates.md.
|
|
599
|
-
updateMessages.cancel();
|
|
574
|
+
const agent = agentRef.current;
|
|
575
|
+
if (!isExpandedRef.current && agent) {
|
|
576
|
+
// `agent.messages` 是「最近一次压缩 + 其后消息」:CLI 屏幕只渲染最近几十
|
|
577
|
+
// 条、清屏重放也只放这些条数,压缩前的历史在这里没有任何读者,留着只是把
|
|
578
|
+
// 图片 base64 挂在堆上(spec ui/message-rendering-system.md「CLI 消息列表
|
|
579
|
+
// 只加载压缩之后」)。从未压缩过的会话这里就是整条线程。
|
|
580
|
+
const msgs = agent.messages.map(snapshotMessage);
|
|
581
|
+
// The snapshot makes the SDK state authoritative: with no throttle
|
|
582
|
+
// window there is nothing pending to cancel — every pre-pull update is
|
|
583
|
+
// already applied and contained in `msgs`, and post-pull deltas append
|
|
584
|
+
// on top of the snapshot. See docs/specs/core/stream-content-updates.md.
|
|
600
585
|
setMessages(msgs);
|
|
601
586
|
setLatestTotalTokens(extractLatestTotalTokens(msgs));
|
|
602
587
|
}
|
|
603
|
-
}, [
|
|
588
|
+
}, []);
|
|
604
589
|
|
|
605
590
|
// Permission confirmation methods with queue support
|
|
606
591
|
const showConfirmation = useCallback(
|
|
@@ -614,8 +599,38 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
614
599
|
warning?: string,
|
|
615
600
|
outsideSafeZoneDirectory?: string,
|
|
616
601
|
): Promise<PermissionDecision> => {
|
|
602
|
+
const requestId = `cli_perm_${++permissionCounterRef.current}`;
|
|
617
603
|
return new Promise<PermissionDecision>((resolve, reject) => {
|
|
604
|
+
const context: ToolPermissionContext = {
|
|
605
|
+
toolName,
|
|
606
|
+
...(toolInput ? { toolInput } : {}),
|
|
607
|
+
...(suggestedPrefix ? { suggestedPrefix } : {}),
|
|
608
|
+
...(hidePersistentOption !== undefined
|
|
609
|
+
? { hidePersistentOption }
|
|
610
|
+
: {}),
|
|
611
|
+
...(outsideSafeZoneDirectory ? { outsideSafeZoneDirectory } : {}),
|
|
612
|
+
...(planContent ? { planContent } : {}),
|
|
613
|
+
// Required on the context, and the phone renders it: which mode this
|
|
614
|
+
// request is being judged under.
|
|
615
|
+
permissionMode: permissionMode ?? "default",
|
|
616
|
+
...(warning ? { warning } : {}),
|
|
617
|
+
};
|
|
618
|
+
// Settling through the map is what keeps the phone's list honest: an
|
|
619
|
+
// approval answered at the terminal must vanish from the phone too.
|
|
620
|
+
const settle = (decision: PermissionDecision) => {
|
|
621
|
+
remotePendingRef.current.delete(requestId);
|
|
622
|
+
syncRemotePending();
|
|
623
|
+
resolve(decision);
|
|
624
|
+
};
|
|
625
|
+
const cancel = () => {
|
|
626
|
+
remotePendingRef.current.delete(requestId);
|
|
627
|
+
syncRemotePending();
|
|
628
|
+
reject();
|
|
629
|
+
};
|
|
630
|
+
remotePendingRef.current.set(requestId, { context, settle });
|
|
631
|
+
syncRemotePending();
|
|
618
632
|
const queueItem = {
|
|
633
|
+
requestId,
|
|
619
634
|
toolName,
|
|
620
635
|
toolInput,
|
|
621
636
|
suggestedPrefix,
|
|
@@ -624,15 +639,15 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
624
639
|
planContent,
|
|
625
640
|
permissionMode,
|
|
626
641
|
warning,
|
|
627
|
-
resolver:
|
|
628
|
-
reject,
|
|
642
|
+
resolver: settle,
|
|
643
|
+
reject: cancel,
|
|
629
644
|
};
|
|
630
645
|
|
|
631
646
|
setConfirmationQueue((prev) => [...prev, queueItem]);
|
|
632
647
|
// processNextConfirmation will be called via useEffect
|
|
633
648
|
});
|
|
634
649
|
},
|
|
635
|
-
[],
|
|
650
|
+
[syncRemotePending],
|
|
636
651
|
);
|
|
637
652
|
|
|
638
653
|
// Initialize AI manager
|
|
@@ -641,10 +656,23 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
641
656
|
const effectiveRestoreSessionId =
|
|
642
657
|
restoreSessionIdOverride ?? restoreSessionId;
|
|
643
658
|
|
|
659
|
+
// The remote path's copy of the payload-carrying frames (see
|
|
660
|
+
// `remoteNotifications`: the streaming trio plus the error block), emitted
|
|
661
|
+
// *beside* the local state updates — never instead of them, and outside the
|
|
662
|
+
// `isExpanded` guards below, because the TUI's own view mode is not the
|
|
663
|
+
// phone's. Built only while a sink exists, so a process with remote control
|
|
664
|
+
// off allocates nothing per chunk.
|
|
665
|
+
const remoteNotify = remoteNotifyRef.current;
|
|
666
|
+
const remoteStream = remoteNotify
|
|
667
|
+
? remoteNotifications(remoteNotify)
|
|
668
|
+
: undefined;
|
|
669
|
+
|
|
644
670
|
const callbacks: AgentCallbacks = {
|
|
645
671
|
// ── Incremental message updates (no full-list pushes) ──────
|
|
646
|
-
//
|
|
647
|
-
//
|
|
672
|
+
// Every update applies immediately via setMessages — no data-layer
|
|
673
|
+
// throttling (removed 2026-10-15, see docs/specs/core/stream-content-updates.md).
|
|
674
|
+
// Arrival order = application order; terminal write frequency is
|
|
675
|
+
// bounded by Ink's built-in maxFps:30.
|
|
648
676
|
onUserMessageAdded: () => {
|
|
649
677
|
if (isExpandedRef.current || !agentRef.current) return;
|
|
650
678
|
const msgs = agentRef.current.messages;
|
|
@@ -654,12 +682,11 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
654
682
|
// the batch flush, so evaluating snapshotMessage inside the updater
|
|
655
683
|
// would read the SDK message after its in-place mutations.
|
|
656
684
|
const snapshot = snapshotMessage(last);
|
|
657
|
-
|
|
685
|
+
// One-shot structural update — applied directly (a window would
|
|
686
|
+
// only delay the message card, no coalescing value).
|
|
687
|
+
setMessages((prev) =>
|
|
658
688
|
prev.some((m) => m.id === last.id) ? prev : [...prev, snapshot],
|
|
659
689
|
);
|
|
660
|
-
// One-shot structural update — flush immediately so the message
|
|
661
|
-
// card appears at once (queuing it gains no coalescing).
|
|
662
|
-
updateMessages.flush();
|
|
663
690
|
},
|
|
664
691
|
onAssistantMessageAdded: (messageId: string) => {
|
|
665
692
|
if (isExpandedRef.current || !agentRef.current) return;
|
|
@@ -669,29 +696,43 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
669
696
|
// fires this callback BEFORE the first delta writes into the shared
|
|
670
697
|
// message, so capturing here copies the pre-mutation (empty) blocks.
|
|
671
698
|
const snapshot = snapshotMessage(msg);
|
|
672
|
-
|
|
699
|
+
setMessages((prev) =>
|
|
673
700
|
prev.some((m) => m.id === messageId) ? prev : [...prev, snapshot],
|
|
674
701
|
);
|
|
675
|
-
|
|
702
|
+
},
|
|
703
|
+
onAssistantMessageDiscarded: (messageId: string) => {
|
|
704
|
+
// The streamed attempt died mid-body and was re-issued without
|
|
705
|
+
// streaming; its half message is void (spec: 流式响应正文中途断连时
|
|
706
|
+
// 降级为非流式重发).
|
|
707
|
+
if (isExpandedRef.current) return;
|
|
708
|
+
setMessages((prev) => prev.filter((m) => m.id !== messageId));
|
|
676
709
|
},
|
|
677
710
|
onAssistantContentUpdated: (params) => {
|
|
711
|
+
remoteStream?.onAssistantContentUpdated(params);
|
|
678
712
|
if (isExpandedRef.current) return;
|
|
679
|
-
|
|
680
|
-
|
|
713
|
+
// Text delta: height grows monotonically → per-delta application.
|
|
714
|
+
// No flush on end: this channel has no pending updates, and the
|
|
715
|
+
// tool-running window must not be cut short by a text end.
|
|
716
|
+
setMessages((prev) => applyContentDelta(prev, params));
|
|
681
717
|
},
|
|
682
718
|
onAssistantReasoningUpdated: (params) => {
|
|
719
|
+
remoteStream?.onAssistantReasoningUpdated(params);
|
|
683
720
|
if (isExpandedRef.current) return;
|
|
684
|
-
|
|
685
|
-
if (params.stage === "end") updateMessages.flush();
|
|
721
|
+
setMessages((prev) => applyReasoningDelta(prev, params));
|
|
686
722
|
},
|
|
687
723
|
onToolBlockUpdated: (params) => {
|
|
724
|
+
remoteStream?.onToolBlockUpdated(params);
|
|
688
725
|
if (isExpandedRef.current) return;
|
|
689
|
-
|
|
690
|
-
|
|
726
|
+
// All stages (start/streaming/running/end) apply directly —
|
|
727
|
+
// `end` carries the authoritative final values.
|
|
728
|
+
setMessages((prev) => applyToolBlockUpdate(prev, params));
|
|
691
729
|
},
|
|
692
730
|
onErrorBlockAdded: (error: string) => {
|
|
731
|
+
remoteStream?.onErrorBlockAdded(error);
|
|
693
732
|
if (isExpandedRef.current) return;
|
|
694
|
-
|
|
733
|
+
// One-shot structural update — applied directly (errors must not be
|
|
734
|
+
// delayed by the tool-running window).
|
|
735
|
+
setMessages((prev) => {
|
|
695
736
|
// Append to the LAST message only if it is an assistant message
|
|
696
737
|
// (the current turn's in-flight reply). If the last message is a
|
|
697
738
|
// user message, the error belongs BELOW it — create a new
|
|
@@ -719,9 +760,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
719
760
|
},
|
|
720
761
|
];
|
|
721
762
|
});
|
|
722
|
-
// One-shot structural update — flush immediately (errors must not be
|
|
723
|
-
// delayed by a streaming window).
|
|
724
|
-
updateMessages.flush();
|
|
725
763
|
},
|
|
726
764
|
onLatestTotalTokensChange: (tokens) => {
|
|
727
765
|
setLatestTotalTokens(tokens);
|
|
@@ -736,6 +774,11 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
736
774
|
setIsCompacting(isCompactingState);
|
|
737
775
|
if (!isCompactingState) {
|
|
738
776
|
setCompactionStream("");
|
|
777
|
+
// 压缩结束 = 上下文已经折叠成「摘要 + 保留轮次」:列表要跟着收缩,
|
|
778
|
+
// 否则这一轮之后仍挂着压缩前的全量(自动压缩不走 `/compact` 那条
|
|
779
|
+
// refresh 路径)。见 spec ui/message-rendering-system.md「CLI 消息
|
|
780
|
+
// 列表只加载压缩之后」。
|
|
781
|
+
refreshMessages();
|
|
739
782
|
}
|
|
740
783
|
},
|
|
741
784
|
onCompactionContentUpdate: (content) => {
|
|
@@ -769,8 +812,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
769
812
|
onModelChange: (model) => {
|
|
770
813
|
setCurrentModelState(model);
|
|
771
814
|
},
|
|
815
|
+
// CLI 的选择器只要名字,能力标识是 webview 输入卡片的事。
|
|
772
816
|
onConfiguredModelsChange: (models) => {
|
|
773
|
-
setConfiguredModels(models);
|
|
817
|
+
setConfiguredModels(models.map((m) => m.name));
|
|
774
818
|
},
|
|
775
819
|
onLoadingChange: (loading) => {
|
|
776
820
|
setIsLoading(loading);
|
|
@@ -828,6 +872,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
828
872
|
isNewWorktree: worktreeSession?.isNew,
|
|
829
873
|
model,
|
|
830
874
|
mcpServers,
|
|
875
|
+
// This session was created from the interactive CLI → `app.entrypoint`.
|
|
876
|
+
entrypoint: "cli",
|
|
831
877
|
});
|
|
832
878
|
|
|
833
879
|
agentRef.current = agent;
|
|
@@ -847,19 +893,22 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
847
893
|
agent.setWorktreeSession(session);
|
|
848
894
|
}
|
|
849
895
|
|
|
850
|
-
// Get initial state —
|
|
851
|
-
//
|
|
852
|
-
//
|
|
896
|
+
// Get initial state — all synchronous reads, so a notification arriving
|
|
897
|
+
// mid-init cannot be overwritten by a stale read. The list is
|
|
898
|
+
// `agent.messages` (the API context): after compaction that is the
|
|
899
|
+
// summary plus the rounds the compaction kept, which is exactly what
|
|
900
|
+
// the CLI shows (spec ui/message-rendering-system.md「CLI 消息列表只加载
|
|
901
|
+
// 压缩之后」); a never-compacted session is the whole thread.
|
|
853
902
|
setSessionId(agent.sessionId);
|
|
854
|
-
setMessages(agent.
|
|
855
|
-
setIsLoading(agent.isLoading);
|
|
903
|
+
setMessages(agent.messages.map(snapshotMessage));
|
|
856
904
|
setLatestTotalTokens(extractLatestTotalTokens(agent.messages));
|
|
905
|
+
setIsLoading(agent.isLoading);
|
|
857
906
|
setIsCommandRunning(agent.isCommandRunning);
|
|
858
907
|
setIsCompacting(agent.isCompacting);
|
|
859
908
|
setPermissionModeState(agent.getPermissionMode());
|
|
860
|
-
setWorkingDirectory(agent.
|
|
909
|
+
setWorkingDirectory(agent.currentWorkdir);
|
|
861
910
|
setCurrentModelState(agent.getModelConfig().model || "");
|
|
862
|
-
setConfiguredModels(agent.getConfiguredModels());
|
|
911
|
+
setConfiguredModels(agent.getConfiguredModels().map((m) => m.name));
|
|
863
912
|
setMaxInputTokens(agent.getMaxInputTokens());
|
|
864
913
|
|
|
865
914
|
// Get initial MCP servers state
|
|
@@ -910,8 +959,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
910
959
|
originalCwd,
|
|
911
960
|
model,
|
|
912
961
|
initialPermissionMode,
|
|
913
|
-
updateMessages,
|
|
914
962
|
mcpServers,
|
|
963
|
+
refreshMessages,
|
|
915
964
|
],
|
|
916
965
|
);
|
|
917
966
|
|
|
@@ -968,7 +1017,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
968
1017
|
// Cleanup on unmount
|
|
969
1018
|
useEffect(() => {
|
|
970
1019
|
return () => {
|
|
971
|
-
updateMessages.cancel();
|
|
972
1020
|
if (agentRef.current) {
|
|
973
1021
|
try {
|
|
974
1022
|
// Display usage summary before cleanup
|
|
@@ -982,7 +1030,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
982
1030
|
agentRef.current.destroy();
|
|
983
1031
|
}
|
|
984
1032
|
};
|
|
985
|
-
}, [
|
|
1033
|
+
}, []);
|
|
986
1034
|
|
|
987
1035
|
// Send message function (including judgment logic)
|
|
988
1036
|
const sendMessage = useCallback(
|
|
@@ -1044,7 +1092,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1044
1092
|
const clearMessages = useCallback(async () => {
|
|
1045
1093
|
await agentRef.current?.clearMessages();
|
|
1046
1094
|
refreshMessages();
|
|
1047
|
-
forceRemount();
|
|
1095
|
+
forceRemount({ preserveScrollback: true });
|
|
1048
1096
|
}, [refreshMessages, forceRemount]);
|
|
1049
1097
|
|
|
1050
1098
|
const compact = useCallback(
|
|
@@ -1178,7 +1226,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1178
1226
|
}
|
|
1179
1227
|
|
|
1180
1228
|
setSessionId(agent.sessionId);
|
|
1181
|
-
setWorkingDirectory(agent.
|
|
1229
|
+
setWorkingDirectory(agent.currentWorkdir);
|
|
1182
1230
|
setIsLoading(agent.isLoading);
|
|
1183
1231
|
refreshMessages();
|
|
1184
1232
|
forceRemount();
|
|
@@ -1280,6 +1328,19 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1280
1328
|
agentRef.current?.stopWorkflowRun(runId);
|
|
1281
1329
|
}, []);
|
|
1282
1330
|
|
|
1331
|
+
const saveWorkflowScript = useCallback(
|
|
1332
|
+
async (
|
|
1333
|
+
runId: string,
|
|
1334
|
+
options: { name: string; scope: "project" | "user"; overwrite?: boolean },
|
|
1335
|
+
) => {
|
|
1336
|
+
if (!agentRef.current) {
|
|
1337
|
+
throw new Error("Agent not available");
|
|
1338
|
+
}
|
|
1339
|
+
return agentRef.current.saveWorkflowScript(runId, options);
|
|
1340
|
+
},
|
|
1341
|
+
[],
|
|
1342
|
+
);
|
|
1343
|
+
|
|
1283
1344
|
const hasSlashCommand = useCallback((commandId: string) => {
|
|
1284
1345
|
if (!agentRef.current) return false;
|
|
1285
1346
|
return agentRef.current.hasSlashCommand(commandId);
|
|
@@ -1334,6 +1395,97 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1334
1395
|
hideConfirmation();
|
|
1335
1396
|
}, [currentConfirmation, hideConfirmation]);
|
|
1336
1397
|
|
|
1398
|
+
// Mirrors for the remote-control host: it outlives any single render, so it
|
|
1399
|
+
// must read the confirmation showing *now* rather than the one captured when
|
|
1400
|
+
// it was built.
|
|
1401
|
+
const currentConfirmationRef = useRef<typeof currentConfirmation>(null);
|
|
1402
|
+
useEffect(() => {
|
|
1403
|
+
currentConfirmationRef.current = currentConfirmation;
|
|
1404
|
+
}, [currentConfirmation]);
|
|
1405
|
+
const hideConfirmationRef = useRef(hideConfirmation);
|
|
1406
|
+
useEffect(() => {
|
|
1407
|
+
hideConfirmationRef.current = hideConfirmation;
|
|
1408
|
+
}, [hideConfirmation]);
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Answer an approval from the phone. It settles the same promise the terminal
|
|
1412
|
+
* is waiting on, so this advances the session exactly as pressing the key
|
|
1413
|
+
* would — and if that request is the one on screen, the dialog goes away.
|
|
1414
|
+
*/
|
|
1415
|
+
const respondPermission = useCallback(
|
|
1416
|
+
(requestId: string, decision: PermissionDecision) => {
|
|
1417
|
+
const entry = remotePendingRef.current.get(requestId);
|
|
1418
|
+
if (!entry) return;
|
|
1419
|
+
entry.settle(decision);
|
|
1420
|
+
if (currentConfirmationRef.current?.requestId === requestId) {
|
|
1421
|
+
hideConfirmationRef.current();
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
[],
|
|
1425
|
+
);
|
|
1426
|
+
|
|
1427
|
+
const getAvailableTools = useCallback(
|
|
1428
|
+
() => agentRef.current?.getAvailableToolNames() ?? [],
|
|
1429
|
+
[],
|
|
1430
|
+
);
|
|
1431
|
+
|
|
1432
|
+
// Serve this foreground session to a phone, if the switch allows it. The hook
|
|
1433
|
+
// owns the outbound connection; it reads current state through a ref, so
|
|
1434
|
+
// passing the values here (rather than wiring callbacks into every setter) is
|
|
1435
|
+
// what keeps this the whole of the integration.
|
|
1436
|
+
const remoteControl = useRemoteControl({
|
|
1437
|
+
workdir: workdir ?? process.cwd(),
|
|
1438
|
+
// 会话身份取 agent 的会话起点(不随 bash `cd` 漂移),不是上面那个会动的 workdir。
|
|
1439
|
+
sessionCwd: () =>
|
|
1440
|
+
agentRef.current?.sessionWorkdir ?? workdir ?? process.cwd(),
|
|
1441
|
+
sessionId,
|
|
1442
|
+
messages,
|
|
1443
|
+
isLoading,
|
|
1444
|
+
permissionMode,
|
|
1445
|
+
latestTotalTokens,
|
|
1446
|
+
maxInputTokens,
|
|
1447
|
+
// 手机侧会话列表的标题材料:`customTitle` 不走 React state(agent 自己存着,
|
|
1448
|
+
// 重命名后即刻生效),所以按「取当前值」的函数递给门面。
|
|
1449
|
+
customTitle: () => agentRef.current?.customTitle,
|
|
1450
|
+
hasRunningBackgroundWork: backgroundTasks.length > 0,
|
|
1451
|
+
hasPendingMessages: queuedMessages.length > 0,
|
|
1452
|
+
// 场景 26 的会话状态族:远控页按状态重读 `getSessionInfo`,这四个是它的
|
|
1453
|
+
// 当前值(回调本就把它们写进 state,这里原样递给门面即可)。
|
|
1454
|
+
tasks,
|
|
1455
|
+
backgroundTasks,
|
|
1456
|
+
isCompacting,
|
|
1457
|
+
isCommandRunning,
|
|
1458
|
+
queuedMessages,
|
|
1459
|
+
pending: remotePending,
|
|
1460
|
+
availableTools: getAvailableTools,
|
|
1461
|
+
sendMessage: (text) => sendMessage(text),
|
|
1462
|
+
abort: () => agentRef.current?.abortMessage(),
|
|
1463
|
+
setPermissionMode,
|
|
1464
|
+
respondPermission,
|
|
1465
|
+
// 会话自身的维护动作(重命名 / 压缩 / 指令清单):页面能直接用,且都由这一份
|
|
1466
|
+
// 会话状态来答,所以给的是「取当前值」的函数。重命名走 agent 而非 /rename 命令,
|
|
1467
|
+
// 是为了让失败以 RPC 错误回给页面(TUI 那条路径把错误渲染成一条消息)。
|
|
1468
|
+
renameSession: (title) => agentRef.current?.renameSession(title),
|
|
1469
|
+
compact: (instructions) => compact(instructions),
|
|
1470
|
+
slashCommands: () => agentRef.current?.getSlashCommands() ?? [],
|
|
1471
|
+
// 手机页取首屏尾段 / 补差都读 agent 手上那份列表(压缩后的上下文),不去
|
|
1472
|
+
// 转录里找压缩前的历史。
|
|
1473
|
+
messagePage: (window) =>
|
|
1474
|
+
Promise.resolve(
|
|
1475
|
+
agentRef.current?.getMessagePage(window) ?? { messages: [] },
|
|
1476
|
+
),
|
|
1477
|
+
currentThread: () =>
|
|
1478
|
+
Promise.resolve([...(agentRef.current?.messages ?? [])]),
|
|
1479
|
+
threadSummary: () =>
|
|
1480
|
+
agentRef.current?.getThreadSummary() ?? { messageCount: 0 },
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
// `initializeAgent` reads this ref when it runs, which is always in an effect
|
|
1484
|
+
// — after this render. `notify` is stable (the hook builds it once, over a sink
|
|
1485
|
+
// it creates once), so assigning it during render is enough to have it in place
|
|
1486
|
+
// before anything can call the agent.
|
|
1487
|
+
remoteNotifyRef.current = remoteControl.notify;
|
|
1488
|
+
|
|
1337
1489
|
const backgroundCurrentTask = useCallback(() => {
|
|
1338
1490
|
agentRef.current?.backgroundCurrentTask();
|
|
1339
1491
|
}, []);
|
|
@@ -1378,7 +1530,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1378
1530
|
if (!agentRef.current) {
|
|
1379
1531
|
return [];
|
|
1380
1532
|
}
|
|
1381
|
-
return agentRef.current.getConfiguredModels();
|
|
1533
|
+
return agentRef.current.getConfiguredModels().map((m) => m.name);
|
|
1382
1534
|
}, []);
|
|
1383
1535
|
|
|
1384
1536
|
const setModel = useCallback((model: string) => {
|
|
@@ -1452,6 +1604,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1452
1604
|
backgroundTasks,
|
|
1453
1605
|
workflowRuns,
|
|
1454
1606
|
stopWorkflowRun,
|
|
1607
|
+
saveWorkflowScript,
|
|
1455
1608
|
tasks,
|
|
1456
1609
|
getBackgroundTaskOutput,
|
|
1457
1610
|
stopBackgroundTask,
|
|
@@ -1462,6 +1615,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1462
1615
|
hooks,
|
|
1463
1616
|
permissionMode,
|
|
1464
1617
|
setPermissionMode,
|
|
1618
|
+
remoteControl,
|
|
1465
1619
|
planView,
|
|
1466
1620
|
setPlanView,
|
|
1467
1621
|
handlePlanCommand,
|