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.
Files changed (40) hide show
  1. package/bin/wave-code.js +2 -0
  2. package/dist/bundle/wave.mjs +777 -855
  3. package/package.json +7 -7
  4. package/src/cli.tsx +9 -3
  5. package/src/components/FileSelector.tsx +11 -8
  6. package/src/components/HelpView.tsx +1 -1
  7. package/src/components/InputBox.tsx +3 -5
  8. package/src/components/McpManager.tsx +26 -0
  9. package/src/components/MessageBlockItem.tsx +14 -2
  10. package/src/components/RemoteControlView.tsx +1 -1
  11. package/src/components/RewindCommand.tsx +40 -46
  12. package/src/contexts/useChat.tsx +61 -49
  13. package/src/daemon/commands.ts +266 -44
  14. package/src/daemon/daemonLog.ts +132 -0
  15. package/src/daemon/socketPath.ts +123 -0
  16. package/src/daemon-cli.ts +19 -6
  17. package/src/hooks/useInputManager.ts +27 -25
  18. package/src/index.ts +36 -4
  19. package/src/managers/inputHandlers.ts +3 -13
  20. package/src/managers/inputReducer.ts +44 -13
  21. package/src/print-cli.ts +199 -49
  22. package/src/remote/hostIdentity.ts +1 -0
  23. package/src/remote/imagePreview.ts +182 -0
  24. package/src/remote/messageWindow.ts +15 -5
  25. package/src/remote/notificationCoalescer.ts +16 -3
  26. package/src/remote/relayClient.ts +1 -1
  27. package/src/remote/remoteControlCliHost.ts +26 -4
  28. package/src/remote/remoteControlService.ts +70 -19
  29. package/src/remote/remoteNotifications.ts +5 -4
  30. package/src/remote/textPreview.ts +155 -0
  31. package/src/stdio/agentBridge.ts +450 -117
  32. package/src/stdio/daemonServer.ts +49 -33
  33. package/src/stdio/hostSlashCommands.ts +9 -4
  34. package/src/stdio/uiPayload.ts +50 -2
  35. package/src/utils/agentSelector.ts +29 -0
  36. package/src/utils/argvCompat.ts +23 -0
  37. package/src/utils/printOutput.ts +403 -0
  38. package/src/utils/usageSummary.ts +74 -24
  39. package/src/utils/worktree.ts +222 -16
  40. 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 unix socket. The desktop
5
- * app launches this on a remote host via nohup/setsid and tunnels the socket
6
- * back with `ssh -L`; multiple attach/detach cycles share one process, so
7
- * sessions and pending permissions survive client disconnects. The net server
8
- * keeps the process alive — there is no stdin to wait on.
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({ socketPath, createRemoteSink });
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
- const fileItems = await searchFilesUtil(state.fileSearchQuery);
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
- (content: string) => {
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: "SET_BTW_STATE",
161
- payload: { answer: content, isLoading: true },
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
- let sessionIds: string[] | undefined = sessionId
197
- ? [sessionId]
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((filePath: string) => {
434
- dispatch({ type: "SELECT_FILE", payload: filePath });
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
- // loadSessionFromJsonl scans every project dir as a fallback, so a miss
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 { loadSessionFromJsonl } = await import("wave-agent-sdk");
739
- const exists = await loadSessionFromJsonl(argv.restore, workdir);
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
- let sessionIds: string[] | undefined = callbacks.sessionId
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
- Message,
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: FileItem[],
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: FileItem[];
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: FileItem[] }
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: string }
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 filePath = action.payload;
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 + `@${filePath} ` + afterWord;
924
- const newCursorPosition = beforeAt.length + filePath.length + 2;
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
  }