wave-code 1.5.0 → 1.6.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/bin/wave-code.js +2 -0
- package/dist/bundle/wave.mjs +777 -855
- package/package.json +7 -7
- package/src/cli.tsx +9 -3
- package/src/components/FileSelector.tsx +11 -8
- package/src/components/HelpView.tsx +1 -1
- package/src/components/InputBox.tsx +3 -5
- package/src/components/McpManager.tsx +26 -0
- package/src/components/MessageBlockItem.tsx +14 -2
- package/src/components/RemoteControlView.tsx +1 -1
- package/src/components/RewindCommand.tsx +40 -46
- package/src/contexts/useChat.tsx +61 -49
- package/src/daemon/commands.ts +266 -44
- package/src/daemon/daemonLog.ts +132 -0
- package/src/daemon/socketPath.ts +123 -0
- package/src/daemon-cli.ts +19 -6
- package/src/hooks/useInputManager.ts +27 -25
- package/src/index.ts +36 -4
- package/src/managers/inputHandlers.ts +3 -13
- package/src/managers/inputReducer.ts +44 -13
- package/src/print-cli.ts +199 -49
- package/src/remote/hostIdentity.ts +1 -0
- package/src/remote/imagePreview.ts +182 -0
- package/src/remote/messageWindow.ts +15 -5
- package/src/remote/notificationCoalescer.ts +16 -3
- package/src/remote/relayClient.ts +1 -1
- package/src/remote/remoteControlCliHost.ts +26 -4
- package/src/remote/remoteControlService.ts +70 -19
- package/src/remote/remoteNotifications.ts +5 -4
- package/src/remote/textPreview.ts +155 -0
- package/src/stdio/agentBridge.ts +450 -117
- package/src/stdio/daemonServer.ts +49 -33
- package/src/stdio/hostSlashCommands.ts +9 -4
- package/src/stdio/uiPayload.ts +50 -2
- package/src/utils/agentSelector.ts +29 -0
- package/src/utils/argvCompat.ts +23 -0
- package/src/utils/printOutput.ts +403 -0
- package/src/utils/usageSummary.ts +74 -24
- package/src/utils/worktree.ts +222 -16
- package/src/utils/rewindCheckpoints.ts +0 -14
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon socket addressing — one default, two platform shapes.
|
|
3
|
+
*
|
|
4
|
+
* POSIX keeps the file-socket it has always used (`~/.wave/daemon.sock`). On
|
|
5
|
+
* Windows libuv only accepts the `\\.\pipe\` shape: handing `net.Server.listen`
|
|
6
|
+
* a filesystem path fails with `EACCES` and the `.sock` file is never created,
|
|
7
|
+
* so the daemon could not start at all there. The default is therefore mapped to
|
|
8
|
+
* a named pipe on Windows.
|
|
9
|
+
*
|
|
10
|
+
* Two constraints shape the mapping:
|
|
11
|
+
* - the pipe namespace is **machine-global** (every OS user shares `\\.\pipe\`),
|
|
12
|
+
* so the name must carry a per-user component — otherwise one user's daemon
|
|
13
|
+
* occupies another's default socket;
|
|
14
|
+
* - a home path cannot be embedded verbatim, because `\` and `:` are illegal in
|
|
15
|
+
* pipe names. Hence a stable digest of the default path, not the path itself.
|
|
16
|
+
*
|
|
17
|
+
* The mapping applies to **the default socket only**, and it is applied on both
|
|
18
|
+
* sides (client and `--daemon` server) by this one function. A name that is
|
|
19
|
+
* already a pipe, or any other path, is used verbatim: rewriting what the caller
|
|
20
|
+
* named would silently point the daemon somewhere else.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
/** The default daemon socket as a file path — the shape POSIX listens on. */
|
|
28
|
+
export const DEFAULT_DAEMON_SOCKET_FILE = path.join(
|
|
29
|
+
os.homedir(),
|
|
30
|
+
".wave",
|
|
31
|
+
"daemon.sock",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** libuv's Windows named-pipe prefixes (`\\.\pipe\` is the documented form). */
|
|
35
|
+
const WINDOWS_PIPE_PREFIXES = ["\\\\.\\pipe\\", "\\\\?\\pipe\\"] as const;
|
|
36
|
+
|
|
37
|
+
/** True when `socketPath` is already a Windows named-pipe name. */
|
|
38
|
+
export function isWindowsPipeName(socketPath: string): boolean {
|
|
39
|
+
return WINDOWS_PIPE_PREFIXES.some((prefix) => socketPath.startsWith(prefix));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Derive the pipe name for a file-path socket. The digest is stable per path, so
|
|
44
|
+
* the same user always lands on the same name (a re-launched daemon finds and
|
|
45
|
+
* refuses to double-bind it), while different home directories — different OS
|
|
46
|
+
* users on one machine — land on different names.
|
|
47
|
+
*/
|
|
48
|
+
export function windowsPipeNameFor(socketFilePath: string): string {
|
|
49
|
+
const digest = createHash("sha256")
|
|
50
|
+
.update(socketFilePath)
|
|
51
|
+
.digest("hex")
|
|
52
|
+
.slice(0, 32);
|
|
53
|
+
return `\\\\.\\pipe\\wave-daemon-${digest}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Same file, allowing for how it was spelled — separator style, `.`/`..`
|
|
58
|
+
* segments, and (on Windows) letter case. A user who writes the default socket
|
|
59
|
+
* out by hand rarely matches `path.join`'s spelling byte for byte
|
|
60
|
+
* (`C:/Users/me/.wave/daemon.sock` vs `C:\Users\me\.wave\daemon.sock`), and
|
|
61
|
+
* missing that match would land the server on an `EACCES` instead of the pipe the
|
|
62
|
+
* client derives.
|
|
63
|
+
*/
|
|
64
|
+
function isSameFilePath(
|
|
65
|
+
a: string,
|
|
66
|
+
b: string,
|
|
67
|
+
platform: NodeJS.Platform,
|
|
68
|
+
): boolean {
|
|
69
|
+
if (a === b) return true;
|
|
70
|
+
const resolvedA = path.resolve(a);
|
|
71
|
+
const resolvedB = path.resolve(b);
|
|
72
|
+
return platform === "win32"
|
|
73
|
+
? resolvedA.toLowerCase() === resolvedB.toLowerCase()
|
|
74
|
+
: resolvedA === resolvedB;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the socket a caller means: identity on POSIX, and on Windows the pipe
|
|
79
|
+
* form of the default socket (anything else — an explicit path or a name that is
|
|
80
|
+
* already a pipe — is returned unchanged).
|
|
81
|
+
*/
|
|
82
|
+
export function resolveDaemonSocket(
|
|
83
|
+
platform: NodeJS.Platform,
|
|
84
|
+
socketPath: string,
|
|
85
|
+
): string {
|
|
86
|
+
if (platform !== "win32") return socketPath;
|
|
87
|
+
if (isWindowsPipeName(socketPath)) return socketPath;
|
|
88
|
+
return isSameFilePath(socketPath, DEFAULT_DAEMON_SOCKET_FILE, platform)
|
|
89
|
+
? // Digest the canonical spelling so every way of writing the default socket
|
|
90
|
+
// derives the same name — the pipe is a shared address, not a per-caller one.
|
|
91
|
+
windowsPipeNameFor(path.resolve(socketPath))
|
|
92
|
+
: socketPath;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The fixed default socket every `wave daemon` subcommand talks to (spec:
|
|
97
|
+
* 默认 socket 固定,形态随平台). No `--socket` override exists, so this is the
|
|
98
|
+
* only address the client side ever uses.
|
|
99
|
+
*/
|
|
100
|
+
export const DEFAULT_DAEMON_SOCKET = resolveDaemonSocket(
|
|
101
|
+
process.platform,
|
|
102
|
+
DEFAULT_DAEMON_SOCKET_FILE,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Translate a `listen` failure on a daemon address. `EADDRINUSE` is the shape a
|
|
107
|
+
* second daemon takes when the first one already holds the name — on Windows that
|
|
108
|
+
* is the *only* shape, since a pipe name has no stale file to probe first (the
|
|
109
|
+
* POSIX path detects the same situation by connecting to the socket file). The
|
|
110
|
+
* raw errno tells a user nothing actionable, so it becomes the same sentence both
|
|
111
|
+
* platforms report.
|
|
112
|
+
*/
|
|
113
|
+
export function describeListenError(
|
|
114
|
+
socketPath: string,
|
|
115
|
+
err: NodeJS.ErrnoException,
|
|
116
|
+
): Error {
|
|
117
|
+
if (err.code === "EADDRINUSE") {
|
|
118
|
+
return new Error(
|
|
119
|
+
`Another wave daemon is already listening on ${socketPath}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return err;
|
|
123
|
+
}
|
package/src/daemon-cli.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* daemon-cli.ts — Entry point for `wave --daemon <socket-path>` mode.
|
|
3
3
|
*
|
|
4
|
-
* Starts a DaemonServer that serves JSON-RPC over a
|
|
5
|
-
*
|
|
6
|
-
* back with `ssh -L`;
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Starts a DaemonServer that serves JSON-RPC over a local socket (a unix domain
|
|
5
|
+
* socket on POSIX, a named pipe on Windows). The desktop app launches this on a
|
|
6
|
+
* remote host via nohup/setsid and tunnels the socket back with `ssh -L`;
|
|
7
|
+
* multiple attach/detach cycles share one process, so sessions and pending
|
|
8
|
+
* permissions survive client disconnects. The net server keeps the process
|
|
9
|
+
* alive — there is no stdin to wait on.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { ensureRuntimeDeps } from "wave-agent-sdk";
|
|
12
13
|
import { DaemonServer } from "./stdio/daemonServer.js";
|
|
13
14
|
import { createRemoteControlSinkFactory } from "./remote/remoteControlSink.js";
|
|
15
|
+
import { resolveDaemonSocket } from "./daemon/socketPath.js";
|
|
14
16
|
|
|
15
17
|
export async function startDaemonCli(socketPath: string): Promise<void> {
|
|
16
18
|
// Install the on-demand runtime dependencies (image codec + ripgrep) before
|
|
@@ -19,10 +21,21 @@ export async function startDaemonCli(socketPath: string): Promise<void> {
|
|
|
19
21
|
// since the host reports "daemon ready" on this socket.
|
|
20
22
|
await ensureRuntimeDeps();
|
|
21
23
|
|
|
24
|
+
// Resolve the address exactly like the client does. The default path is the
|
|
25
|
+
// one name both sides must agree on, and on Windows it is reachable only in
|
|
26
|
+
// its pipe form — a caller that spells it out (`wave --daemon
|
|
27
|
+
// ~/.wave/daemon.sock`) must land on the same pipe the client derives, not on
|
|
28
|
+
// an EACCES. An explicit pipe name, or any other path, is used verbatim so a
|
|
29
|
+
// wrong address still fails loudly instead of being silently renamed.
|
|
30
|
+
const resolvedSocketPath = resolveDaemonSocket(process.platform, socketPath);
|
|
31
|
+
|
|
22
32
|
// The resident daemon owns the remote sessions, so it is the process that
|
|
23
33
|
// serves a paired phone. Off (undefined) unless the switch says otherwise.
|
|
24
34
|
const createRemoteSink = createRemoteControlSinkFactory(process.cwd());
|
|
25
|
-
const server = new DaemonServer({
|
|
35
|
+
const server = new DaemonServer({
|
|
36
|
+
socketPath: resolvedSocketPath,
|
|
37
|
+
createRemoteSink,
|
|
38
|
+
});
|
|
26
39
|
await server.start();
|
|
27
40
|
// Ready — any error that follows goes to the daemon log via stderr.
|
|
28
41
|
}
|
|
@@ -4,9 +4,11 @@ import {
|
|
|
4
4
|
inputReducer,
|
|
5
5
|
initialState,
|
|
6
6
|
InputManagerCallbacks,
|
|
7
|
+
FileSelectorItem,
|
|
7
8
|
ESC_DOUBLE_PRESS_TIMEOUT_MS,
|
|
8
9
|
btwOverlayActiveRef,
|
|
9
10
|
} from "../managers/inputReducer.js";
|
|
11
|
+
import { toAgentSelectorItems } from "../utils/agentSelector.js";
|
|
10
12
|
import {
|
|
11
13
|
searchFiles as searchFilesUtil,
|
|
12
14
|
PermissionMode,
|
|
@@ -49,10 +51,10 @@ export const useInputManager = (
|
|
|
49
51
|
onAskBtw,
|
|
50
52
|
sessionId,
|
|
51
53
|
workdir,
|
|
52
|
-
getFullMessageThread,
|
|
53
54
|
logger,
|
|
54
55
|
hasQueuedMessages: hasQueuedMessagesProp,
|
|
55
56
|
onRecallQueuedMessage,
|
|
57
|
+
subagentConfigurations,
|
|
56
58
|
onClearMessages,
|
|
57
59
|
onCompact,
|
|
58
60
|
onReloadPlugins,
|
|
@@ -62,7 +64,8 @@ export const useInputManager = (
|
|
|
62
64
|
isIdle: isIdleProp,
|
|
63
65
|
} = callbacks;
|
|
64
66
|
|
|
65
|
-
// Handle debounced file search
|
|
67
|
+
// Handle debounced file search. 子代理条目与文件结果一起下发(子代理是内存里
|
|
68
|
+
// 的稳定集合,不参与防抖;文件搜索失败也只是没有文件,子代理照旧可选)。
|
|
66
69
|
useEffect(() => {
|
|
67
70
|
if (state.showFileSelector) {
|
|
68
71
|
const debounceDelay =
|
|
@@ -70,17 +73,26 @@ export const useInputManager = (
|
|
|
70
73
|
? 0
|
|
71
74
|
: parseInt(process.env.FILE_SELECTOR_DEBOUNCE_MS || "300", 10);
|
|
72
75
|
const timer = setTimeout(async () => {
|
|
76
|
+
let fileItems: FileSelectorItem[] = [];
|
|
73
77
|
try {
|
|
74
|
-
|
|
75
|
-
dispatch({ type: "SET_FILTERED_FILES", payload: fileItems });
|
|
78
|
+
fileItems = await searchFilesUtil(state.fileSearchQuery);
|
|
76
79
|
} catch (error) {
|
|
77
80
|
console.error("File search error:", error);
|
|
78
|
-
dispatch({ type: "SET_FILTERED_FILES", payload: [] });
|
|
79
81
|
}
|
|
82
|
+
dispatch({
|
|
83
|
+
type: "SET_FILTERED_FILES",
|
|
84
|
+
payload: [
|
|
85
|
+
...toAgentSelectorItems(
|
|
86
|
+
subagentConfigurations,
|
|
87
|
+
state.fileSearchQuery,
|
|
88
|
+
),
|
|
89
|
+
...fileItems,
|
|
90
|
+
],
|
|
91
|
+
});
|
|
80
92
|
}, debounceDelay);
|
|
81
93
|
return () => clearTimeout(timer);
|
|
82
94
|
}
|
|
83
|
-
}, [state.showFileSelector, state.fileSearchQuery]);
|
|
95
|
+
}, [state.showFileSelector, state.fileSearchQuery, subagentConfigurations]);
|
|
84
96
|
|
|
85
97
|
// Auto-expire the double-Esc clear pending flag after the timeout window
|
|
86
98
|
// (aligned with Claude Code's useDoublePress timeout-based expiry).
|
|
@@ -150,15 +162,16 @@ export const useInputManager = (
|
|
|
150
162
|
const answer = await onAskBtw?.(
|
|
151
163
|
effect.question,
|
|
152
164
|
controller.signal,
|
|
153
|
-
(
|
|
165
|
+
(delta: string) => {
|
|
154
166
|
// Stream partial answers into the overlay so the user sees
|
|
155
167
|
// the response grow in real time (same visual language as
|
|
156
168
|
// assistant text / thinking blocks) instead of a static
|
|
157
|
-
// "Answering" indicator.
|
|
169
|
+
// "Answering" indicator. The callback hands over a delta, so
|
|
170
|
+
// this appends rather than replacing.
|
|
158
171
|
if (!btwDismissedRef.current) {
|
|
159
172
|
dispatch({
|
|
160
|
-
type: "
|
|
161
|
-
payload:
|
|
173
|
+
type: "APPEND_BTW_ANSWER",
|
|
174
|
+
payload: delta,
|
|
162
175
|
});
|
|
163
176
|
}
|
|
164
177
|
},
|
|
@@ -193,18 +206,8 @@ export const useInputManager = (
|
|
|
193
206
|
onPermissionModeChange?.(effect.mode);
|
|
194
207
|
break;
|
|
195
208
|
case "FETCH_HISTORY": {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
: undefined;
|
|
199
|
-
|
|
200
|
-
if (getFullMessageThread) {
|
|
201
|
-
try {
|
|
202
|
-
const thread = await getFullMessageThread();
|
|
203
|
-
sessionIds = thread.sessionIds;
|
|
204
|
-
} catch (error) {
|
|
205
|
-
logger?.error("Failed to fetch ancestor session IDs", error);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
209
|
+
// 同 inputHandlers 里那条:只按当前会话取,不为两个字段读整份转录。
|
|
210
|
+
const sessionIds = sessionId ? [sessionId] : undefined;
|
|
208
211
|
|
|
209
212
|
const history = await PromptHistoryManager.getHistory({
|
|
210
213
|
sessionId: sessionIds,
|
|
@@ -310,7 +313,6 @@ export const useInputManager = (
|
|
|
310
313
|
onPermissionModeChange,
|
|
311
314
|
sessionId,
|
|
312
315
|
workdir,
|
|
313
|
-
getFullMessageThread,
|
|
314
316
|
logger,
|
|
315
317
|
onHasSlashCommand,
|
|
316
318
|
onRecallQueuedMessage,
|
|
@@ -430,8 +432,8 @@ export const useInputManager = (
|
|
|
430
432
|
dispatch({ type: "ACTIVATE_FILE_SELECTOR", payload: position });
|
|
431
433
|
}, []);
|
|
432
434
|
|
|
433
|
-
const handleFileSelect = useCallback((
|
|
434
|
-
dispatch({ type: "SELECT_FILE", payload:
|
|
435
|
+
const handleFileSelect = useCallback((item: FileSelectorItem) => {
|
|
436
|
+
dispatch({ type: "SELECT_FILE", payload: item });
|
|
435
437
|
}, []);
|
|
436
438
|
|
|
437
439
|
const handleCancelFileSelect = useCallback(() => {
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,12 @@ import { createWorktree, type WorktreeSession } from "./utils/worktree.js";
|
|
|
11
11
|
import path from "path";
|
|
12
12
|
import { pathToFileURL } from "url";
|
|
13
13
|
import { readNearestPackageJson } from "./utils/readPackageJson.js";
|
|
14
|
+
import { ensurePlainNodeArgv } from "./utils/argvCompat.js";
|
|
15
|
+
|
|
16
|
+
// Must run before `main()` builds its yargs instance (see argvCompat.ts):
|
|
17
|
+
// when a host runs this bundle directly under Electron RUN_AS_NODE, yargs would
|
|
18
|
+
// otherwise treat the script path as an unknown positional and exit 1.
|
|
19
|
+
ensurePlainNodeArgv();
|
|
14
20
|
|
|
15
21
|
const version = readNearestPackageJson().version;
|
|
16
22
|
|
|
@@ -61,6 +67,14 @@ export async function main() {
|
|
|
61
67
|
type: "boolean",
|
|
62
68
|
global: false,
|
|
63
69
|
})
|
|
70
|
+
.option("output-format", {
|
|
71
|
+
description:
|
|
72
|
+
'Print-mode output format: "text" (default), "json" (a single result object), or "stream-json" (one JSON event per line)',
|
|
73
|
+
choices: ["text", "json", "stream-json"] as const,
|
|
74
|
+
default: "text" as const,
|
|
75
|
+
type: "string",
|
|
76
|
+
global: false,
|
|
77
|
+
})
|
|
64
78
|
.option("dangerously-skip-permissions", {
|
|
65
79
|
description: "Skip all permission checks (dangerous)",
|
|
66
80
|
type: "boolean",
|
|
@@ -726,17 +740,34 @@ export async function main() {
|
|
|
726
740
|
});
|
|
727
741
|
}
|
|
728
742
|
|
|
743
|
+
// --output-format only means anything for a print-mode run: anywhere else
|
|
744
|
+
// stdout is the Ink UI, so a JSON request there would silently do nothing.
|
|
745
|
+
// (`text` is the default, so an explicit `text` is not an error — and
|
|
746
|
+
// subcommands may not carry the flag at all.)
|
|
747
|
+
if (
|
|
748
|
+
argv.print === undefined &&
|
|
749
|
+
argv.outputFormat !== undefined &&
|
|
750
|
+
argv.outputFormat !== "text"
|
|
751
|
+
) {
|
|
752
|
+
console.error(
|
|
753
|
+
`--output-format ${argv.outputFormat} requires print mode (-p).`,
|
|
754
|
+
);
|
|
755
|
+
process.exit(1);
|
|
756
|
+
}
|
|
757
|
+
|
|
729
758
|
// Validate an explicitly-specified restore ID before entering the CLI —
|
|
730
|
-
//
|
|
759
|
+
// sessionExistsAnywhere scans every project dir as a fallback, so a miss
|
|
731
760
|
// here means the session truly does not exist. Fail fast with a clear
|
|
732
|
-
// error instead of silently starting a fresh session.
|
|
761
|
+
// error instead of silently starting a fresh session. It is a yes/no
|
|
762
|
+
// question, so it does not read the transcript: the CLI loads the session
|
|
763
|
+
// for real a moment later.
|
|
733
764
|
if (
|
|
734
765
|
typeof argv.restore === "string" &&
|
|
735
766
|
argv.restore !== "" &&
|
|
736
767
|
!argv.print
|
|
737
768
|
) {
|
|
738
|
-
const {
|
|
739
|
-
const exists = await
|
|
769
|
+
const { sessionExistsAnywhere } = await import("wave-agent-sdk");
|
|
770
|
+
const exists = await sessionExistsAnywhere(argv.restore, workdir);
|
|
740
771
|
if (!exists) {
|
|
741
772
|
console.error(`Session ${argv.restore} not found on disk.`);
|
|
742
773
|
process.exit(1);
|
|
@@ -751,6 +782,7 @@ export async function main() {
|
|
|
751
782
|
continueLastSession: argv.continue as boolean | undefined,
|
|
752
783
|
message: argv.print,
|
|
753
784
|
showStats: argv.showStats as boolean | undefined,
|
|
785
|
+
outputFormat: argv.outputFormat as "text" | "json" | "stream-json",
|
|
754
786
|
bypassPermissions: argv.dangerouslySkipPermissions as
|
|
755
787
|
| boolean
|
|
756
788
|
| undefined,
|
|
@@ -582,22 +582,12 @@ export const handleNormalInput = async (
|
|
|
582
582
|
|
|
583
583
|
if (key.upArrow) {
|
|
584
584
|
if (state.history.length === 0) {
|
|
585
|
-
|
|
585
|
+
// 提示历史只按当前会话取:`sessionIds` 恒为 `[当前会话 id]`,不要为了这两个
|
|
586
|
+
// 字段去读整份转录,几万条的会话为此白读几十 MB。
|
|
587
|
+
const sessionIds = callbacks.sessionId
|
|
586
588
|
? [callbacks.sessionId]
|
|
587
589
|
: undefined;
|
|
588
590
|
|
|
589
|
-
if (callbacks.getFullMessageThread) {
|
|
590
|
-
try {
|
|
591
|
-
const thread = await callbacks.getFullMessageThread();
|
|
592
|
-
sessionIds = thread.sessionIds;
|
|
593
|
-
} catch (error) {
|
|
594
|
-
callbacks.logger?.error(
|
|
595
|
-
"Failed to fetch ancestor session IDs",
|
|
596
|
-
error,
|
|
597
|
-
);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
591
|
const history = await PromptHistoryManager.getHistory({
|
|
602
592
|
sessionId: sessionIds,
|
|
603
593
|
workdir: callbacks.workdir,
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
-
FileItem,
|
|
3
2
|
PermissionMode,
|
|
4
3
|
Logger,
|
|
5
4
|
PromptEntry,
|
|
6
|
-
|
|
5
|
+
SubagentConfiguration,
|
|
7
6
|
} from "wave-agent-sdk";
|
|
8
7
|
import { Key } from "ink";
|
|
9
8
|
import {
|
|
@@ -27,6 +26,23 @@ export interface BtwState {
|
|
|
27
26
|
isLoading: boolean;
|
|
28
27
|
}
|
|
29
28
|
|
|
29
|
+
/**
|
|
30
|
+
* `@` 候选条目。文件/目录来自 SDK 的 `searchFiles`,子代理来自
|
|
31
|
+
* `agent.getSubagentConfigurations()`(spec `multi-agent/subagent.md`
|
|
32
|
+
* 「用户故事:显式子代理调用」)。
|
|
33
|
+
*
|
|
34
|
+
* 子代理条目的 `path` 是 `agent:<名称>` 占位值、`agentName` 是判据;选中后插入
|
|
35
|
+
* `@agent:<名称>`,SDK 会在每轮首回合把它解析成点名提醒。
|
|
36
|
+
*/
|
|
37
|
+
export interface FileSelectorItem {
|
|
38
|
+
path: string;
|
|
39
|
+
type: "file" | "directory" | "agent";
|
|
40
|
+
/** 子代理名(`type === "agent"` 时有值),也是插入正文的名字。 */
|
|
41
|
+
agentName?: string;
|
|
42
|
+
/** 子代理定义的 description,作为条目的第二行说明。 */
|
|
43
|
+
agentDescription?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
/**
|
|
31
47
|
* True while the /btw overlay is up (a question is on display, loading or
|
|
32
48
|
* answered). App's Ctrl+C exit handler checks this so Ctrl+C does not quit
|
|
@@ -85,7 +101,7 @@ export interface InputManagerCallbacks {
|
|
|
85
101
|
onCursorPositionChange?: (position: number) => void;
|
|
86
102
|
onFileSelectorStateChange?: (
|
|
87
103
|
show: boolean,
|
|
88
|
-
files:
|
|
104
|
+
files: FileSelectorItem[],
|
|
89
105
|
query: string,
|
|
90
106
|
position: number,
|
|
91
107
|
) => void;
|
|
@@ -126,14 +142,15 @@ export interface InputManagerCallbacks {
|
|
|
126
142
|
onPlanCommand?: (args?: string) => Promise<void> | void;
|
|
127
143
|
sessionId?: string;
|
|
128
144
|
workdir?: string;
|
|
129
|
-
getFullMessageThread?: () => Promise<{
|
|
130
|
-
messages: Message[];
|
|
131
|
-
sessionIds: string[];
|
|
132
|
-
}>;
|
|
133
145
|
logger?: Logger;
|
|
134
146
|
hasQueuedMessages?: boolean;
|
|
135
147
|
isIdle?: boolean;
|
|
136
148
|
onRecallQueuedMessage?: () => void;
|
|
149
|
+
/**
|
|
150
|
+
* 已加载的子代理(`Agent.getSubagentConfigurations()`),`@` 候选会把它们并到
|
|
151
|
+
* 文件结果之前(spec `multi-agent/subagent.md`「用户故事:显式子代理调用」)。
|
|
152
|
+
*/
|
|
153
|
+
subagentConfigurations?: SubagentConfiguration[];
|
|
137
154
|
}
|
|
138
155
|
|
|
139
156
|
export interface InputState {
|
|
@@ -142,7 +159,7 @@ export interface InputState {
|
|
|
142
159
|
showFileSelector: boolean;
|
|
143
160
|
atPosition: number;
|
|
144
161
|
fileSearchQuery: string;
|
|
145
|
-
filteredFiles:
|
|
162
|
+
filteredFiles: FileSelectorItem[];
|
|
146
163
|
showCommandSelector: boolean;
|
|
147
164
|
slashPosition: number;
|
|
148
165
|
commandSearchQuery: string;
|
|
@@ -486,7 +503,7 @@ export type InputAction =
|
|
|
486
503
|
| { type: "MOVE_CURSOR"; payload: number }
|
|
487
504
|
| { type: "ACTIVATE_FILE_SELECTOR"; payload: number }
|
|
488
505
|
| { type: "SET_FILE_SEARCH_QUERY"; payload: string }
|
|
489
|
-
| { type: "SET_FILTERED_FILES"; payload:
|
|
506
|
+
| { type: "SET_FILTERED_FILES"; payload: FileSelectorItem[] }
|
|
490
507
|
| { type: "CANCEL_FILE_SELECTOR" }
|
|
491
508
|
| { type: "ACTIVATE_COMMAND_SELECTOR"; payload: number }
|
|
492
509
|
| { type: "SET_COMMAND_SEARCH_QUERY"; payload: string }
|
|
@@ -526,8 +543,9 @@ export type InputAction =
|
|
|
526
543
|
| { type: "SELECT_HISTORY_ENTRY"; payload: PromptEntry }
|
|
527
544
|
| { type: "SELECT_COMMAND"; payload: string }
|
|
528
545
|
| { type: "INSERT_COMMAND"; payload: string }
|
|
529
|
-
| { type: "SELECT_FILE"; payload:
|
|
546
|
+
| { type: "SELECT_FILE"; payload: FileSelectorItem }
|
|
530
547
|
| { type: "SET_BTW_STATE"; payload: Partial<BtwState> }
|
|
548
|
+
| { type: "APPEND_BTW_ANSWER"; payload: string }
|
|
531
549
|
| { type: "CLEAR_PENDING_EFFECT" }
|
|
532
550
|
| { type: "RESET_ESC_CLEAR_PENDING" }
|
|
533
551
|
| {
|
|
@@ -915,13 +933,17 @@ export function inputReducer(
|
|
|
915
933
|
return state;
|
|
916
934
|
}
|
|
917
935
|
case "SELECT_FILE": {
|
|
918
|
-
const
|
|
936
|
+
const item = action.payload;
|
|
937
|
+
// 子代理条目插入 `@agent:<名称>`(SDK 的判据),文件/目录仍插 `@路径`。
|
|
938
|
+
const inserted = item.agentName
|
|
939
|
+
? `@agent:${item.agentName}`
|
|
940
|
+
: `@${item.path}`;
|
|
919
941
|
if (state.atPosition >= 0) {
|
|
920
942
|
const wordEnd = getWordEnd(state.inputText, state.atPosition);
|
|
921
943
|
const beforeAt = state.inputText.substring(0, state.atPosition);
|
|
922
944
|
const afterWord = state.inputText.substring(wordEnd);
|
|
923
|
-
const newInput = beforeAt +
|
|
924
|
-
const newCursorPosition = beforeAt.length +
|
|
945
|
+
const newInput = beforeAt + `${inserted} ` + afterWord;
|
|
946
|
+
const newCursorPosition = beforeAt.length + inserted.length + 1;
|
|
925
947
|
|
|
926
948
|
return {
|
|
927
949
|
...state,
|
|
@@ -1664,6 +1686,15 @@ export function inputReducer(
|
|
|
1664
1686
|
...action.payload,
|
|
1665
1687
|
},
|
|
1666
1688
|
};
|
|
1689
|
+
case "APPEND_BTW_ANSWER":
|
|
1690
|
+
// Streaming deltas are appended, never treated as the whole answer.
|
|
1691
|
+
return {
|
|
1692
|
+
...state,
|
|
1693
|
+
btwState: {
|
|
1694
|
+
...state.btwState,
|
|
1695
|
+
answer: (state.btwState.answer ?? "") + action.payload,
|
|
1696
|
+
},
|
|
1697
|
+
};
|
|
1667
1698
|
default:
|
|
1668
1699
|
return state;
|
|
1669
1700
|
}
|