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.
- package/dist/bundle/wave.mjs +713 -842
- package/package.json +7 -7
- package/src/cli.tsx +14 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +22 -15
- 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 -13
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- 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 +117 -3
- package/src/contexts/useChat.tsx +87 -165
- package/src/daemon/commands.ts +18 -18
- package/src/hooks/useInputManager.ts +24 -42
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +23 -1
- package/src/managers/inputHandlers.ts +4 -1
- package/src/managers/inputReducer.ts +39 -1
- package/src/remote/messageWindow.ts +18 -69
- package/src/remote/notificationCoalescer.ts +21 -3
- package/src/remote/relayClient.ts +48 -26
- package/src/remote/remoteControlCliHost.ts +72 -43
- package/src/remote/remoteControlConfig.ts +25 -6
- package/src/remote/remoteControlService.ts +93 -70
- package/src/remote/remoteControlSink.ts +4 -3
- package/src/remote/remoteNotifications.ts +8 -2
- package/src/stdio/agentBridge.ts +325 -91
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/uiPayload.ts +65 -0
- package/src/types.ts +7 -0
- package/src/utils/bracketedPaste.ts +0 -170
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
832
|
+
agent.currentWorkdir;
|
|
755
833
|
} else {
|
|
756
|
-
createdWorkdir = params.workdir ?? agent.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
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`
|
|
1705
|
-
* - `
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
1708
|
-
*
|
|
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
|
|
1892
|
+
* caller re-pulls instead of guessing.
|
|
1716
1893
|
*
|
|
1717
|
-
* Ids can repeat
|
|
1718
|
-
* anchors are resolved to their **last** occurrence:
|
|
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?:
|
|
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
|
|
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
|
|
1908
|
+
let normalized: MessageWindow | undefined;
|
|
1742
1909
|
try {
|
|
1743
|
-
|
|
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
|
-
|
|
1751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2150
|
-
|
|
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
|
-
|
|
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:
|
|
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?.
|
|
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?.
|
|
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?.
|
|
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.
|
|
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 {
|