wave-code 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/bundle/wave.mjs +713 -842
  2. package/package.json +7 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/components/AgentsManager.tsx +3 -3
  5. package/src/components/App.tsx +5 -0
  6. package/src/components/BtwDisplay.tsx +71 -15
  7. package/src/components/CompactDisplay.tsx +1 -6
  8. package/src/components/ConfirmationDetails.tsx +22 -15
  9. package/src/components/ConfirmationSelector.tsx +31 -36
  10. package/src/components/DiffDisplay.tsx +1 -1
  11. package/src/components/HooksManager.tsx +3 -3
  12. package/src/components/InputBox.tsx +37 -13
  13. package/src/components/LoginCommand.tsx +16 -35
  14. package/src/components/Markdown.tsx +7 -3
  15. package/src/components/MarketplaceAddForm.tsx +15 -19
  16. package/src/components/RemoteControlView.tsx +175 -0
  17. package/src/components/ScrolledContent.tsx +15 -13
  18. package/src/components/SkillsManager.tsx +3 -3
  19. package/src/components/TaskList.tsx +3 -3
  20. package/src/components/ToolDisplay.tsx +3 -18
  21. package/src/components/WorkflowManager.tsx +117 -3
  22. package/src/contexts/useChat.tsx +87 -165
  23. package/src/daemon/commands.ts +18 -18
  24. package/src/hooks/useInputManager.ts +24 -42
  25. package/src/hooks/useLineScroll.ts +51 -5
  26. package/src/hooks/useRemoteControl.ts +23 -1
  27. package/src/managers/inputHandlers.ts +4 -1
  28. package/src/managers/inputReducer.ts +39 -1
  29. package/src/remote/messageWindow.ts +18 -69
  30. package/src/remote/notificationCoalescer.ts +21 -3
  31. package/src/remote/relayClient.ts +48 -26
  32. package/src/remote/remoteControlCliHost.ts +72 -43
  33. package/src/remote/remoteControlConfig.ts +25 -6
  34. package/src/remote/remoteControlService.ts +93 -70
  35. package/src/remote/remoteControlSink.ts +4 -3
  36. package/src/remote/remoteNotifications.ts +8 -2
  37. package/src/stdio/agentBridge.ts +325 -91
  38. package/src/stdio/hostSlashCommands.ts +209 -0
  39. package/src/stdio/uiPayload.ts +65 -0
  40. package/src/types.ts +7 -0
  41. package/src/utils/bracketedPaste.ts +0 -170
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The **one** table of host-local slash commands (spec ui/slash-commands.md
3
+ * 「弹窗里的宿主命令只有一份表——由服务端下发并按入口过滤」).
4
+ *
5
+ * These are the commands a front end handles itself — it opens a dialog, a
6
+ * settings page or a panel, or it intercepts the text before it reaches the
7
+ * agent. They are not registered with the SDK, so they cannot come from
8
+ * `agent.getSlashCommands()`; before this table existed each host carried its
9
+ * own copy (desktop 17, VS Code 18, JetBrains 17 — differing on `/clear` and
10
+ * `/config` only) and the phone page carried none, which is why the same
11
+ * command was visible on the computer and had to be typed by hand on the phone.
12
+ *
13
+ * It lives here, next to `AgentBridge`, because that is the one place every
14
+ * front end already talks to: the daemon and each GUI's `--stdio` child serve
15
+ * the popup from it, the phone's requests land on it, and JetBrains — which
16
+ * cannot import TypeScript at all — reads it as JSON over the same RPC.
17
+ *
18
+ * **What each entry claims**: `surfaces` is the set of front ends that really
19
+ * implement it. It is a capability statement, not a label: an entry listed for
20
+ * a surface that has no handling behind it produces exactly the defect spec
21
+ * ui/remote-control.md 场景 24 forbids — a row in the popup that does nothing
22
+ * when picked. `hostSlashCommands.test.ts` pins each surface's set, and the
23
+ * remote-page set is also cross-checked there against the remote allowlist,
24
+ * which is the other half of the same promise (popup lists it ⇒ picking it
25
+ * works).
26
+ */
27
+
28
+ import type { HostSurface, SlashCommandInfo } from "wave-agent-sdk/types";
29
+
30
+ /** Which front ends a command is offered to. */
31
+ type Surfaces = readonly HostSurface[];
32
+
33
+ interface HostSlashCommandSpec {
34
+ id: string;
35
+ description: string;
36
+ /** Absent = insert `/name ` and let the user finish (only `/btw` today). */
37
+ runOnSelect?: boolean;
38
+ surfaces: Surfaces;
39
+ }
40
+
41
+ /** Everyone's entries — `/clear` being the one that is not quite everyone's. */
42
+ const ALL: Surfaces = ["desktop", "vscode", "jetbrains", "remote-page"];
43
+
44
+ /** The GUIs that own a settings page / marketplace / panel — not the phone. */
45
+ const GUIS: Surfaces = ["desktop", "vscode", "jetbrains"];
46
+
47
+ /**
48
+ * The table, in popup order. Order matters: the popup appends these after the
49
+ * SDK's own commands and renders them as given, so the machine also decides
50
+ * where a command sits. This is the order the two GUI hosts already used.
51
+ */
52
+ export const HOST_SLASH_COMMANDS: readonly HostSlashCommandSpec[] = [
53
+ // ── 需要宿主界面的:只有桌面端与 IDE 有承载它们的页面 ──
54
+ {
55
+ id: "config",
56
+ description: "打开配置设置",
57
+ runOnSelect: true,
58
+ // JB 的设置页在自己的菜单里,没有这条入口;远控页没有设置页。
59
+ surfaces: ["desktop", "vscode"],
60
+ },
61
+ {
62
+ id: "plugin",
63
+ description: "打开插件市场",
64
+ runOnSelect: true,
65
+ surfaces: GUIS,
66
+ },
67
+ {
68
+ id: "reload-plugins",
69
+ description: "就地重载插件(不重启对话)",
70
+ runOnSelect: true,
71
+ surfaces: GUIS,
72
+ },
73
+ {
74
+ id: "mcp",
75
+ description: "打开 MCP 服务器管理",
76
+ runOnSelect: true,
77
+ surfaces: GUIS,
78
+ },
79
+
80
+ // ── 页面也能执行的:背后是会话内的方法,不需要编辑器与文件系统 ──
81
+ {
82
+ id: "status",
83
+ description: "查看当前状态",
84
+ runOnSelect: true,
85
+ surfaces: ALL,
86
+ },
87
+ {
88
+ id: "remote-control",
89
+ description: "远程控制(用手机接管本机会话)",
90
+ runOnSelect: true,
91
+ // 远控页不列它:那一页就是二维码指向的东西,给自己开一个入口是自我指涉。
92
+ surfaces: GUIS,
93
+ },
94
+ {
95
+ id: "clear",
96
+ description: "清除对话历史并重置会话",
97
+ runOnSelect: true,
98
+ // 桌面端整端移除了 `/clear`(2026-09-05 拍板,见 clear-command.md 与
99
+ // ui/remote-control.md 场景 24)。
100
+ surfaces: ["vscode", "jetbrains", "remote-page"],
101
+ },
102
+ {
103
+ id: "compact",
104
+ description: "手动压缩对话历史",
105
+ runOnSelect: true,
106
+ surfaces: ALL,
107
+ },
108
+ {
109
+ id: "tasks",
110
+ description: "查看后台任务",
111
+ runOnSelect: true,
112
+ surfaces: ALL,
113
+ },
114
+ {
115
+ id: "workflows",
116
+ description: "查看工作流运行",
117
+ runOnSelect: true,
118
+ surfaces: ALL,
119
+ },
120
+ {
121
+ id: "agents",
122
+ description: "查看可用 agents",
123
+ runOnSelect: true,
124
+ surfaces: GUIS,
125
+ },
126
+ {
127
+ id: "skills",
128
+ description: "查看可用技能",
129
+ runOnSelect: true,
130
+ surfaces: GUIS,
131
+ },
132
+ {
133
+ id: "hooks",
134
+ description: "查看已配置钩子",
135
+ runOnSelect: true,
136
+ surfaces: GUIS,
137
+ },
138
+ {
139
+ id: "rewind",
140
+ description: "回退到之前的用户消息",
141
+ runOnSelect: true,
142
+ surfaces: ALL,
143
+ },
144
+ // `/model` 不在这里:模型入口自 2026-10-21 起是输入卡片底栏的选择器(见 spec
145
+ // ui/model-selection.md),桌面 / IDE / 远控页都不再有这条命令;CLI 的 TUI 没有
146
+ // 底栏,因此 `/model` 只留在它自己的命令表里(packages/code 的 AVAILABLE_COMMANDS)。
147
+ {
148
+ id: "resume",
149
+ description: "恢复历史对话",
150
+ runOnSelect: true,
151
+ // 页面的会话列表只报进程内的活会话,磁盘历史会话在那边不渲染(场景 24)。
152
+ surfaces: GUIS,
153
+ },
154
+ {
155
+ id: "btw",
156
+ description: "旁路提问(不进入聊天记录)",
157
+ // 不标 `runOnSelect`:选中后只把 `/btw ` 插进输入框,等用户补问题
158
+ // (spec ui/btw-command.md)。
159
+ surfaces: ALL,
160
+ },
161
+ {
162
+ id: "plan",
163
+ description: "启用规划模式或查看当前方案",
164
+ runOnSelect: true,
165
+ // 页面上没有计划预览面板,计划正文渲染在 ExitPlanMode 的审批卡里。
166
+ surfaces: GUIS,
167
+ },
168
+ ];
169
+
170
+ /** The table as one surface sees it, already in wire shape. */
171
+ export function hostSlashCommandsFor(surface: HostSurface): SlashCommandInfo[] {
172
+ return HOST_SLASH_COMMANDS.filter((command) =>
173
+ command.surfaces.includes(surface),
174
+ ).map((command) => ({
175
+ id: command.id,
176
+ name: command.id,
177
+ description: command.description,
178
+ ...(command.runOnSelect ? { runOnSelect: true } : {}),
179
+ }));
180
+ }
181
+
182
+ /**
183
+ * An agent command as the popup's wire shape — `handler` and `immediate` do
184
+ * not cross the wire, and `skillSource` is kept only when it is actually set
185
+ * (the webview reads "has a source tag" off its presence).
186
+ */
187
+ export function toSlashCommandInfo(command: {
188
+ id: string;
189
+ name: string;
190
+ description: string;
191
+ skillSource?: SlashCommandInfo["skillSource"];
192
+ }): SlashCommandInfo {
193
+ return {
194
+ id: command.id,
195
+ name: command.name,
196
+ description: command.description,
197
+ ...(command.skillSource ? { skillSource: command.skillSource } : {}),
198
+ };
199
+ }
200
+
201
+ /** Narrow the `surface` field a caller sent, or `undefined` when absent/unknown. */
202
+ export function parseHostSurface(value: unknown): HostSurface | undefined {
203
+ return value === "desktop" ||
204
+ value === "vscode" ||
205
+ value === "jetbrains" ||
206
+ value === "remote-page"
207
+ ? value
208
+ : undefined;
209
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Payload trimming for everything this process sends **to a UI**
3
+ * (docs/specs/ui/stdio-transport.md「工具图片的字节不下发 UI」).
4
+ *
5
+ * Tool images arrive as base64 on `ToolBlock.images` (Read on a picture, MCP
6
+ * tools returning image content). No UI renders them — the webview's only read
7
+ * of the field sits in a branch that never runs for a tool block, the CLI shows
8
+ * a count — while one screenshot can be well over a megabyte of base64, and a
9
+ * remote-hosted page cannot even receive a frame that large. So every message
10
+ * leaving this process for a host goes through here: notifications,
11
+ * `getMessages`, `getFullMessageThread`, and the remote relay that is fed by
12
+ * the same frames.
13
+ *
14
+ * **The agent's own copy is untouched.** The SDK still holds the bytes, the
15
+ * next request still sends them to the model, and the transcript on disk keeps
16
+ * them. This is a wire cut, not a memory model: stripping `images` where the
17
+ * SDK writes the block would break the next turn, so do not move it there.
18
+ *
19
+ * Copies are shallow and made only where the field is actually present — the
20
+ * common message (no images) keeps its identity and costs nothing.
21
+ */
22
+
23
+ import type {
24
+ Message,
25
+ ToolBlock,
26
+ ToolBlockUpdateCallbackParams,
27
+ } from "wave-agent-sdk";
28
+
29
+ /** A message whose tool blocks carry no image bytes. */
30
+ export function messageForUi(message: Message): Message {
31
+ const { blocks } = message;
32
+ if (!Array.isArray(blocks)) return message;
33
+ const trimmed = blocks.map((block) =>
34
+ block.type === "tool" && hasImages(block) ? withoutImages(block) : block,
35
+ );
36
+ const changed = trimmed.some((block, index) => block !== blocks[index]);
37
+ return changed ? { ...message, blocks: trimmed } : message;
38
+ }
39
+
40
+ /** `messageForUi` over a page / list, preserving the array when nothing changed. */
41
+ export function messagesForUi(messages: Message[]): Message[] {
42
+ const trimmed = messages.map(messageForUi);
43
+ const changed = trimmed.some((message, index) => message !== messages[index]);
44
+ return changed ? trimmed : messages;
45
+ }
46
+
47
+ /** A `toolBlockUpdated` frame whose block carries no image bytes. */
48
+ export function toolBlockUpdateForUi(
49
+ params: ToolBlockUpdateCallbackParams,
50
+ ): ToolBlockUpdateCallbackParams {
51
+ if (!params.images || params.images.length === 0) return params;
52
+ const { images, ...rest } = params;
53
+ void images;
54
+ return rest;
55
+ }
56
+
57
+ function hasImages(block: ToolBlock): boolean {
58
+ return Array.isArray(block.images) && block.images.length > 0;
59
+ }
60
+
61
+ function withoutImages(block: ToolBlock): ToolBlock {
62
+ const { images, ...rest } = block;
63
+ void images;
64
+ return rest;
65
+ }
package/src/types.ts CHANGED
@@ -18,4 +18,11 @@ export interface BaseAppProps {
18
18
  model?: string;
19
19
  /** Optional MCP server configs to pass to Agent.create() */
20
20
  mcpServers?: Record<string, McpServerConfig>;
21
+ /**
22
+ * Erases what ink has drawn without touching the terminal scrollback (ink's
23
+ * `instance.clear()`, injected by the CLI entry where the ink instance lives).
24
+ * Actions that must leave the previous conversation scrollable above — `/clear`
25
+ * — use it instead of a hard erase.
26
+ */
27
+ onClearOutput?: () => void;
21
28
  }
@@ -1,170 +0,0 @@
1
- /**
2
- * Bracketed paste (DECSET 2004) detector.
3
- *
4
- * When bracketed paste is enabled, terminals wrap pasted text in
5
- * `\x1b[200~` (start) and `\x1b[201~` (end) markers, which lets the input
6
- * pipeline distinguish "text was pasted" from "user typed keys" — most
7
- * importantly, a pasted trailing `\r` must NOT be treated as an Enter key.
8
- *
9
- * ink delivers each stdin chunk through useInput after stripping ONE leading
10
- * ESC from the parsed sequence (see use-input.js), so markers may arrive in
11
- * either the raw form (`\x1b[200~`) or the stripped form (`[200~`), and may
12
- * be split across chunks. The detector normalizes both forms and buffers
13
- * partial markers until they complete (deferral is safe: a deferred partial
14
- * is flushed unchanged if the next chunk does not complete a marker).
15
- */
16
- export type PasteProcessResult =
17
- | { kind: "input"; input: string }
18
- | { kind: "paste"; text: string; leadingInput?: string }
19
- | { kind: "consume" };
20
-
21
- export interface BracketedPasteDetector {
22
- /**
23
- * Feed one input chunk (as delivered by ink's useInput callback).
24
- * - `input`: regular keystrokes, pass through to normal handling.
25
- * - `paste`: completed bracketed paste; insert `text` without submitting.
26
- * `leadingInput` (rare) is content that preceded the start marker in the
27
- * same chunk and should be handled as regular input first.
28
- * - `consume`: content of an in-flight paste (or an empty paste); do
29
- * nothing with this chunk.
30
- */
31
- process(chunk: string): PasteProcessResult;
32
- reset(): void;
33
- }
34
-
35
- const ESC = "\u001b";
36
- const START_STRIPPED = "[200~";
37
- const START_RAW = `${ESC}[200~`;
38
- const END_STRIPPED = "[201~";
39
- const END_RAW = `${ESC}[201~`;
40
-
41
- const START_FORMS = [START_RAW, START_STRIPPED];
42
- const END_FORMS = [END_RAW, END_STRIPPED];
43
-
44
- /**
45
- * Suffixes that may be the beginning of a marker split across chunks.
46
- * Longest-first so the longest matching suffix is deferred (e.g. `[200`
47
- * rather than `[20` for `...x[200`). Deferral is flush-equivalent: if the
48
- * next chunk does not complete a marker, the deferred suffix is delivered
49
- * as regular input unchanged.
50
- */
51
- const PARTIAL_SUFFIXES = [
52
- `${ESC}[201`,
53
- `${ESC}[200`,
54
- `${ESC}[20`,
55
- `${ESC}[2`,
56
- "[201",
57
- "[200",
58
- "[20",
59
- "[2",
60
- ].sort((a, b) => b.length - a.length);
61
-
62
- export function createBracketedPasteDetector(): BracketedPasteDetector {
63
- let inPaste = false;
64
- let buffer = ""; // paste content collected since the start marker
65
- let pending = ""; // deferred partial marker suffix from a previous chunk
66
-
67
- const findFirst = (
68
- text: string,
69
- forms: string[],
70
- ): { index: number; length: number } | null => {
71
- let best: { index: number; length: number } | null = null;
72
- for (const form of forms) {
73
- const index = text.indexOf(form);
74
- if (index !== -1 && (best === null || index < best.index)) {
75
- best = { index, length: form.length };
76
- }
77
- }
78
- return best;
79
- };
80
-
81
- const endsWithPartial = (text: string): string | null => {
82
- for (const prefix of PARTIAL_SUFFIXES) {
83
- if (text.endsWith(prefix)) {
84
- return prefix;
85
- }
86
- }
87
- return null;
88
- };
89
-
90
- const process = (chunk: string): PasteProcessResult => {
91
- let remaining = pending + chunk;
92
- pending = "";
93
- let leadingInput = "";
94
- let pasteText: string | null = null;
95
-
96
- while (remaining.length > 0) {
97
- if (!inPaste) {
98
- const start = findFirst(remaining, START_FORMS);
99
- if (start) {
100
- leadingInput += remaining.slice(0, start.index);
101
- remaining = remaining.slice(start.index + start.length);
102
- inPaste = true;
103
- continue;
104
- }
105
- // Orphan end marker (start was missed, e.g. consumed by another
106
- // input handler): treat the preceding text as paste so a trailing
107
- // `\r` cannot trigger the coalesced-Enter submit heuristic.
108
- const end = findFirst(remaining, END_FORMS);
109
- if (end) {
110
- pasteText =
111
- (pasteText ?? "") + leadingInput + remaining.slice(0, end.index);
112
- leadingInput = "";
113
- remaining = remaining.slice(end.index + end.length);
114
- continue;
115
- }
116
- const partial = endsWithPartial(remaining);
117
- if (partial) {
118
- pending = remaining.slice(remaining.length - partial.length);
119
- remaining = remaining.slice(0, remaining.length - partial.length);
120
- }
121
- if (remaining.length > 0) {
122
- leadingInput += remaining;
123
- remaining = "";
124
- }
125
- break;
126
- }
127
-
128
- const end = findFirst(remaining, END_FORMS);
129
- if (end) {
130
- pasteText = (pasteText ?? "") + buffer + remaining.slice(0, end.index);
131
- buffer = "";
132
- inPaste = false;
133
- remaining = remaining.slice(end.index + end.length);
134
- continue;
135
- }
136
- const partial = endsWithPartial(remaining);
137
- if (partial) {
138
- pending = remaining.slice(remaining.length - partial.length);
139
- remaining = remaining.slice(0, remaining.length - partial.length);
140
- }
141
- buffer += remaining;
142
- remaining = "";
143
- break;
144
- }
145
-
146
- if (inPaste) {
147
- // Paste still in flight: hold the content, deliver nothing yet.
148
- return { kind: "consume" };
149
- }
150
- if (pasteText !== null) {
151
- const result: { kind: "paste"; text: string; leadingInput?: string } = {
152
- kind: "paste",
153
- text: pasteText,
154
- };
155
- if (leadingInput !== "") {
156
- result.leadingInput = leadingInput;
157
- }
158
- return result;
159
- }
160
- return { kind: "input", input: leadingInput };
161
- };
162
-
163
- const reset = (): void => {
164
- inPaste = false;
165
- buffer = "";
166
- pending = "";
167
- };
168
-
169
- return { process, reset };
170
- }