wave-code 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle/wave.mjs +1382 -1131
- package/package.json +9 -7
- package/src/cli.tsx +14 -22
- package/src/commands/update.ts +62 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +34 -13
- package/src/components/ConfirmationSelector.tsx +31 -36
- package/src/components/DiffDisplay.tsx +1 -1
- package/src/components/HooksManager.tsx +3 -3
- package/src/components/InputBox.tsx +37 -11
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/RemoteControlView.tsx +175 -0
- package/src/components/ScrolledContent.tsx +15 -13
- package/src/components/SkillsManager.tsx +3 -3
- package/src/components/TaskList.tsx +3 -3
- package/src/components/ToolDisplay.tsx +3 -18
- package/src/components/WorkflowManager.tsx +150 -136
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +308 -154
- package/src/daemon/commands.ts +31 -22
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +25 -39
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +346 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +5 -0
- package/src/managers/inputReducer.ts +39 -0
- package/src/print-cli.ts +5 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +46 -0
- package/src/remote/notificationCoalescer.ts +192 -0
- package/src/remote/relayClient.ts +690 -0
- package/src/remote/remoteControlCliHost.ts +420 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +353 -0
- package/src/remote/remoteControlService.ts +822 -0
- package/src/remote/remoteControlSink.ts +78 -0
- package/src/remote/remoteNotifications.ts +200 -0
- package/src/stdio/agentBridge.ts +874 -106
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -115
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio/uiPayload.ts +65 -0
- package/src/stdio-cli.ts +11 -2
- package/src/types.ts +7 -0
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/utils/bracketedPaste.ts +0 -170
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires the interactive CLI's own session to the remote-control relay.
|
|
3
|
+
*
|
|
4
|
+
* The daemon serves its sessions by fanning notifications into a
|
|
5
|
+
* `RemoteControlSink`; the CLI has no notification fan-out at all — every
|
|
6
|
+
* surface it has is React state. So this hook supplies that fan-out, under the
|
|
7
|
+
* same method names and payloads a `--stdio` / daemon process uses, which is what
|
|
8
|
+
* lets the page treat the two hosts identically — including the remote channel's
|
|
9
|
+
* 150 ms merge window, which only bites if the frames it merges are the same
|
|
10
|
+
* ones.
|
|
11
|
+
*
|
|
12
|
+
* Two halves, and both have to be the same shape as the bridge's:
|
|
13
|
+
*
|
|
14
|
+
* - the callbacks that carry content, which the front end hands over from its own
|
|
15
|
+
* agent callbacks (`remoteNotifications` → `notify`);
|
|
16
|
+
* - the coarse state this hook watches itself (`messagesChange` as a content-free
|
|
17
|
+
* structural signal, loading, permission mode, context usage, approvals). The
|
|
18
|
+
* page answers a signal by pulling with `getMessages({ since })`, so no payload
|
|
19
|
+
* ever rides the signal.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
23
|
+
import type {
|
|
24
|
+
BackgroundTask,
|
|
25
|
+
Message,
|
|
26
|
+
MessageWindow,
|
|
27
|
+
MessageWindowResult,
|
|
28
|
+
PermissionDecision,
|
|
29
|
+
PermissionMode,
|
|
30
|
+
QueuedMessage,
|
|
31
|
+
RemoteControlSnapshot,
|
|
32
|
+
RemoteControlStatus,
|
|
33
|
+
SlashCommand,
|
|
34
|
+
Task,
|
|
35
|
+
ToolPermissionContext,
|
|
36
|
+
} from "wave-agent-sdk";
|
|
37
|
+
import {
|
|
38
|
+
RemoteControlCliHost,
|
|
39
|
+
type RemoteControlCliSession,
|
|
40
|
+
} from "../remote/remoteControlCliHost.js";
|
|
41
|
+
import {
|
|
42
|
+
percentOf,
|
|
43
|
+
remoteWire,
|
|
44
|
+
toBackgroundTaskSummaries,
|
|
45
|
+
} from "../remote/remoteNotifications.js";
|
|
46
|
+
import type { RemoteNotify } from "../remote/remoteNotifications.js";
|
|
47
|
+
import { createRemoteControlSinkFactory } from "../remote/remoteControlSink.js";
|
|
48
|
+
import type { RemoteControlSink } from "../remote/remoteControlSink.js";
|
|
49
|
+
|
|
50
|
+
export interface UseRemoteControlOptions {
|
|
51
|
+
/** Where the shell is *now* — follows a Bash `cd`; the page's `@file` joins use it. */
|
|
52
|
+
workdir: string;
|
|
53
|
+
/**
|
|
54
|
+
* Where the session was opened (the agent's `sessionWorkdir`): the directory
|
|
55
|
+
* the page's session row keys on. Read through a function because the agent
|
|
56
|
+
* outlives individual renders and does not exist yet on the first one.
|
|
57
|
+
*/
|
|
58
|
+
sessionCwd: () => string;
|
|
59
|
+
sessionId: string;
|
|
60
|
+
messages: Message[];
|
|
61
|
+
isLoading: boolean;
|
|
62
|
+
permissionMode: PermissionMode;
|
|
63
|
+
latestTotalTokens: number;
|
|
64
|
+
maxInputTokens: number;
|
|
65
|
+
/**
|
|
66
|
+
* The title `/rename` sets, read live off the agent — the page's session list
|
|
67
|
+
* labels the row with it (and the page itself may be the one that renamed it).
|
|
68
|
+
*/
|
|
69
|
+
customTitle: () => string | undefined;
|
|
70
|
+
hasRunningBackgroundWork: boolean;
|
|
71
|
+
hasPendingMessages: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* The session-state family the page re-reads off `getSessionInfo` (spec
|
|
74
|
+
* remote-control「Web 页作为第五种宿主」场景 26) — current values, so the
|
|
75
|
+
* long-lived host answers reads even though the session underneath it is
|
|
76
|
+
* replaced.
|
|
77
|
+
*/
|
|
78
|
+
tasks: Task[];
|
|
79
|
+
/** Background tasks as the TUI holds them — read off `getSessionInfo`. */
|
|
80
|
+
backgroundTasks: BackgroundTask[];
|
|
81
|
+
isCompacting: boolean;
|
|
82
|
+
isCommandRunning: boolean;
|
|
83
|
+
queuedMessages: QueuedMessage[];
|
|
84
|
+
/** Approvals waiting on the user right now. */
|
|
85
|
+
pending: Array<{ requestId: string; context: ToolPermissionContext }>;
|
|
86
|
+
availableTools: () => string[];
|
|
87
|
+
sendMessage: (text: string, force?: boolean) => void | Promise<void>;
|
|
88
|
+
abort: () => void;
|
|
89
|
+
setPermissionMode: (mode: PermissionMode) => void;
|
|
90
|
+
respondPermission: (requestId: string, decision: PermissionDecision) => void;
|
|
91
|
+
/** Rename the conversation (the title `wave -r` lists). */
|
|
92
|
+
renameSession: (title: string) => void | Promise<void>;
|
|
93
|
+
/** Compact the conversation; errors must propagate (the page shows them). */
|
|
94
|
+
compact: (customInstructions?: string) => void | Promise<void>;
|
|
95
|
+
/** Slash commands the page may offer (SDK commands: skills / custom). */
|
|
96
|
+
slashCommands: () => SlashCommand[];
|
|
97
|
+
/** One page of the display thread — read from the transcript when it reaches back. */
|
|
98
|
+
messagePage: (window: MessageWindow) => Promise<MessageWindowResult>;
|
|
99
|
+
/** The whole thread in one shot (no window asked for); nothing is cached. */
|
|
100
|
+
currentThread: () => Promise<Message[]>;
|
|
101
|
+
/** Thread totals for the session list (the resident window cannot answer). */
|
|
102
|
+
threadSummary: () => {
|
|
103
|
+
messageCount: number;
|
|
104
|
+
firstUserText?: string;
|
|
105
|
+
lastActiveAt?: string;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface RemoteControlApi {
|
|
110
|
+
/** The `enableRemoteControl` switch was on when this process started. */
|
|
111
|
+
enabled: boolean;
|
|
112
|
+
status: RemoteControlStatus;
|
|
113
|
+
getSnapshot: () => RemoteControlSnapshot;
|
|
114
|
+
/** Dials out. `name` labels this machine on the phone. */
|
|
115
|
+
connect: (name?: string) => void;
|
|
116
|
+
disconnect: () => void;
|
|
117
|
+
/**
|
|
118
|
+
* Send one notification to the phone under the method name a server host
|
|
119
|
+
* (`AgentBridge`) would use for the same thing. The front end calls it from its
|
|
120
|
+
* content-carrying agent callbacks (see `remoteNotifications`) — that is what
|
|
121
|
+
* makes the two hosts' fan-out identical, and therefore what makes the remote
|
|
122
|
+
* channel's merge window apply to this host too.
|
|
123
|
+
*/
|
|
124
|
+
notify: RemoteNotify;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The GUI dialog polls once a second for the same reason: there is no push. */
|
|
128
|
+
const STATUS_POLL_MS = 1000;
|
|
129
|
+
|
|
130
|
+
export function useRemoteControl(
|
|
131
|
+
options: UseRemoteControlOptions,
|
|
132
|
+
): RemoteControlApi {
|
|
133
|
+
// Every getter below must read the *current* value: the host outlives any
|
|
134
|
+
// single render, and the session underneath it is replaced on /resume.
|
|
135
|
+
const latest = useRef(options);
|
|
136
|
+
latest.current = options;
|
|
137
|
+
|
|
138
|
+
const [sink] = useState<RemoteControlSink | undefined>(() => {
|
|
139
|
+
// The switch is read once, here, exactly as a server process reads it once
|
|
140
|
+
// at startup — hence "restart the CLI" in the failure hint.
|
|
141
|
+
const factory = createRemoteControlSinkFactory(options.workdir);
|
|
142
|
+
if (!factory) return undefined;
|
|
143
|
+
return factory(new RemoteControlCliHost(adapterOf(latest)));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const [status, setStatus] = useState<RemoteControlStatus>(
|
|
147
|
+
() => sink?.getSnapshot() ?? { state: "off" },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// ── Outbound: state changes the phone renders ─────────────────
|
|
151
|
+
//
|
|
152
|
+
// `sendNotification` drops everything while the link is down, which is what
|
|
153
|
+
// we want: a phone that reconnects re-reads the tail anyway.
|
|
154
|
+
const sessionId = options.sessionId;
|
|
155
|
+
const { messages, isLoading, permissionMode, latestTotalTokens } = options;
|
|
156
|
+
|
|
157
|
+
const notify = useCallback<RemoteNotify>(
|
|
158
|
+
(method, params) => {
|
|
159
|
+
sink?.sendNotification(method, params, latest.current.sessionId);
|
|
160
|
+
},
|
|
161
|
+
[sink],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// A structural notice, never a payload: the phone answers it by pulling the
|
|
165
|
+
// tail with `getMessages({ since })`, the same path a cold start takes and the
|
|
166
|
+
// only one that can carry a 13 MB list. What counts as "structurally changed"
|
|
167
|
+
// is `useStructureKey`'s business below.
|
|
168
|
+
const structureKey = useStructureKey(messages, sink !== undefined);
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
sink?.sendNotification(
|
|
171
|
+
"messagesChange",
|
|
172
|
+
remoteWire.messagesChangeSignal(),
|
|
173
|
+
sessionId,
|
|
174
|
+
);
|
|
175
|
+
}, [sink, structureKey, sessionId]);
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
sink?.sendNotification(
|
|
179
|
+
"loadingChange",
|
|
180
|
+
remoteWire.loadingChange(isLoading),
|
|
181
|
+
sessionId,
|
|
182
|
+
);
|
|
183
|
+
}, [sink, isLoading, sessionId]);
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
sink?.sendNotification(
|
|
187
|
+
"permissionModeChange",
|
|
188
|
+
remoteWire.permissionModeChange(permissionMode),
|
|
189
|
+
sessionId,
|
|
190
|
+
);
|
|
191
|
+
}, [sink, permissionMode, sessionId]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!sink || !sessionId) return;
|
|
195
|
+
sink.sendNotification(
|
|
196
|
+
"contextUsage",
|
|
197
|
+
remoteWire.contextUsage(latestTotalTokens, options.maxInputTokens),
|
|
198
|
+
sessionId,
|
|
199
|
+
);
|
|
200
|
+
}, [sink, sessionId, latestTotalTokens, options.maxInputTokens]);
|
|
201
|
+
|
|
202
|
+
// Approvals are announced once, when they appear. The page answers with
|
|
203
|
+
// `permissionResponse`, which the host routes back into the same promise the
|
|
204
|
+
// terminal is waiting on — first answer wins, as the spec requires.
|
|
205
|
+
const announced = useRef<Set<string>>(new Set());
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const pending = latest.current.pending;
|
|
208
|
+
if (!sink || pending.length === 0) return;
|
|
209
|
+
for (const request of pending) {
|
|
210
|
+
if (announced.current.has(request.requestId)) continue;
|
|
211
|
+
announced.current.add(request.requestId);
|
|
212
|
+
sink.sendNotification(
|
|
213
|
+
"permissionRequest",
|
|
214
|
+
remoteWire.permissionRequest(request.requestId, request.context),
|
|
215
|
+
sessionId,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const alive = new Set(pending.map((request) => request.requestId));
|
|
219
|
+
for (const id of [...announced.current]) {
|
|
220
|
+
if (!alive.has(id)) announced.current.delete(id);
|
|
221
|
+
}
|
|
222
|
+
}, [sink, options.pending, sessionId]);
|
|
223
|
+
|
|
224
|
+
// ── Status the command view renders ───────────────────────────
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (!sink) return;
|
|
228
|
+
const timer = setInterval(() => {
|
|
229
|
+
setStatus(sink.getSnapshot());
|
|
230
|
+
}, STATUS_POLL_MS);
|
|
231
|
+
timer.unref?.();
|
|
232
|
+
return () => clearInterval(timer);
|
|
233
|
+
}, [sink]);
|
|
234
|
+
|
|
235
|
+
const connect = useCallback(
|
|
236
|
+
(name?: string) => {
|
|
237
|
+
sink?.connect(name);
|
|
238
|
+
setStatus(sink?.getSnapshot() ?? { state: "off" });
|
|
239
|
+
},
|
|
240
|
+
[sink],
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const disconnect = useCallback(() => {
|
|
244
|
+
sink?.disconnect();
|
|
245
|
+
setStatus(sink?.getSnapshot() ?? { state: "off" });
|
|
246
|
+
}, [sink]);
|
|
247
|
+
|
|
248
|
+
const getSnapshot = useCallback(
|
|
249
|
+
() => sink?.getSnapshot() ?? { state: "off" as const },
|
|
250
|
+
[sink],
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// The process is going away: drop the outbound connection rather than let
|
|
254
|
+
// the relay keep a subscription for a session that no longer exists.
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
return () => {
|
|
257
|
+
sink?.stop();
|
|
258
|
+
};
|
|
259
|
+
}, [sink]);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
enabled: sink !== undefined,
|
|
263
|
+
status,
|
|
264
|
+
getSnapshot,
|
|
265
|
+
connect,
|
|
266
|
+
disconnect,
|
|
267
|
+
notify,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The id composition of the display list, in order — what "the list changed"
|
|
273
|
+
* means for the remote path — as a key that is stable while the composition is.
|
|
274
|
+
*
|
|
275
|
+
* Ids are stable across a streamed message's growth and across in-place edits of
|
|
276
|
+
* its blocks, so comparing this key is what separates a structural change (a
|
|
277
|
+
* message appeared, was discarded, or the list was truncated/replaced) from a
|
|
278
|
+
* delta landing in a message the phone already has. Anchored on the ids rather
|
|
279
|
+
* than on "the messages state changed" so no call site has to be trusted to
|
|
280
|
+
* remember to announce a structural change — watching `messages` instead is what
|
|
281
|
+
* turned every model chunk into a signal plus a phone round trip.
|
|
282
|
+
*
|
|
283
|
+
* This runs once per render, and a streamed reply renders once per chunk, so the
|
|
284
|
+
* two shortcuts are the point: with no sink there is nobody to signal, and an
|
|
285
|
+
* unchanged id list returns the previous key rather than building a new string.
|
|
286
|
+
*
|
|
287
|
+
* The trigger stops at the ids on purpose: `since` is an **exclusive** anchor, so
|
|
288
|
+
* a signal can only ever deliver entries appended after the phone's own last
|
|
289
|
+
* message — a change made *inside* that message needs a payload-carrying
|
|
290
|
+
* notification of its own (see `remoteNotifications`).
|
|
291
|
+
*/
|
|
292
|
+
function useStructureKey(messages: Message[], enabled: boolean): string {
|
|
293
|
+
const last = useRef<{ ids: string[]; key: string }>({ ids: [], key: "" });
|
|
294
|
+
if (!enabled) return "";
|
|
295
|
+
const previous = last.current;
|
|
296
|
+
if (
|
|
297
|
+
previous.ids.length === messages.length &&
|
|
298
|
+
messages.every((message, index) => message.id === previous.ids[index])
|
|
299
|
+
) {
|
|
300
|
+
return previous.key;
|
|
301
|
+
}
|
|
302
|
+
const ids = messages.map((message) => message.id);
|
|
303
|
+
last.current = { ids, key: ids.join("\n") };
|
|
304
|
+
return last.current.key;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The single-session adapter the host is built on. */
|
|
308
|
+
function adapterOf(latest: {
|
|
309
|
+
current: UseRemoteControlOptions;
|
|
310
|
+
}): RemoteControlCliSession {
|
|
311
|
+
return {
|
|
312
|
+
sessionId: () => latest.current.sessionId,
|
|
313
|
+
workdir: () => latest.current.workdir,
|
|
314
|
+
sessionCwd: () => latest.current.sessionCwd(),
|
|
315
|
+
messagePage: (window) => latest.current.messagePage(window),
|
|
316
|
+
currentThread: () => latest.current.currentThread(),
|
|
317
|
+
threadSummary: () => latest.current.threadSummary(),
|
|
318
|
+
contextUsagePercent: () =>
|
|
319
|
+
percentOf(
|
|
320
|
+
latest.current.latestTotalTokens,
|
|
321
|
+
latest.current.maxInputTokens,
|
|
322
|
+
),
|
|
323
|
+
latestTotalTokens: () => latest.current.latestTotalTokens,
|
|
324
|
+
permissionMode: () => latest.current.permissionMode,
|
|
325
|
+
customTitle: () => latest.current.customTitle(),
|
|
326
|
+
availableTools: () => latest.current.availableTools(),
|
|
327
|
+
isLoading: () => latest.current.isLoading,
|
|
328
|
+
hasRunningBackgroundWork: () => latest.current.hasRunningBackgroundWork,
|
|
329
|
+
hasPendingMessages: () => latest.current.hasPendingMessages,
|
|
330
|
+
tasks: () => latest.current.tasks,
|
|
331
|
+
backgroundTasks: () =>
|
|
332
|
+
toBackgroundTaskSummaries(latest.current.backgroundTasks),
|
|
333
|
+
isCompacting: () => latest.current.isCompacting,
|
|
334
|
+
isCommandRunning: () => latest.current.isCommandRunning,
|
|
335
|
+
queuedMessages: () => latest.current.queuedMessages,
|
|
336
|
+
pendingPermissions: () => latest.current.pending,
|
|
337
|
+
sendMessage: (text, force) => latest.current.sendMessage(text, force),
|
|
338
|
+
abort: () => latest.current.abort(),
|
|
339
|
+
setPermissionMode: (mode) => latest.current.setPermissionMode(mode),
|
|
340
|
+
respondPermission: (requestId, decision) =>
|
|
341
|
+
latest.current.respondPermission(requestId, decision),
|
|
342
|
+
renameSession: (title) => latest.current.renameSession(title),
|
|
343
|
+
compact: (customInstructions) => latest.current.compact(customInstructions),
|
|
344
|
+
slashCommands: () => latest.current.slashCommands(),
|
|
345
|
+
};
|
|
346
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -537,6 +537,41 @@ export async function main() {
|
|
|
537
537
|
.demandCommand(1, "Please specify a daemon subcommand");
|
|
538
538
|
},
|
|
539
539
|
)
|
|
540
|
+
.command(
|
|
541
|
+
"remote-control [action] [name]",
|
|
542
|
+
"Serve this machine's sessions to a paired phone (via the wave daemon)",
|
|
543
|
+
(yargs) => {
|
|
544
|
+
return yargs
|
|
545
|
+
.positional("action", {
|
|
546
|
+
describe: "on | off | status",
|
|
547
|
+
type: "string",
|
|
548
|
+
default: "status",
|
|
549
|
+
})
|
|
550
|
+
.positional("name", {
|
|
551
|
+
describe: "Label for this machine, shown on the phone",
|
|
552
|
+
type: "string",
|
|
553
|
+
});
|
|
554
|
+
},
|
|
555
|
+
async (argv) => {
|
|
556
|
+
const {
|
|
557
|
+
remoteControlOnCommand,
|
|
558
|
+
remoteControlOffCommand,
|
|
559
|
+
remoteControlStatusCommand,
|
|
560
|
+
} = await import("./remote/remoteControlCommands.js");
|
|
561
|
+
const action = (argv.action as string | undefined) ?? "status";
|
|
562
|
+
if (action === "on") {
|
|
563
|
+
await remoteControlOnCommand(argv.name as string | undefined);
|
|
564
|
+
} else if (action === "off") {
|
|
565
|
+
await remoteControlOffCommand();
|
|
566
|
+
} else if (action === "status") {
|
|
567
|
+
await remoteControlStatusCommand();
|
|
568
|
+
} else {
|
|
569
|
+
console.error(`Unknown remote-control action: ${action}`);
|
|
570
|
+
console.error("Usage: wave remote-control [on|off|status] [name]");
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
)
|
|
540
575
|
.command(
|
|
541
576
|
"update",
|
|
542
577
|
"Update WAVE Code to the latest version",
|
|
@@ -405,6 +405,8 @@ export const handleCommandSelect = (
|
|
|
405
405
|
await callbacks.onAddDir?.();
|
|
406
406
|
} else if (command === "rename") {
|
|
407
407
|
await callbacks.onRenameSession?.();
|
|
408
|
+
} else if (command === "remote-control") {
|
|
409
|
+
dispatch({ type: "SET_SHOW_REMOTE_CONTROL", payload: true });
|
|
408
410
|
}
|
|
409
411
|
}
|
|
410
412
|
})();
|
|
@@ -761,6 +763,7 @@ export const handleInput = async (
|
|
|
761
763
|
state.showResumeSelector ||
|
|
762
764
|
state.showHelp ||
|
|
763
765
|
state.showStatusCommand ||
|
|
766
|
+
state.showRemoteControl ||
|
|
764
767
|
state.showPluginManager ||
|
|
765
768
|
state.showModelSelector ||
|
|
766
769
|
state.showWorkflowManager
|
|
@@ -786,6 +789,7 @@ export const handleInput = async (
|
|
|
786
789
|
state.showResumeSelector ||
|
|
787
790
|
state.showHelp ||
|
|
788
791
|
state.showStatusCommand ||
|
|
792
|
+
state.showRemoteControl ||
|
|
789
793
|
state.showPluginManager ||
|
|
790
794
|
state.showModelSelector ||
|
|
791
795
|
state.showWorkflowManager
|
|
@@ -797,6 +801,7 @@ export const handleInput = async (
|
|
|
797
801
|
state.showResumeSelector ||
|
|
798
802
|
state.showHelp ||
|
|
799
803
|
state.showStatusCommand ||
|
|
804
|
+
state.showRemoteControl ||
|
|
800
805
|
state.showPluginManager ||
|
|
801
806
|
state.showModelSelector ||
|
|
802
807
|
state.showWorkflowManager
|
|
@@ -159,6 +159,7 @@ export interface InputState {
|
|
|
159
159
|
showResumeSelector: boolean;
|
|
160
160
|
showHelp: boolean;
|
|
161
161
|
showStatusCommand: boolean;
|
|
162
|
+
showRemoteControl: boolean;
|
|
162
163
|
showLoginCommand: boolean;
|
|
163
164
|
showPluginManager: boolean;
|
|
164
165
|
showModelSelector: boolean;
|
|
@@ -200,6 +201,7 @@ export const initialState: InputState = {
|
|
|
200
201
|
showResumeSelector: false,
|
|
201
202
|
showHelp: false,
|
|
202
203
|
showStatusCommand: false,
|
|
204
|
+
showRemoteControl: false,
|
|
203
205
|
showLoginCommand: false,
|
|
204
206
|
showPluginManager: false,
|
|
205
207
|
showModelSelector: false,
|
|
@@ -338,6 +340,24 @@ function deleteTokenBefore(
|
|
|
338
340
|
* when there is nothing to submit (empty text).
|
|
339
341
|
*/
|
|
340
342
|
function submitInput(state: InputState): InputState | null {
|
|
343
|
+
const submitted = buildSubmitState(state);
|
|
344
|
+
if (!submitted) return null;
|
|
345
|
+
// A submit consumes the text, so the popups anchored to it have to go too.
|
|
346
|
+
// Enter reaches here instead of `SELECT_COMMAND` — which is what used to
|
|
347
|
+
// close the selector — so submitting `/workflows` left the popup painted
|
|
348
|
+
// above the panel the command had just opened.
|
|
349
|
+
return {
|
|
350
|
+
...submitted,
|
|
351
|
+
showCommandSelector: false,
|
|
352
|
+
slashPosition: -1,
|
|
353
|
+
commandSearchQuery: "",
|
|
354
|
+
showFileSelector: false,
|
|
355
|
+
atPosition: -1,
|
|
356
|
+
fileSearchQuery: "",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function buildSubmitState(state: InputState): InputState | null {
|
|
341
361
|
if (!state.inputText.trim()) {
|
|
342
362
|
return null;
|
|
343
363
|
}
|
|
@@ -484,6 +504,7 @@ export type InputAction =
|
|
|
484
504
|
| { type: "SET_SHOW_RESUME_SELECTOR"; payload: boolean }
|
|
485
505
|
| { type: "SET_SHOW_HELP"; payload: boolean }
|
|
486
506
|
| { type: "SET_SHOW_STATUS_COMMAND"; payload: boolean }
|
|
507
|
+
| { type: "SET_SHOW_REMOTE_CONTROL"; payload: boolean }
|
|
487
508
|
| { type: "SET_SHOW_LOGIN_COMMAND"; payload: boolean }
|
|
488
509
|
| { type: "SET_SHOW_PLUGIN_MANAGER"; payload: boolean }
|
|
489
510
|
| { type: "SET_SHOW_MODEL_SELECTOR"; payload: boolean }
|
|
@@ -702,6 +723,12 @@ export function inputReducer(
|
|
|
702
723
|
showStatusCommand: action.payload,
|
|
703
724
|
selectorJustUsed: !action.payload ? true : state.selectorJustUsed,
|
|
704
725
|
};
|
|
726
|
+
case "SET_SHOW_REMOTE_CONTROL":
|
|
727
|
+
return {
|
|
728
|
+
...state,
|
|
729
|
+
showRemoteControl: action.payload,
|
|
730
|
+
selectorJustUsed: !action.payload ? true : state.selectorJustUsed,
|
|
731
|
+
};
|
|
705
732
|
case "SET_SHOW_LOGIN_COMMAND":
|
|
706
733
|
return {
|
|
707
734
|
...state,
|
|
@@ -752,6 +779,17 @@ export function inputReducer(
|
|
|
752
779
|
inputText: "",
|
|
753
780
|
cursorPosition: 0,
|
|
754
781
|
historyIndex: -1,
|
|
782
|
+
// A cleared input leaves no `/` or `@` for a popup to anchor to, but
|
|
783
|
+
// this action used to reset only the text. Submitting `/workflows` goes
|
|
784
|
+
// through `handleSubmit` rather than `SELECT_COMMAND`, so the selector
|
|
785
|
+
// stayed up and painted a stale "Command Selector (filtering: …)" above
|
|
786
|
+
// the panel the command had just opened.
|
|
787
|
+
showCommandSelector: false,
|
|
788
|
+
slashPosition: -1,
|
|
789
|
+
commandSearchQuery: "",
|
|
790
|
+
showFileSelector: false,
|
|
791
|
+
atPosition: -1,
|
|
792
|
+
fileSearchQuery: "",
|
|
755
793
|
};
|
|
756
794
|
case "ADD_IMAGE_AND_INSERT_PLACEHOLDER": {
|
|
757
795
|
const newImage: AttachedImage = {
|
|
@@ -1102,6 +1140,7 @@ export function inputReducer(
|
|
|
1102
1140
|
state.showResumeSelector ||
|
|
1103
1141
|
state.showHelp ||
|
|
1104
1142
|
state.showStatusCommand ||
|
|
1143
|
+
state.showRemoteControl ||
|
|
1105
1144
|
state.showLoginCommand ||
|
|
1106
1145
|
state.showPluginManager ||
|
|
1107
1146
|
state.showModelSelector ||
|
package/src/print-cli.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
hasNewCommits,
|
|
6
6
|
getDefaultRemoteBranch,
|
|
7
7
|
validateWorktreeRemovalPath,
|
|
8
|
+
setTelemetryStdoutProtected,
|
|
8
9
|
type WorktreeSession,
|
|
9
10
|
} from "wave-agent-sdk";
|
|
10
11
|
import { displayUsageSummary } from "./utils/usageSummary.js";
|
|
@@ -35,6 +36,9 @@ function displayTimingInfo(startTime: Date, showStats: boolean): void {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
39
|
+
// stdout carries the printed result here; console telemetry exporters would
|
|
40
|
+
// mix structured telemetry into it — strip them at config resolution.
|
|
41
|
+
setTelemetryStdoutProtected(true);
|
|
38
42
|
const startTime = new Date();
|
|
39
43
|
const {
|
|
40
44
|
restoreSessionId,
|
|
@@ -135,6 +139,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
135
139
|
isNewWorktree: worktreeSession?.isNew,
|
|
136
140
|
model,
|
|
137
141
|
mcpServers,
|
|
142
|
+
entrypoint: "cli",
|
|
138
143
|
// 保持流式模式以获得更好的命令行用户体验
|
|
139
144
|
});
|
|
140
145
|
|
|
@@ -29,8 +29,27 @@ export function workflowManagerReducer(
|
|
|
29
29
|
action: WorkflowManagerAction,
|
|
30
30
|
): WorkflowManagerState {
|
|
31
31
|
switch (action.type) {
|
|
32
|
-
case "SET_RUNS":
|
|
33
|
-
|
|
32
|
+
case "SET_RUNS": {
|
|
33
|
+
// Newest first (spec multi-agent/workflow「工作流管理对话框(IDE 与 CLI)」场景
|
|
34
|
+
// 1): the run just started is the one to look at. The host hands them over
|
|
35
|
+
// in its own map order (creation order, plus whatever a session restore
|
|
36
|
+
// appended), which puts it at the bottom.
|
|
37
|
+
const runs = [...action.runs].sort((a, b) => b.startTime - a.startTime);
|
|
38
|
+
// The sort moves rows when a run appears at the top, so an index-stable
|
|
39
|
+
// selection would silently follow the position instead of the run.
|
|
40
|
+
const previous = state.runs[state.selectedIndex];
|
|
41
|
+
const keptIndex = previous
|
|
42
|
+
? runs.findIndex((r) => r.runId === previous.runId)
|
|
43
|
+
: -1;
|
|
44
|
+
return {
|
|
45
|
+
...state,
|
|
46
|
+
runs,
|
|
47
|
+
selectedIndex:
|
|
48
|
+
keptIndex >= 0
|
|
49
|
+
? keptIndex
|
|
50
|
+
: Math.min(state.selectedIndex, Math.max(0, runs.length - 1)),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
34
53
|
case "SELECT_INDEX":
|
|
35
54
|
return { ...state, selectedIndex: action.index };
|
|
36
55
|
case "MOVE_UP":
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-frame budget for the frames this machine hands to the relay.
|
|
3
|
+
*
|
|
4
|
+
* Two relay-side limits stand behind every frame, and neither is ours to raise:
|
|
5
|
+
* `maxPayload` (4 MiB by default) makes the relay close the connection with
|
|
6
|
+
* `1009`, and — lower, and far easier to hit — the per-subscriber buffer
|
|
7
|
+
* (1 MiB by default) makes it **drop that one frame** and ask for a resync.
|
|
8
|
+
* The second failure is silent: no error, just a page the phone never sees, and
|
|
9
|
+
* a phone that answers the resync by asking for the same page again.
|
|
10
|
+
*
|
|
11
|
+
* Hence the budget lives here, at half of that buffer, leaving room for the
|
|
12
|
+
* frames already in flight. It is deliberately **not** derived from the relay's
|
|
13
|
+
* number at runtime: the promise "nothing we send exceeds what the relay will
|
|
14
|
+
* forward" has to hold without knowing which relay build is on the other side.
|
|
15
|
+
*/
|
|
16
|
+
export const REMOTE_MAX_FRAME_BYTES = 512 * 1024;
|
|
17
|
+
|
|
18
|
+
/** Serialized size of a frame, exactly as it goes on the wire. */
|
|
19
|
+
export function frameBytes(frame: unknown): number {
|
|
20
|
+
const json = JSON.stringify(frame);
|
|
21
|
+
return json === undefined ? 0 : Buffer.byteLength(json, "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How many trailing items of a list still fit under the budget.
|
|
26
|
+
*
|
|
27
|
+
* The candidate list is always a page of messages, so the newest ones are the
|
|
28
|
+
* ones worth keeping: a phone that asked for the tail is better served by the
|
|
29
|
+
* last 80 messages than by the first 80. `fits` must be monotonic in the count
|
|
30
|
+
* — it is, for a suffix of a JSON array — so this is a binary search rather
|
|
31
|
+
* than one `stringify` per candidate length.
|
|
32
|
+
*/
|
|
33
|
+
export function trailingCountThatFits(
|
|
34
|
+
total: number,
|
|
35
|
+
fits: (count: number) => boolean,
|
|
36
|
+
): number {
|
|
37
|
+
let low = 0;
|
|
38
|
+
let high = total;
|
|
39
|
+
while (low < high) {
|
|
40
|
+
const mid = Math.ceil((low + high) / 2);
|
|
41
|
+
if (fits(mid)) low = mid;
|
|
42
|
+
else high = mid - 1;
|
|
43
|
+
}
|
|
44
|
+
return low;
|
|
45
|
+
}
|