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.
Files changed (63) hide show
  1. package/dist/bundle/wave.mjs +1382 -1131
  2. package/package.json +9 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/commands/update.ts +62 -22
  5. package/src/components/AgentsManager.tsx +3 -3
  6. package/src/components/App.tsx +5 -0
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/BtwDisplay.tsx +71 -15
  9. package/src/components/CompactDisplay.tsx +1 -6
  10. package/src/components/ConfirmationDetails.tsx +34 -13
  11. package/src/components/ConfirmationSelector.tsx +31 -36
  12. package/src/components/DiffDisplay.tsx +1 -1
  13. package/src/components/HooksManager.tsx +3 -3
  14. package/src/components/InputBox.tsx +37 -11
  15. package/src/components/LoginCommand.tsx +16 -35
  16. package/src/components/Markdown.tsx +7 -3
  17. package/src/components/MarketplaceAddForm.tsx +15 -19
  18. package/src/components/MessageBlockItem.tsx +0 -6
  19. package/src/components/RemoteControlView.tsx +175 -0
  20. package/src/components/ScrolledContent.tsx +15 -13
  21. package/src/components/SkillsManager.tsx +3 -3
  22. package/src/components/TaskList.tsx +3 -3
  23. package/src/components/ToolDisplay.tsx +3 -18
  24. package/src/components/WorkflowManager.tsx +150 -136
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +6 -0
  27. package/src/contexts/useChat.tsx +308 -154
  28. package/src/daemon/commands.ts +31 -22
  29. package/src/daemon-cli.ts +6 -1
  30. package/src/hooks/useInputManager.ts +25 -39
  31. package/src/hooks/useLineScroll.ts +51 -5
  32. package/src/hooks/useRemoteControl.ts +346 -0
  33. package/src/index.ts +35 -0
  34. package/src/managers/inputHandlers.ts +5 -0
  35. package/src/managers/inputReducer.ts +39 -0
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/workflowManagerReducer.ts +21 -2
  38. package/src/remote/frameBudget.ts +45 -0
  39. package/src/remote/hostIdentity.ts +128 -0
  40. package/src/remote/messageWindow.ts +46 -0
  41. package/src/remote/notificationCoalescer.ts +192 -0
  42. package/src/remote/relayClient.ts +690 -0
  43. package/src/remote/remoteControlCliHost.ts +420 -0
  44. package/src/remote/remoteControlCommands.ts +197 -0
  45. package/src/remote/remoteControlConfig.ts +353 -0
  46. package/src/remote/remoteControlService.ts +822 -0
  47. package/src/remote/remoteControlSink.ts +78 -0
  48. package/src/remote/remoteNotifications.ts +200 -0
  49. package/src/stdio/agentBridge.ts +874 -106
  50. package/src/stdio/daemonServer.ts +30 -0
  51. package/src/stdio/hostSlashCommands.ts +209 -0
  52. package/src/stdio/jsonRpcConnection.ts +11 -2
  53. package/src/stdio/protocol.ts +14 -115
  54. package/src/stdio/stdioServer.ts +33 -2
  55. package/src/stdio/uiPayload.ts +65 -0
  56. package/src/stdio-cli.ts +11 -2
  57. package/src/types.ts +7 -0
  58. package/src/utils/gitCredentialHelper.ts +106 -0
  59. package/src/utils/logger.ts +5 -2
  60. package/src/utils/rewindCheckpoints.ts +2 -4
  61. package/src/utils/workspaceDiff.ts +620 -0
  62. package/src/utils/worktree.ts +97 -0
  63. package/src/utils/bracketedPaste.ts +0 -170
@@ -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
- // Single throttled entry for ALL message-state updates — one 500ms
458
- // window-concat window shared by every callback (content/reasoning deltas,
459
- // tool parametersChunk, tool `running` stage shortResult/result, one-shot
460
- // structural updates). The tool `running` stage used to bypass throttling
461
- // (bash shortResult height changes per chunk → layout flicker); routing it
462
- // through the same window as streaming deltas coalesces it to ≤1 render per
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
- if (isExpanded) {
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
- // Full terminal clear + remount so Ink's append-only <Static> re-renders.
570
- // Used on structural actions (/clear, /compact, rewind, ctrl-o) where stale
571
- // Static output must not linger on screen.
572
- const forceRemount = useCallback(() => {
573
- stdout?.write("\u001b[2J\u001b[3J\u001b[0;0H", () => {
574
- setRemountKey((prev) => prev + 1);
575
- });
576
- }, [stdout]);
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
- // Full-list refresh — one-shot pull from the agent, used only for structural
584
- // changes (compact/clear/rewind/collapse/init). Streaming updates flow through
585
- // the incremental callbacks in initializeAgent below.
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
- if (!isExpandedRef.current && agentRef.current) {
588
- // Pull the full UI display stream (keeps pre-compaction history);
589
- // `agent.messages` would only expose the folded API context.
590
- const msgs = agentRef.current.displayMessages.map(snapshotMessage);
591
- // Snapshot-safe: the full-list replacement makes the SDK state
592
- // authoritative. Any update still queued inside the 500ms throttle
593
- // window was applied to the SDK before this pull, so it is already
594
- // contained in `msgs` — dropping it prevents the trailing-edge flush
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();
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
- }, [updateMessages]);
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: resolve,
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
- // All of these funnel through the single throttled `updateMessages`
647
- // entry, so every message-state update shares one 500ms window.
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
- updateMessages((prev) =>
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
- updateMessages((prev) =>
699
+ setMessages((prev) =>
673
700
  prev.some((m) => m.id === messageId) ? prev : [...prev, snapshot],
674
701
  );
675
- updateMessages.flush();
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
- updateMessages((prev) => applyContentDelta(prev, params));
680
- if (params.stage === "end") updateMessages.flush();
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
- updateMessages((prev) => applyReasoningDelta(prev, params));
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
- updateMessages((prev) => applyToolBlockUpdate(prev, params));
690
- if (params.stage === "end") updateMessages.flush();
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
- updateMessages((prev) => {
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 — snapshot the SDK messages (never hold live
851
- // references; see snapshotMessage). Uses the full display stream so a
852
- // restored session shows pre-compaction history too.
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.displayMessages.map(snapshotMessage));
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.workingDirectory);
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
- }, [updateMessages]);
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.workingDirectory);
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,