wave-code 1.4.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 (41) hide show
  1. package/dist/bundle/wave.mjs +713 -842
  2. package/package.json +7 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/components/AgentsManager.tsx +3 -3
  5. package/src/components/App.tsx +5 -0
  6. package/src/components/BtwDisplay.tsx +71 -15
  7. package/src/components/CompactDisplay.tsx +1 -6
  8. package/src/components/ConfirmationDetails.tsx +22 -15
  9. package/src/components/ConfirmationSelector.tsx +31 -36
  10. package/src/components/DiffDisplay.tsx +1 -1
  11. package/src/components/HooksManager.tsx +3 -3
  12. package/src/components/InputBox.tsx +37 -13
  13. package/src/components/LoginCommand.tsx +16 -35
  14. package/src/components/Markdown.tsx +7 -3
  15. package/src/components/MarketplaceAddForm.tsx +15 -19
  16. package/src/components/RemoteControlView.tsx +175 -0
  17. package/src/components/ScrolledContent.tsx +15 -13
  18. package/src/components/SkillsManager.tsx +3 -3
  19. package/src/components/TaskList.tsx +3 -3
  20. package/src/components/ToolDisplay.tsx +3 -18
  21. package/src/components/WorkflowManager.tsx +117 -3
  22. package/src/contexts/useChat.tsx +87 -165
  23. package/src/daemon/commands.ts +18 -18
  24. package/src/hooks/useInputManager.ts +24 -42
  25. package/src/hooks/useLineScroll.ts +51 -5
  26. package/src/hooks/useRemoteControl.ts +23 -1
  27. package/src/managers/inputHandlers.ts +4 -1
  28. package/src/managers/inputReducer.ts +39 -1
  29. package/src/remote/messageWindow.ts +18 -69
  30. package/src/remote/notificationCoalescer.ts +21 -3
  31. package/src/remote/relayClient.ts +48 -26
  32. package/src/remote/remoteControlCliHost.ts +72 -43
  33. package/src/remote/remoteControlConfig.ts +25 -6
  34. package/src/remote/remoteControlService.ts +93 -70
  35. package/src/remote/remoteControlSink.ts +4 -3
  36. package/src/remote/remoteNotifications.ts +8 -2
  37. package/src/stdio/agentBridge.ts +325 -91
  38. package/src/stdio/hostSlashCommands.ts +209 -0
  39. package/src/stdio/uiPayload.ts +65 -0
  40. package/src/types.ts +7 -0
  41. package/src/utils/bracketedPaste.ts +0 -170
@@ -37,6 +37,8 @@ import {
37
37
  isValidHookEvent,
38
38
  listSessions,
39
39
  listAllSessions,
40
+ readRecentWorkdirs,
41
+ rememberRecentWorkdir,
40
42
  setSessionCustomTitle,
41
43
  searchFiles,
42
44
  generateRandomName,
@@ -46,7 +48,9 @@ import {
46
48
  AuthService,
47
49
  PluginCore,
48
50
  validateWorktreeRemovalPath,
49
- type SlashCommand,
51
+ type HostSurface,
52
+ type SlashCommandInfo,
53
+ type ConfiguredModel,
50
54
  loadUserConfigEnv,
51
55
  loadWaveConfigFromFile,
52
56
  getUserConfigPaths,
@@ -59,7 +63,18 @@ import {
59
63
  type SkillMetadata,
60
64
  type PluginReloadResult,
61
65
  type RemoteControlSnapshot,
66
+ type CompletionSnippet,
67
+ type NextEditExcerpt,
68
+ type NextEditHistoryEntry,
69
+ type NextEditRegion,
70
+ type NextEditSuggestion,
62
71
  normalizeEntrypoint,
72
+ fetchArtifactGalleryList,
73
+ fetchArtifactShareView,
74
+ saveArtifactShare,
75
+ revokeArtifact,
76
+ searchArtifactMembers,
77
+ type ArtifactShareMode,
63
78
  } from "wave-agent-sdk";
64
79
  import {
65
80
  type ClientNotificationMethod,
@@ -91,7 +106,8 @@ import {
91
106
  import { logger } from "../utils/logger.js";
92
107
  import {
93
108
  InvalidMessageWindowError,
94
- sliceMessageWindow,
109
+ normalizeMessageWindow,
110
+ type MessageWindow,
95
111
  type MessageWindowResult,
96
112
  } from "../remote/messageWindow.js";
97
113
  import {
@@ -103,6 +119,12 @@ import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
103
119
  import { getWorkspaceDiff } from "../utils/workspaceDiff.js";
104
120
  import { installGitCredentialHelper } from "../utils/gitCredentialHelper.js";
105
121
  import type { RemoteControlController } from "../remote/remoteControlService.js";
122
+ import {
123
+ hostSlashCommandsFor,
124
+ parseHostSurface,
125
+ toSlashCommandInfo,
126
+ } from "./hostSlashCommands.js";
127
+ import { messageForUi, messagesForUi } from "./uiPayload.js";
106
128
 
107
129
  export type NotificationEmitter = (
108
130
  method: ServerNotificationMethod,
@@ -179,7 +201,7 @@ interface SessionEntry {
179
201
  storedConfig: Partial<InitializeParams>;
180
202
  /**
181
203
  * The workdir the session was CREATED in — an immutable identity, unlike the
182
- * live `agent.workingDirectory`, which drifts as the session `cd`s (bash tool
204
+ * live `agent.currentWorkdir`, which drifts as the session `cd`s (bash tool
183
205
  * / worktree switches). Hosted-session consumers (destroy --remove-worktree's
184
206
  * worktree resolution, list) read this instead of the live value so a session
185
207
  * that moved out of its worktree into the main repo is still cleaned up at
@@ -241,6 +263,14 @@ export class AgentBridge {
241
263
  * docs/specs/ui/code-completion.md: 同一时刻只允许一个补全请求在途).
242
264
  */
243
265
  private inFlightCompletions = new Map<string, AbortController>();
266
+ /**
267
+ * In-flight next-edit calls, same one-per-session rule and same reason as
268
+ * completions. It also participates in the cross-capability exclusion from
269
+ * `docs/specs/ui/next-edit-suggestion.md`: completion and next-edit never hold
270
+ * an in-flight request at the same time, so one of them cannot answer into a
271
+ * buffer the other has already rewritten.
272
+ */
273
+ private inFlightNextEdits = new Map<string, AbortController>();
244
274
  private emit: NotificationEmitter;
245
275
  private onShutdownRequest: (() => void) | undefined;
246
276
  private remoteControl:
@@ -370,7 +400,6 @@ export class AgentBridge {
370
400
  return this.deleteQueuedMessageById(p.id as string, sessionId);
371
401
  case "getMessages":
372
402
  return this.getMessages(sessionId, {
373
- before: p.before as string | undefined,
374
403
  since: p.since as string | undefined,
375
404
  limit: p.limit as number | undefined,
376
405
  });
@@ -410,7 +439,7 @@ export class AgentBridge {
410
439
 
411
440
  // ── Commands ──
412
441
  case "getSlashCommands":
413
- return this.getSlashCommands(sessionId);
442
+ return this.getSlashCommands(sessionId, parseHostSurface(p.surface));
414
443
  case "getSubagentConfigurations":
415
444
  return this.getSubagentConfigurations(sessionId);
416
445
  case "getSkillMetadata":
@@ -462,6 +491,28 @@ export class AgentBridge {
462
491
  case "installGitCredentialHelper":
463
492
  return installGitCredentialHelper();
464
493
 
494
+ // ── 创作集(global — 账号级读写,无 session 要求)──
495
+ // 桌面端整页与右侧预览面板共用这一组;HTTP 与 token 只留在本进程
496
+ // (services/artifactGallery.ts),宿主不直连网络。
497
+ case "artifactGalleryList":
498
+ return fetchArtifactGalleryList();
499
+ case "artifactShareView":
500
+ return fetchArtifactShareView(p.slug as string);
501
+ case "artifactShareSave":
502
+ return saveArtifactShare({
503
+ slug: p.slug as string,
504
+ mode: p.mode as ArtifactShareMode,
505
+ userIds: p.userIds as string[] | undefined,
506
+ pinnedVersion: p.pinnedVersion as number | null | undefined,
507
+ });
508
+ case "artifactRevoke":
509
+ return revokeArtifact(p.slug as string);
510
+ case "artifactMembersSearch":
511
+ return searchArtifactMembers(
512
+ (p.search as string | undefined) ?? "",
513
+ p.limit as number | undefined,
514
+ );
515
+
465
516
  // ── Memory files (settings UI — user-level ~/.wave/AGENTS.md and
466
517
  // project-level <workdir>/AGENTS.md) ──
467
518
  case "getAgentsContent":
@@ -601,6 +652,14 @@ export class AgentBridge {
601
652
  // ── Filesystem / Git / worktree (global — no session required) ──
602
653
  case "listDirectories":
603
654
  return this.listDirectories(p.dir as string | undefined);
655
+ // ── 最近打开的工作目录(spec core/recent-workdirs.md)──
656
+ // 机器级、与会话无关:文件就在这台机器上,谁问就回谁的。桌面端按主机
657
+ // 各问一次,远控 Web 页问它连着的那个进程。
658
+ case "getRecentWorkdirs":
659
+ return { dirs: readRecentWorkdirs() };
660
+ case "rememberRecentWorkdir":
661
+ rememberRecentWorkdir(p.workdir as string);
662
+ return null;
604
663
  case "listGitBranches":
605
664
  return this.listGitBranches(p.workdir as string | undefined);
606
665
  case "createWorktree":
@@ -639,6 +698,28 @@ export class AgentBridge {
639
698
  suffix?: string;
640
699
  language?: string;
641
700
  filePath?: string;
701
+ snippets?: CompletionSnippet[];
702
+ },
703
+ sessionId,
704
+ );
705
+ case "reportCompletionOutcome":
706
+ return this.reportCompletionOutcome(
707
+ p.outcome as "displayed" | "accepted",
708
+ sessionId,
709
+ );
710
+ case "reportNextEditOutcome":
711
+ return this.reportNextEditOutcome(
712
+ p.outcome as "displayed" | "accepted",
713
+ sessionId,
714
+ );
715
+ case "getNextEdit":
716
+ return this.getNextEdit(
717
+ p as unknown as {
718
+ edits?: NextEditHistoryEntry[];
719
+ region?: NextEditRegion;
720
+ excerpt?: NextEditExcerpt;
721
+ language?: string;
722
+ filePath?: string;
642
723
  },
643
724
  sessionId,
644
725
  );
@@ -680,7 +761,9 @@ export class AgentBridge {
680
761
 
681
762
  private async initialize(params: InitializeParams): Promise<{
682
763
  sessionId: string;
683
- workingDirectory: string;
764
+ currentWorkdir: string;
765
+ sessionCwd: string;
766
+ repoRoot?: string;
684
767
  permissionMode: PermissionMode;
685
768
  latestTotalTokens: number;
686
769
  }> {
@@ -690,12 +773,7 @@ export class AgentBridge {
690
773
  if (params.restoreSessionId) {
691
774
  const live = this.sessions.get(params.restoreSessionId);
692
775
  if (live) {
693
- return {
694
- sessionId: live.agent.sessionId,
695
- workingDirectory: live.agent.workingDirectory,
696
- permissionMode: live.agent.getPermissionMode(),
697
- latestTotalTokens: live.agent.latestTotalTokens,
698
- };
776
+ return this.initializeReply(live.agent, live.createdWorkdir);
699
777
  }
700
778
  }
701
779
 
@@ -751,9 +829,9 @@ export class AgentBridge {
751
829
  createdWorkdir =
752
830
  (await readTranscriptWorkdir(agent.sessionFilePath)) ??
753
831
  params.workdir ??
754
- agent.workingDirectory;
832
+ agent.currentWorkdir;
755
833
  } else {
756
- createdWorkdir = params.workdir ?? agent.workingDirectory;
834
+ createdWorkdir = params.workdir ?? agent.currentWorkdir;
757
835
  }
758
836
 
759
837
  this.sessions.set(agent.sessionId, {
@@ -764,9 +842,43 @@ export class AgentBridge {
764
842
  createdWorkdir,
765
843
  });
766
844
 
845
+ return this.initializeReply(agent, createdWorkdir);
846
+ }
847
+
848
+ /**
849
+ * The `initialize` reply.
850
+ *
851
+ * The current cwd and the session root travel as two separate facts on
852
+ * purpose. A re-attached session may have bash-`cd`'d away from where it was
853
+ * created, so `agent.currentWorkdir` answers "where is it now" while
854
+ * `createdWorkdir` answers "which project does this conversation belong to".
855
+ * Hosts key durable things (sidebar grouping, project-scoped config, `@file`
856
+ * scope) off the latter — the desktop used to derive both from one
857
+ * `workingDirectory` field and moved a resumed conversation into a
858
+ * subdirectory group.
859
+ *
860
+ * `repoRoot` closes the worktree case: a session created inside a linked git
861
+ * worktree belongs to the repo it was forked from. It is derived the same way
862
+ * `listDaemonSessions` derives each row's `worktree`, i.e. from the
863
+ * filesystem, without shelling out to git.
864
+ */
865
+ private initializeReply(
866
+ agent: Agent,
867
+ sessionCwd: string,
868
+ ): {
869
+ sessionId: string;
870
+ currentWorkdir: string;
871
+ sessionCwd: string;
872
+ repoRoot?: string;
873
+ permissionMode: PermissionMode;
874
+ latestTotalTokens: number;
875
+ } {
876
+ const worktree = deriveWorktreeInfo(sessionCwd);
767
877
  return {
768
878
  sessionId: agent.sessionId,
769
- workingDirectory: agent.workingDirectory,
879
+ currentWorkdir: agent.currentWorkdir,
880
+ sessionCwd,
881
+ ...(worktree ? { repoRoot: worktree.repoRoot } : {}),
770
882
  permissionMode: agent.getPermissionMode(),
771
883
  latestTotalTokens: agent.latestTotalTokens,
772
884
  };
@@ -1186,6 +1298,7 @@ export class AgentBridge {
1186
1298
  suffix?: string;
1187
1299
  language?: string;
1188
1300
  filePath?: string;
1301
+ snippets?: CompletionSnippet[];
1189
1302
  },
1190
1303
  sessionId?: string,
1191
1304
  ): Promise<{ text: string; model: string }> {
@@ -1193,8 +1306,10 @@ export class AgentBridge {
1193
1306
  const key = sessionId as string;
1194
1307
 
1195
1308
  // Supersede the session's previous request: its answer would land at a
1196
- // cursor position that no longer exists.
1309
+ // cursor position that no longer exists. A next-edit suggestion in flight is
1310
+ // dropped too — both rewrite the same buffer (spec ui/next-edit-suggestion.md).
1197
1311
  this.inFlightCompletions.get(key)?.abort();
1312
+ this.inFlightNextEdits.get(key)?.abort();
1198
1313
  const controller = new AbortController();
1199
1314
  this.inFlightCompletions.set(key, controller);
1200
1315
 
@@ -1204,6 +1319,10 @@ export class AgentBridge {
1204
1319
  suffix: params.suffix ?? "",
1205
1320
  ...(params.language ? { language: params.language } : {}),
1206
1321
  ...(params.filePath ? { filePath: params.filePath } : {}),
1322
+ // `undefined` (host cannot collect snippets) and `[]` (tried, found
1323
+ // nothing) stay distinguishable here and both mean "no snippets" to
1324
+ // the model; only an absent field is left out of the call.
1325
+ ...(params.snippets ? { snippets: params.snippets } : {}),
1207
1326
  abortSignal: controller.signal,
1208
1327
  });
1209
1328
  } finally {
@@ -1214,6 +1333,55 @@ export class AgentBridge {
1214
1333
  }
1215
1334
  }
1216
1335
 
1336
+ /**
1337
+ * One next-edit suggestion for the editor plugins (spec
1338
+ * ui/next-edit-suggestion.md).
1339
+ *
1340
+ * Session-scoped for the same reason as completion: the model, the switch and
1341
+ * the credentials all live in that session. Read-only, so a suggestion still
1342
+ * resolves while `updateConfig` rebuilds the session.
1343
+ */
1344
+ private async getNextEdit(
1345
+ params: {
1346
+ edits?: NextEditHistoryEntry[];
1347
+ region?: NextEditRegion;
1348
+ excerpt?: NextEditExcerpt;
1349
+ language?: string;
1350
+ filePath?: string;
1351
+ },
1352
+ sessionId?: string,
1353
+ ): Promise<{ suggestion: NextEditSuggestion | null; model: string }> {
1354
+ const entry = this.requireSessionForRead(sessionId);
1355
+ const key = sessionId as string;
1356
+
1357
+ // A region and its excerpt are the whole basis of the answer; without them
1358
+ // there is nothing to predict into, so this is a no-op rather than an error.
1359
+ if (!params.region || !params.excerpt) {
1360
+ return { suggestion: null, model: "" };
1361
+ }
1362
+
1363
+ // Same exclusion as the completion path, from this side.
1364
+ this.inFlightNextEdits.get(key)?.abort();
1365
+ this.inFlightCompletions.get(key)?.abort();
1366
+ const controller = new AbortController();
1367
+ this.inFlightNextEdits.set(key, controller);
1368
+
1369
+ try {
1370
+ return await entry.agent.getNextEdit({
1371
+ edits: params.edits ?? [],
1372
+ region: params.region,
1373
+ excerpt: params.excerpt,
1374
+ ...(params.language ? { language: params.language } : {}),
1375
+ ...(params.filePath ? { filePath: params.filePath } : {}),
1376
+ abortSignal: controller.signal,
1377
+ });
1378
+ } finally {
1379
+ if (this.inFlightNextEdits.get(key) === controller) {
1380
+ this.inFlightNextEdits.delete(key);
1381
+ }
1382
+ }
1383
+ }
1384
+
1217
1385
  private async createWorktreeSession(params: {
1218
1386
  workdir: string;
1219
1387
  baseBranch?: string;
@@ -1301,7 +1469,7 @@ export class AgentBridge {
1301
1469
 
1302
1470
  private async getSessionInfo(sessionId?: string): Promise<{
1303
1471
  sessionId: string;
1304
- workingDirectory: string;
1472
+ sessionCwd: string;
1305
1473
  latestTotalTokens: number;
1306
1474
  permissionMode: PermissionMode;
1307
1475
  availableTools: string[];
@@ -1314,10 +1482,10 @@ export class AgentBridge {
1314
1482
  const entry = this.requireSessionForRead(sessionId);
1315
1483
  return {
1316
1484
  sessionId: entry.agent.sessionId,
1317
- // The recorded creation workdir, not the live agent.workingDirectory:
1485
+ // The recorded creation workdir, not the live agent.currentWorkdir:
1318
1486
  // destroy --remove-worktree resolves the session's worktree from this
1319
1487
  // value, and it must not drift when the session cd'd out of the worktree.
1320
- workingDirectory: entry.createdWorkdir,
1488
+ sessionCwd: entry.createdWorkdir,
1321
1489
  latestTotalTokens: entry.agent.latestTotalTokens,
1322
1490
  permissionMode: entry.agent.getPermissionMode(),
1323
1491
  availableTools: entry.agent.getAvailableToolNames(),
@@ -1447,7 +1615,7 @@ export class AgentBridge {
1447
1615
  }
1448
1616
 
1449
1617
  private getConfiguredModels(sessionId?: string): {
1450
- models: string[];
1618
+ models: ConfiguredModel[];
1451
1619
  currentModel: string | undefined;
1452
1620
  } {
1453
1621
  const entry = this.requireSessionForRead(sessionId);
@@ -1519,20 +1687,31 @@ export class AgentBridge {
1519
1687
  sessionId?: string,
1520
1688
  ): Promise<null> {
1521
1689
  const entry = this.requireSession(sessionId);
1522
- if (params.force) {
1523
- entry.agent.abortMessage();
1524
- }
1525
1690
  // Save prompt to history (mirrors VSCE chatSession.ts:236-242)
1526
1691
  try {
1527
1692
  await PromptHistoryManager.addEntry(
1528
1693
  params.text,
1529
1694
  entry.agent.sessionId,
1530
1695
  {},
1531
- entry.agent.workingDirectory,
1696
+ entry.agent.currentWorkdir,
1532
1697
  );
1533
1698
  } catch {
1534
1699
  // Best-effort; don't block message sending on history save failure
1535
1700
  }
1701
+ if (params.force) {
1702
+ // "Send now" for one queued item: interrupt the running turn but keep the
1703
+ // rest of the queue — the host removes the picked item separately, and
1704
+ // everything it did not touch must still be delivered afterwards.
1705
+ //
1706
+ // The abort must be immediately followed by `sendMessage` with nothing
1707
+ // awaited in between: the abort resets the loading flag, which is the gate
1708
+ // a queued item waits behind, so any await here lets the *next* queued
1709
+ // message start a turn first — the picked one would then be enqueued at
1710
+ // the tail instead of going out now (realhost smoke: with the history
1711
+ // write above the abort, a `deleteQueuedMessageById` landing in that gap
1712
+ // started the remaining message immediately).
1713
+ entry.agent.abortMessage({ keepQueue: true });
1714
+ }
1536
1715
  await entry.agent.sendMessage(
1537
1716
  params.text,
1538
1717
  this.persistDataUrlImages(params.images),
@@ -1694,61 +1873,72 @@ export class AgentBridge {
1694
1873
  /**
1695
1874
  * Read a session's display messages.
1696
1875
  *
1697
- * With no window this returns the whole list — that is the contract every
1698
- * existing host relies on (VS Code / JetBrains / desktop call `getMessages`
1699
- * with no parameters and must keep seeing the full thread, byte for byte).
1700
- * The window exists for remote clients on a phone, which only ever want the
1701
- * tail of a long conversation and must not be handed 13 MB on every cold
1702
- * start:
1876
+ * With no parameters this returns the whole list the agent holds, read once
1877
+ * and cached nowhere (spec ui/message-rendering-system.md「首屏就是整份列表,
1878
+ * 不分页」). Two parameters narrow it, both for the remote page:
1703
1879
  *
1704
- * - `limit` — page size.
1705
- * - `before` — the page that ends immediately before the given message id
1706
- * (paging *backwards* into history).
1707
- * - `since` — everything appended after the given message id (catching up
1708
- * after a reconnect).
1880
+ * - `limit` — the newest `limit` messages (that page's frame budget).
1881
+ * - `since` — everything appended after the given message id (catching up
1882
+ * after a reconnect).
1883
+ *
1884
+ * The list starts at the last compaction, so its earliest entry is the
1885
+ * earliest any UI can reach; there is nothing older to read back.
1709
1886
  *
1710
1887
  * `since` is a position, not a version number: message ids are stable, so a
1711
1888
  * caller can name its last known message without the list carrying a
1712
1889
  * sequence number. The one failure that matters is the anchor having been
1713
1890
  * truncated away (rewind / clear) — there is no seq/version in the store that
1714
1891
  * could detect it otherwise — and that comes back as `reset: true` so the
1715
- * caller re-pulls the tail instead of guessing.
1892
+ * caller re-pulls instead of guessing.
1716
1893
  *
1717
- * Ids can repeat in the display list (a compacted message is re-added), so
1718
- * anchors are resolved to their **last** occurrence: "after `since`" means
1719
- * after the newest copy the caller could have seen.
1894
+ * Ids can repeat (old transcripts re-appended the preserved rounds at each
1895
+ * compact point), so anchors are resolved to their **last** occurrence:
1896
+ * "after `since`" means after the newest copy the caller could have seen.
1720
1897
  */
1721
- private getMessages(
1898
+ private async getMessages(
1722
1899
  sessionId?: string,
1723
- window?: { before?: string; since?: string; limit?: number },
1724
- ): {
1725
- messages: Message[];
1726
- contextUsagePercent?: number;
1727
- /** Older messages exist before the returned page. */
1728
- hasMore?: boolean;
1729
- /** The `since` anchor is gone — rebuild from the tail. */
1730
- reset?: boolean;
1731
- /** Total display messages held for the session. */
1732
- total?: number;
1733
- } {
1900
+ window?: MessageWindow,
1901
+ ): Promise<MessageWindowResult> {
1734
1902
  const entry = this.requireSessionForRead(sessionId);
1735
- const all = entry.agent.displayMessages;
1736
1903
  const contextUsagePercent = this.contextUsagePercentOf(entry.agent);
1737
1904
 
1738
- // The slicing is shared with the CLI front end (`RemoteControlCliHost`): the
1739
- // phone's reconciliation rules belong to the wire contract, not to this
1905
+ // The validation is shared with the CLI front end (`RemoteControlCliHost`):
1906
+ // the phone's reconciliation rules belong to the wire contract, not to this
1740
1907
  // registry, and the two hosts must answer identically.
1741
- let sliced: MessageWindowResult | undefined;
1908
+ let normalized: MessageWindow | undefined;
1742
1909
  try {
1743
- sliced = sliceMessageWindow(all, window);
1910
+ normalized = normalizeMessageWindow(window);
1744
1911
  } catch (err) {
1745
1912
  if (err instanceof InvalidMessageWindowError) {
1746
1913
  throw new RpcError(PROTOCOL_INVALID_PARAMS, err.message);
1747
1914
  }
1748
1915
  throw err;
1749
1916
  }
1750
- if (!sliced) return { messages: all, contextUsagePercent };
1751
- return { ...sliced, contextUsagePercent };
1917
+ // The totals ride along with the window: the list starts at the last
1918
+ // compaction, so this reply is the only place a host can learn the
1919
+ // thread's size and its first user message (the session-title fallback).
1920
+ const threadSummary = entry.agent.getThreadSummary();
1921
+ // Trimmed on the way out, never in the agent's own list: no UI renders tool
1922
+ // pictures, and the agent has to keep them for the next request
1923
+ // (specs/ui/stdio-transport.md「工具图片的字节不下发 UI」). The trim is
1924
+ // copy-on-write, so a message with no picture keeps its identity.
1925
+ if (!normalized) {
1926
+ return {
1927
+ // `Agent.messages` already hands back a copy, so this reply is a
1928
+ // single-pass view for the host that asked once — do not copy again
1929
+ // (a long session makes this O(n) per call).
1930
+ messages: messagesForUi(entry.agent.messages),
1931
+ contextUsagePercent,
1932
+ threadSummary,
1933
+ };
1934
+ }
1935
+ const page = entry.agent.getMessagePage(normalized);
1936
+ return {
1937
+ ...page,
1938
+ messages: messagesForUi(page.messages),
1939
+ contextUsagePercent,
1940
+ threadSummary,
1941
+ };
1752
1942
  }
1753
1943
 
1754
1944
  private async getFullMessageThread(sessionId?: string): Promise<{
@@ -1756,7 +1946,8 @@ export class AgentBridge {
1756
1946
  sessionIds: string[];
1757
1947
  }> {
1758
1948
  const entry = this.requireSessionForRead(sessionId);
1759
- return entry.agent.getFullMessageThread();
1949
+ const thread = await entry.agent.getFullMessageThread();
1950
+ return { ...thread, messages: messagesForUi(thread.messages) };
1760
1951
  }
1761
1952
 
1762
1953
  private async compact(
@@ -1832,6 +2023,43 @@ export class AgentBridge {
1832
2023
  return { mode: entry.agent.getPermissionMode() };
1833
2024
  }
1834
2025
 
2026
+ /**
2027
+ * Records what the editor host saw happen to the last suggestion —
2028
+ * `displayed` when it rendered the ghost text, `accepted` when the user took
2029
+ * it. The outcome is the only thing the host reports: the model and the
2030
+ * language stay on this side, derived when the completion was produced.
2031
+ */
2032
+ private reportCompletionOutcome(
2033
+ outcome: "displayed" | "accepted",
2034
+ sessionId?: string,
2035
+ ): { ok: true } {
2036
+ const entry = this.requireSessionForRead(sessionId);
2037
+ if (outcome !== "displayed" && outcome !== "accepted") {
2038
+ throw new RpcError(
2039
+ PROTOCOL_INTERNAL_ERROR,
2040
+ "outcome must be 'displayed' or 'accepted'",
2041
+ );
2042
+ }
2043
+ entry.agent.reportCompletionOutcome(outcome);
2044
+ return { ok: true };
2045
+ }
2046
+
2047
+ /** The next-edit twin: same validation, its own metric series in the SDK. */
2048
+ private reportNextEditOutcome(
2049
+ outcome: "displayed" | "accepted",
2050
+ sessionId?: string,
2051
+ ): { ok: true } {
2052
+ const entry = this.requireSessionForRead(sessionId);
2053
+ if (outcome !== "displayed" && outcome !== "accepted") {
2054
+ throw new RpcError(
2055
+ PROTOCOL_INTERNAL_ERROR,
2056
+ "outcome must be 'displayed' or 'accepted'",
2057
+ );
2058
+ }
2059
+ entry.agent.reportNextEditOutcome(outcome);
2060
+ return { ok: true };
2061
+ }
2062
+
1835
2063
  /**
1836
2064
  * Returns the current plan file path and its contents for the session.
1837
2065
  * When the path generation is still in flight (e.g. the host just switched
@@ -1906,9 +2134,28 @@ export class AgentBridge {
1906
2134
 
1907
2135
  // ── Commands ──────────────────────────────────────────────────
1908
2136
 
1909
- private getSlashCommands(sessionId?: string): { commands: SlashCommand[] } {
2137
+ /**
2138
+ * The popup's rows: the agent's own commands (skills / custom / plugin) plus
2139
+ * the host-local table for the front end that asked.
2140
+ *
2141
+ * `surface` is optional on purpose: a client that predates the field asks
2142
+ * without it and gets exactly what it got before (the SDK half), instead of
2143
+ * being handed rows it has no handling for. Unknown values are dropped the
2144
+ * same way by `parseHostSurface`.
2145
+ */
2146
+ private getSlashCommands(
2147
+ sessionId?: string,
2148
+ surface?: HostSurface,
2149
+ ): { commands: SlashCommandInfo[] } {
1910
2150
  const entry = this.requireSessionForRead(sessionId);
1911
- return { commands: entry.agent.getSlashCommands() };
2151
+ const agentCommands = entry.agent
2152
+ .getSlashCommands()
2153
+ .map((command) => toSlashCommandInfo(command));
2154
+ return {
2155
+ commands: surface
2156
+ ? [...agentCommands, ...hostSlashCommandsFor(surface)]
2157
+ : agentCommands,
2158
+ };
1912
2159
  }
1913
2160
 
1914
2161
  private getSubagentConfigurations(sessionId?: string): {
@@ -2114,7 +2361,7 @@ export class AgentBridge {
2114
2361
  private listDaemonSessions(): {
2115
2362
  sessions: Array<{
2116
2363
  sessionId: string;
2117
- workingDirectory: string;
2364
+ sessionCwd: string;
2118
2365
  isLoading: boolean;
2119
2366
  hasRunningBackgroundWork: boolean;
2120
2367
  hasPendingMessages: boolean;
@@ -2144,20 +2391,23 @@ export class AgentBridge {
2144
2391
  } {
2145
2392
  return {
2146
2393
  sessions: [...this.sessions.entries()].map(([sessionId, entry]) => {
2147
- const messages = entry.agent.displayMessages;
2148
2394
  const customTitle = entry.agent.customTitle;
2149
- const firstMessage = firstUserText(messages);
2150
- const lastActiveAt = messages[messages.length - 1]?.timestamp;
2395
+ // Totals come from the agent's counters, not from `displayMessages`:
2396
+ // that is only the resident tail now, so its length and its first
2397
+ // message are no longer the thread's.
2398
+ const summary = entry.agent.getThreadSummary();
2399
+ const firstMessage = summary.firstUserText;
2400
+ const lastActiveAt = summary.lastActiveAt;
2151
2401
  // Derived from the creation workdir (not the live one) and fs-only —
2152
2402
  // see `deriveWorktreeInfo` for why this must not shell out to git.
2153
2403
  const worktree = deriveWorktreeInfo(entry.createdWorkdir);
2154
2404
  return {
2155
2405
  sessionId,
2156
- workingDirectory: entry.createdWorkdir,
2406
+ sessionCwd: entry.createdWorkdir,
2157
2407
  isLoading: entry.transitioning === true || entry.agent.isLoading,
2158
2408
  hasRunningBackgroundWork: entry.agent.hasRunningBackgroundWork,
2159
2409
  hasPendingMessages: entry.agent.hasPendingMessages,
2160
- messageCount: messages.length,
2410
+ messageCount: summary.messageCount,
2161
2411
  ...(customTitle ? { customTitle } : {}),
2162
2412
  ...(firstMessage ? { firstMessage } : {}),
2163
2413
  ...(lastActiveAt ? { lastActiveAt } : {}),
@@ -2320,7 +2570,7 @@ export class AgentBridge {
2320
2570
  return { content: await readTextFileSafe(path), path };
2321
2571
  }
2322
2572
  const resolvedWorkdir =
2323
- workdir || (agent?.workingDirectory as string | undefined);
2573
+ workdir || (agent?.currentWorkdir as string | undefined);
2324
2574
  if (!resolvedWorkdir) {
2325
2575
  throw new RpcError(
2326
2576
  PROTOCOL_INVALID_PARAMS,
@@ -2359,7 +2609,7 @@ export class AgentBridge {
2359
2609
  return { ok: true };
2360
2610
  }
2361
2611
  const resolvedWorkdir =
2362
- workdir || (agent?.workingDirectory as string | undefined);
2612
+ workdir || (agent?.currentWorkdir as string | undefined);
2363
2613
  if (!resolvedWorkdir) {
2364
2614
  throw new RpcError(
2365
2615
  PROTOCOL_INVALID_PARAMS,
@@ -2661,7 +2911,7 @@ export class AgentBridge {
2661
2911
  if (msg)
2662
2912
  this.emit(
2663
2913
  "userMessageAdded",
2664
- { message: msg },
2914
+ { message: messageForUi(msg) },
2665
2915
  ctx.registeredSessionId,
2666
2916
  );
2667
2917
  },
@@ -2670,7 +2920,7 @@ export class AgentBridge {
2670
2920
  if (msg)
2671
2921
  this.emit(
2672
2922
  "assistantMessageAdded",
2673
- { message: msg },
2923
+ { message: messageForUi(msg) },
2674
2924
  ctx.registeredSessionId,
2675
2925
  );
2676
2926
  },
@@ -2795,7 +3045,7 @@ export class AgentBridge {
2795
3045
  // than once under the same task id (a workflow warns about its size
2796
3046
  // mid-run, then notifies again when it finishes), and this callback
2797
3047
  // fires for the message that was just appended — the newest one.
2798
- const matches = (ctx.agent?.displayMessages ?? []).filter(
3048
+ const matches = (ctx.agent?.messages ?? []).filter(
2799
3049
  (m) =>
2800
3050
  m.role === "user" &&
2801
3051
  m.blocks.some(
@@ -2807,14 +3057,14 @@ export class AgentBridge {
2807
3057
  const msg = matches[matches.length - 1];
2808
3058
  this.emit(
2809
3059
  "notificationMessageAdded",
2810
- { ...params, message: msg },
3060
+ { ...params, message: msg ? messageForUi(msg) : undefined },
2811
3061
  ctx.registeredSessionId,
2812
3062
  );
2813
3063
  },
2814
3064
  onCompactBlockAdded: (content: string, message?: Message) => {
2815
3065
  this.emit(
2816
3066
  "compactBlockAdded",
2817
- { content, message },
3067
+ { content, message: message ? messageForUi(message) : undefined },
2818
3068
  ctx.registeredSessionId,
2819
3069
  );
2820
3070
  },
@@ -2891,7 +3141,7 @@ export class AgentBridge {
2891
3141
 
2892
3142
  private getSessionWorkdir(sessionId?: string): string | undefined {
2893
3143
  if (!sessionId) return undefined;
2894
- return this.sessions.get(sessionId)?.agent.workingDirectory;
3144
+ return this.sessions.get(sessionId)?.agent.currentWorkdir;
2895
3145
  }
2896
3146
  }
2897
3147
 
@@ -2923,22 +3173,6 @@ function isSessionRecoveryError(error: unknown): boolean {
2923
3173
  );
2924
3174
  }
2925
3175
 
2926
- /**
2927
- * The label a session list shows when no custom title is set: the first real
2928
- * user message. Same rule as the webview's `firstUserMessageText` — meta messages
2929
- * (task notifications and other injected turns) are skipped, because they are not
2930
- * what the user typed — so the phone labels a conversation the way the IDE hosts
2931
- * do.
2932
- */
2933
- function firstUserText(messages: Message[]): string | undefined {
2934
- for (const message of messages) {
2935
- if (message.role !== "user" || message.isMeta) continue;
2936
- const text = getMessageContent(message).trim();
2937
- if (text) return text;
2938
- }
2939
- return undefined;
2940
- }
2941
-
2942
3176
  /** Read a text file, returning "" when it does not exist. */
2943
3177
  async function readTextFileSafe(filePath: string): Promise<string> {
2944
3178
  try {