wave-code 0.19.8 → 1.0.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 (37) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/components/ChatInterface.js +1 -1
  3. package/dist/components/HelpView.js +6 -0
  4. package/dist/components/InputBox.d.ts +2 -0
  5. package/dist/components/InputBox.js +11 -2
  6. package/dist/components/RewindCommand.js +4 -2
  7. package/dist/components/TaskList.js +28 -12
  8. package/dist/hooks/useInputManager.d.ts +1 -0
  9. package/dist/hooks/useInputManager.js +23 -20
  10. package/dist/index.js +1 -1
  11. package/dist/managers/inputHandlers.js +5 -8
  12. package/dist/managers/inputReducer.d.ts +10 -20
  13. package/dist/managers/inputReducer.js +309 -173
  14. package/dist/print-cli.js +36 -10
  15. package/dist/stdio/agentBridge.d.ts +6 -0
  16. package/dist/stdio/agentBridge.js +141 -1
  17. package/dist/stdio/protocol.d.ts +1 -1
  18. package/dist/utils/rewindCheckpoints.d.ts +8 -0
  19. package/dist/utils/rewindCheckpoints.js +15 -0
  20. package/dist/utils/worktree.d.ts +12 -2
  21. package/dist/utils/worktree.js +61 -25
  22. package/package.json +2 -2
  23. package/src/cli.tsx +1 -1
  24. package/src/components/ChatInterface.tsx +2 -0
  25. package/src/components/HelpView.tsx +6 -0
  26. package/src/components/InputBox.tsx +19 -0
  27. package/src/components/RewindCommand.tsx +4 -2
  28. package/src/components/TaskList.tsx +30 -12
  29. package/src/hooks/useInputManager.ts +27 -21
  30. package/src/index.ts +1 -1
  31. package/src/managers/inputHandlers.ts +6 -10
  32. package/src/managers/inputReducer.ts +381 -208
  33. package/src/print-cli.ts +48 -11
  34. package/src/stdio/agentBridge.ts +210 -0
  35. package/src/stdio/protocol.ts +6 -1
  36. package/src/utils/rewindCheckpoints.ts +15 -0
  37. package/src/utils/worktree.ts +99 -34
@@ -14,9 +14,12 @@
14
14
  * - Implement the canUseTool permission flow over the stdio protocol
15
15
  * - Handle config updates by destroying and recreating the Agent
16
16
  */
17
- import { Agent, listSessions, searchFiles, PromptHistoryManager, AuthService, PluginCore, } from "wave-agent-sdk";
17
+ import { Agent, listSessions, searchFiles, generateRandomName, getDefaultRemoteBranch, getMessageContent, PromptHistoryManager, AuthService, PluginCore, validateWorktreeRemovalPath, } from "wave-agent-sdk";
18
18
  import { INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR, METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND, } from "./protocol.js";
19
+ import { execFileSync } from "node:child_process";
20
+ import { createWorktree, removeWorktree } from "../utils/worktree.js";
19
21
  import { logger } from "../utils/logger.js";
22
+ import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
20
23
  export class AgentBridge {
21
24
  constructor(options) {
22
25
  this.sessions = new Map();
@@ -52,6 +55,8 @@ export class AgentBridge {
52
55
  return this.clearMessages(sessionId);
53
56
  case "rewindToMessage":
54
57
  return this.rewindToMessage(p.messageId, sessionId);
58
+ case "listRewindCheckpoints":
59
+ return this.listRewindCheckpoints(sessionId);
55
60
  case "deleteQueuedMessage":
56
61
  return this.deleteQueuedMessage(p.index, sessionId);
57
62
  case "updateQueuedMessage":
@@ -102,6 +107,10 @@ export class AgentBridge {
102
107
  return this.enablePlugin(p.pluginId, p.scope, p.workdir, sessionId);
103
108
  case "disablePlugin":
104
109
  return this.disablePlugin(p.pluginId, p.scope, p.workdir, sessionId);
110
+ case "getProjectSettings":
111
+ return this.getProjectSettings(p.workdir, sessionId);
112
+ case "setBuiltinPluginEnabled":
113
+ return this.setBuiltinPluginEnabled(p.pluginId, p.enabled, p.scope, p.workdir, sessionId);
105
114
  case "updatePlugin":
106
115
  return this.updatePlugin(p.pluginId, p.workdir, sessionId);
107
116
  case "listMarketplaces":
@@ -123,6 +132,13 @@ export class AgentBridge {
123
132
  return this.getWorkflowRuns(sessionId);
124
133
  case "stopWorkflowRun":
125
134
  return this.stopWorkflowRun(p.runId, sessionId);
135
+ // ── Git / worktree (global — no session required) ──
136
+ case "listGitBranches":
137
+ return this.listGitBranches(p.workdir);
138
+ case "createWorktree":
139
+ return this.createWorktreeSession(p);
140
+ case "removeWorktree":
141
+ return this.removeWorktreeSession(p);
126
142
  default:
127
143
  throw new RpcError(PROTOCOL_METHOD_NOT_FOUND, `Method not found: ${method}`);
128
144
  }
@@ -159,6 +175,8 @@ export class AgentBridge {
159
175
  disallowedTools: params.disallowedTools,
160
176
  plugins: params.pluginDirs?.map((path) => ({ type: "local", path })),
161
177
  mcpServers: params.mcpServers,
178
+ worktreeName: params.worktreeName,
179
+ isNewWorktree: params.isNewWorktree,
162
180
  canUseTool: (context) => this.canUseTool(context, ctx),
163
181
  };
164
182
  const agent = await Agent.create(options);
@@ -194,6 +212,98 @@ export class AgentBridge {
194
212
  const sessions = await listSessions(workdir || this.getSessionWorkdir(sessionId) || process.cwd());
195
213
  return { sessions };
196
214
  }
215
+ // ── Git / worktree ────────────────────────────────────────────
216
+ listGitBranches(workdir) {
217
+ if (!workdir) {
218
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
219
+ }
220
+ const gitOpts = {
221
+ cwd: workdir,
222
+ encoding: "utf8",
223
+ stdio: ["ignore", "pipe", "pipe"],
224
+ };
225
+ let branchesRaw;
226
+ try {
227
+ branchesRaw = execFileSync("git", ["for-each-ref", "--format=%(refname:short)", "refs/heads"], gitOpts).trim();
228
+ }
229
+ catch {
230
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, `Not a git repository (or git unavailable): ${workdir}`);
231
+ }
232
+ const branches = branchesRaw
233
+ ? branchesRaw
234
+ .split("\n")
235
+ .map((b) => b.trim())
236
+ .filter(Boolean)
237
+ : [];
238
+ let current = null;
239
+ try {
240
+ const head = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], gitOpts).trim();
241
+ // Detached HEAD prints "HEAD" — treat as no current branch.
242
+ current = head && head !== "HEAD" ? head : null;
243
+ }
244
+ catch {
245
+ current = null;
246
+ }
247
+ return { branches, current };
248
+ }
249
+ async createWorktreeSession(params) {
250
+ if (!params.workdir) {
251
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
252
+ }
253
+ const name = params.name?.trim() || generateRandomName();
254
+ try {
255
+ const session = await createWorktree(name, params.workdir, {
256
+ baseBranch: params.baseBranch,
257
+ });
258
+ return {
259
+ name: session.name,
260
+ path: session.path,
261
+ branch: session.branch,
262
+ repoRoot: session.repoRoot,
263
+ baseBranch: params.baseBranch ?? getDefaultRemoteBranch(params.workdir),
264
+ isNew: session.isNew,
265
+ };
266
+ }
267
+ catch (e) {
268
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, e.message);
269
+ }
270
+ }
271
+ async removeWorktreeSession(params) {
272
+ // Align with Claude Code v2.1.216+: refuse to remove a worktree whose path
273
+ // is a symlink or resolves outside the repo root. Already-removed (missing)
274
+ // paths pass validation so removal stays idempotent.
275
+ try {
276
+ validateWorktreeRemovalPath(params.path, params.repoRoot);
277
+ }
278
+ catch (e) {
279
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, e.message);
280
+ }
281
+ // Trigger the WorktreeRemove hook (before git removal, non-blocking) using
282
+ // the session that runs in this worktree, if it is still registered.
283
+ for (const entry of this.sessions.values()) {
284
+ if (entry.agent.workingDirectory === params.path) {
285
+ try {
286
+ await entry.agent.triggerWorktreeRemoveHook(params.path);
287
+ }
288
+ catch (e) {
289
+ logger.warn("WorktreeRemove hooks execution failed:", e);
290
+ }
291
+ break;
292
+ }
293
+ }
294
+ // removeWorktree is best-effort/idempotent: already-removed worktrees or
295
+ // branches only log, never throw.
296
+ await removeWorktree({
297
+ name: "",
298
+ path: params.path,
299
+ branch: params.branch,
300
+ repoRoot: params.repoRoot,
301
+ hasUncommittedChanges: false,
302
+ hasNewCommits: false,
303
+ isNew: false,
304
+ });
305
+ return { ok: true };
306
+ }
197
307
  getSessionInfo(sessionId) {
198
308
  const entry = this.requireSession(sessionId);
199
309
  return {
@@ -233,6 +343,9 @@ export class AgentBridge {
233
343
  path: p,
234
344
  })),
235
345
  mcpServers: entry.storedConfig.mcpServers,
346
+ // Keep worktree context (permission safety) across recreation, but never
347
+ // re-fire WorktreeCreate — the hook ran at initial creation.
348
+ worktreeName: entry.storedConfig.worktreeName,
236
349
  canUseTool: (context) => this.canUseTool(context, ctx),
237
350
  };
238
351
  const agent = await Agent.create(options);
@@ -287,6 +400,17 @@ export class AgentBridge {
287
400
  await entry.agent.truncateHistory(index);
288
401
  return { inputContent: textBlock?.content || "" };
289
402
  }
403
+ async listRewindCheckpoints(sessionId) {
404
+ const entry = this.requireSession(sessionId);
405
+ const { messages } = await entry.agent.getFullMessageThread();
406
+ const checkpoints = messages
407
+ .filter((m) => isUserCheckpointMessage(m) && m.id)
408
+ .map((m) => ({
409
+ id: m.id,
410
+ content: getMessageContent(m).replace(/\s+/g, " ").trim(),
411
+ }));
412
+ return { checkpoints };
413
+ }
290
414
  deleteQueuedMessage(index, sessionId) {
291
415
  const entry = this.requireSession(sessionId);
292
416
  entry.agent.removeQueuedMessage(index);
@@ -467,6 +591,22 @@ export class AgentBridge {
467
591
  async disablePlugin(pluginId, scope, workdir, sessionId) {
468
592
  return this.getPluginCore(workdir, sessionId).disablePlugin(pluginId, scope);
469
593
  }
594
+ async getProjectSettings(workdir, sessionId) {
595
+ return {
596
+ enabledPlugins: this.getPluginCore(workdir, sessionId).getMergedEnabledPlugins(),
597
+ };
598
+ }
599
+ async setBuiltinPluginEnabled(pluginId, enabled, scope, workdir, sessionId) {
600
+ const core = this.getPluginCore(workdir, sessionId);
601
+ const targetScope = scope ?? "project";
602
+ if (enabled) {
603
+ await core.enablePlugin(pluginId, targetScope);
604
+ }
605
+ else {
606
+ await core.disablePlugin(pluginId, targetScope);
607
+ }
608
+ return { enabledPlugins: core.getMergedEnabledPlugins() };
609
+ }
470
610
  async updatePlugin(pluginId, workdir, sessionId) {
471
611
  return this.getPluginCore(workdir, sessionId).updatePlugin(pluginId);
472
612
  }
@@ -34,7 +34,7 @@ export declare const INVALID_REQUEST = -32600;
34
34
  export declare const METHOD_NOT_FOUND = -32601;
35
35
  export declare const INVALID_PARAMS = -32602;
36
36
  export declare const INTERNAL_ERROR = -32603;
37
- export type RequestMethod = "initialize" | "destroy" | "restoreSession" | "listSessions" | "getSessionInfo" | "sendMessage" | "bang" | "abortMessage" | "clearMessages" | "rewindToMessage" | "deleteQueuedMessage" | "updateQueuedMessage" | "deleteQueuedMessageById" | "getMessages" | "getFullMessageThread" | "setPermissionMode" | "getPermissionMode" | "getMcpServers" | "connectMcpServer" | "disconnectMcpServer" | "getSlashCommands" | "searchFiles" | "getPromptHistory" | "searchPromptHistory" | "updateConfig" | "getAuthStatus" | "login" | "logout" | "listPlugins" | "installPlugin" | "uninstallPlugin" | "enablePlugin" | "disablePlugin" | "updatePlugin" | "listMarketplaces" | "addMarketplace" | "removeMarketplace" | "updateMarketplace" | "compact" | "getBackgroundTaskOutput" | "stopBackgroundTask" | "getWorkflowRuns" | "stopWorkflowRun";
37
+ export type RequestMethod = "initialize" | "destroy" | "restoreSession" | "listSessions" | "getSessionInfo" | "sendMessage" | "bang" | "abortMessage" | "clearMessages" | "rewindToMessage" | "listRewindCheckpoints" | "deleteQueuedMessage" | "updateQueuedMessage" | "deleteQueuedMessageById" | "getMessages" | "getFullMessageThread" | "setPermissionMode" | "getPermissionMode" | "getMcpServers" | "connectMcpServer" | "disconnectMcpServer" | "getSlashCommands" | "searchFiles" | "getPromptHistory" | "searchPromptHistory" | "updateConfig" | "getAuthStatus" | "login" | "logout" | "listPlugins" | "installPlugin" | "uninstallPlugin" | "enablePlugin" | "disablePlugin" | "updatePlugin" | "listMarketplaces" | "addMarketplace" | "removeMarketplace" | "updateMarketplace" | "compact" | "getBackgroundTaskOutput" | "stopBackgroundTask" | "getWorkflowRuns" | "stopWorkflowRun" | "listGitBranches" | "createWorktree" | "removeWorktree";
38
38
  export type ClientNotificationMethod = "permissionResponse";
39
39
  export type ServerNotificationMethod = "messagesChange" | "userMessageAdded" | "assistantMessageAdded" | "assistantContentUpdated" | "assistantReasoningUpdated" | "toolBlockUpdated" | "errorBlockAdded" | "loadingChange" | "commandRunningChange" | "queuedMessagesChange" | "tasksChange" | "sessionIdChange" | "permissionModeChange" | "mcpServersChange" | "workdirChange" | "bangMessageAdded" | "bangMessageUpdated" | "bangMessageCompleted" | "notificationMessageAdded" | "permissionRequest" | "authUrl" | "compactBlockAdded" | "compactionStateChange" | "backgroundTasksChange";
40
40
  export declare function isRequest(msg: unknown): msg is JsonRpcRequest;
@@ -0,0 +1,8 @@
1
+ import type { Message } from "wave-agent-sdk";
2
+ /**
3
+ * 判断一条 user 消息能否作为 /rewind 检查点。
4
+ * 后台任务通知(task_notification)与 hook 注入的消息(source: "hook")
5
+ * 都是系统生成、用户不可见的,不能作为回滚点。
6
+ * CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
7
+ */
8
+ export declare function isUserCheckpointMessage(m: Message): boolean;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 判断一条 user 消息能否作为 /rewind 检查点。
3
+ * 后台任务通知(task_notification)与 hook 注入的消息(source: "hook")
4
+ * 都是系统生成、用户不可见的,不能作为回滚点。
5
+ * CLI 交互式选择器与 stdio listRewindCheckpoints 共用此判定,避免两处漂移。
6
+ */
7
+ export function isUserCheckpointMessage(m) {
8
+ if (m.role !== "user" || m.isMeta)
9
+ return false;
10
+ if (m.blocks.some((b) => b.type === "task_notification"))
11
+ return false;
12
+ if (m.blocks.some((b) => b.type === "text" && b.source === "hook"))
13
+ return false;
14
+ return true;
15
+ }
@@ -7,19 +7,29 @@ export interface WorktreeSession {
7
7
  hasNewCommits: boolean;
8
8
  isNew: boolean;
9
9
  }
10
+ /**
11
+ * Validate a worktree name before any side effects. Names are slash-separated
12
+ * slugs: every segment must be non-empty and contain only letters, digits,
13
+ * dots, underscores, and dashes. The total length is capped at 64 characters
14
+ * and "." / ".." segments are rejected (path traversal protection).
15
+ * @throws {Error} When the name is not a valid slug
16
+ */
17
+ export declare function validateWorktreeSlug(name: string): void;
10
18
  /**
11
19
  * Create a new git worktree
12
20
  * @param name Worktree name
13
21
  * @param cwd Current working directory
14
22
  * @param options Optional creation options
15
23
  * @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
24
+ * @param options.baseBranch Explicit base branch (overrides baseRef)
16
25
  * @returns Worktree session details
17
26
  */
18
27
  export declare function createWorktree(name: string, cwd: string, options?: {
19
28
  baseRef?: "fresh" | "head";
20
- }): WorktreeSession;
29
+ baseBranch?: string;
30
+ }): Promise<WorktreeSession>;
21
31
  /**
22
32
  * Remove a git worktree and its associated branch
23
33
  * @param session Worktree session details
24
34
  */
25
- export declare function removeWorktree(session: WorktreeSession): void;
35
+ export declare function removeWorktree(session: WorktreeSession): Promise<void>;
@@ -1,21 +1,58 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
2
3
  import * as path from "node:path";
3
4
  import * as fs from "node:fs";
4
- import { getDefaultRemoteBranch, getGitMainRepoRoot } from "wave-agent-sdk";
5
+ import { getDefaultRemoteBranch, getGitMainRepoRoot, performPostCreationSetup, } from "wave-agent-sdk";
6
+ // Never use execFileSync here: the shared `wave --stdio` process handles all
7
+ // desktop sessions, so a synchronous git call (especially a multi-second
8
+ // recursive worktree delete or a network fetch) freezes every session.
9
+ const execFileAsync = promisify(execFile);
10
+ // --- Worktree name validation ------------------------------------------------
11
+ const VALID_WORKTREE_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/;
12
+ const MAX_WORKTREE_SLUG_LENGTH = 64;
13
+ /**
14
+ * Validate a worktree name before any side effects. Names are slash-separated
15
+ * slugs: every segment must be non-empty and contain only letters, digits,
16
+ * dots, underscores, and dashes. The total length is capped at 64 characters
17
+ * and "." / ".." segments are rejected (path traversal protection).
18
+ * @throws {Error} When the name is not a valid slug
19
+ */
20
+ export function validateWorktreeSlug(name) {
21
+ if (name.length > MAX_WORKTREE_SLUG_LENGTH) {
22
+ throw new Error(`Invalid worktree name: "${name}" must be ${MAX_WORKTREE_SLUG_LENGTH} characters or fewer (got ${name.length})`);
23
+ }
24
+ for (const segment of name.split("/")) {
25
+ if (segment === "." || segment === "..") {
26
+ throw new Error(`Invalid worktree name: "${name}" must not contain "." or ".." path segments`);
27
+ }
28
+ if (segment.length === 0 || !VALID_WORKTREE_SLUG_SEGMENT.test(segment)) {
29
+ throw new Error(`Invalid worktree name: "${name}" each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`);
30
+ }
31
+ }
32
+ }
5
33
  /**
6
34
  * Create a new git worktree
7
35
  * @param name Worktree name
8
36
  * @param cwd Current working directory
9
37
  * @param options Optional creation options
10
38
  * @param options.baseRef "fresh" (default, origin/<default-branch>) | "head" (local HEAD)
39
+ * @param options.baseBranch Explicit base branch (overrides baseRef)
11
40
  * @returns Worktree session details
12
41
  */
13
- export function createWorktree(name, cwd, options) {
42
+ export async function createWorktree(name, cwd, options) {
43
+ validateWorktreeSlug(name);
44
+ const session = await createWorktreeInternal(name, cwd, options);
45
+ if (session.isNew) {
46
+ await performPostCreationSetup(session.path, session.repoRoot);
47
+ }
48
+ return session;
49
+ }
50
+ async function createWorktreeInternal(name, cwd, options) {
14
51
  const repoRoot = getGitMainRepoRoot(cwd);
15
52
  const worktreePath = path.join(repoRoot, ".wave", "worktrees", name);
16
53
  const branchName = `worktree-${name}`;
17
54
  const useHead = options?.baseRef === "head";
18
- const baseBranch = useHead ? "HEAD" : getDefaultRemoteBranch(cwd);
55
+ const resolvedBaseBranch = options?.baseBranch ?? (useHead ? "HEAD" : getDefaultRemoteBranch(cwd));
19
56
  // Ensure parent directory exists
20
57
  const parentDir = path.dirname(worktreePath);
21
58
  if (!fs.existsSync(parentDir)) {
@@ -36,9 +73,8 @@ export function createWorktree(name, cwd, options) {
36
73
  }
37
74
  try {
38
75
  // Create worktree and branch
39
- execFileSync("git", ["worktree", "add", "-b", branchName, worktreePath, baseBranch], {
76
+ await execFileAsync("git", ["worktree", "add", "-b", branchName, worktreePath, resolvedBaseBranch], {
40
77
  cwd: repoRoot,
41
- stdio: ["ignore", "pipe", "pipe"],
42
78
  });
43
79
  return {
44
80
  name,
@@ -55,9 +91,8 @@ export function createWorktree(name, cwd, options) {
55
91
  if (stderr.includes("already exists")) {
56
92
  // If branch already exists, try to add worktree without -b
57
93
  try {
58
- execFileSync("git", ["worktree", "add", worktreePath, branchName], {
94
+ await execFileAsync("git", ["worktree", "add", worktreePath, branchName], {
59
95
  cwd: repoRoot,
60
- stdio: ["ignore", "pipe", "pipe"],
61
96
  });
62
97
  return {
63
98
  name,
@@ -77,15 +112,20 @@ export function createWorktree(name, cwd, options) {
77
112
  (stderr.includes("not a valid object name") ||
78
113
  stderr.includes("unknown revision"))) {
79
114
  // Base branch not fetched yet — try fetching then retrying
80
- const branchNameOnly = baseBranch.split("/").pop();
115
+ const branchNameOnly = resolvedBaseBranch.split("/").pop();
81
116
  try {
82
- execFileSync("git", ["fetch", "origin", branchNameOnly], {
117
+ await execFileAsync("git", ["fetch", "origin", branchNameOnly], {
83
118
  cwd: repoRoot,
84
- stdio: ["ignore", "pipe", "pipe"],
85
119
  });
86
- execFileSync("git", ["worktree", "add", "-b", branchName, worktreePath, baseBranch], {
120
+ await execFileAsync("git", [
121
+ "worktree",
122
+ "add",
123
+ "-b",
124
+ branchName,
125
+ worktreePath,
126
+ resolvedBaseBranch,
127
+ ], {
87
128
  cwd: repoRoot,
88
- stdio: ["ignore", "pipe", "pipe"],
89
129
  });
90
130
  return {
91
131
  name,
@@ -100,9 +140,8 @@ export function createWorktree(name, cwd, options) {
100
140
  catch {
101
141
  // Fetch or retry failed — fall back to HEAD
102
142
  try {
103
- execFileSync("git", ["worktree", "add", "-b", branchName, worktreePath, "HEAD"], {
143
+ await execFileAsync("git", ["worktree", "add", "-b", branchName, worktreePath, "HEAD"], {
104
144
  cwd: repoRoot,
105
- stdio: ["ignore", "pipe", "pipe"],
106
145
  });
107
146
  return {
108
147
  name,
@@ -126,31 +165,29 @@ export function createWorktree(name, cwd, options) {
126
165
  * Remove a git worktree and its associated branch
127
166
  * @param session Worktree session details
128
167
  */
129
- export function removeWorktree(session) {
168
+ export async function removeWorktree(session) {
130
169
  const repoRoot = session.repoRoot;
131
170
  try {
132
171
  // Get current branch in worktree before removing it
133
172
  let currentBranch;
134
173
  try {
135
- currentBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
174
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
136
175
  cwd: session.path,
137
176
  encoding: "utf8",
138
- stdio: ["ignore", "pipe", "ignore"],
139
- }).trim();
177
+ });
178
+ currentBranch = stdout.trim();
140
179
  }
141
180
  catch {
142
181
  // Ignore errors getting current branch
143
182
  }
144
183
  // Remove worktree
145
- execFileSync("git", ["worktree", "remove", "--force", session.path], {
184
+ await execFileAsync("git", ["worktree", "remove", "--force", session.path], {
146
185
  cwd: repoRoot,
147
- stdio: ["ignore", "pipe", "pipe"],
148
186
  });
149
187
  // Delete original branch
150
188
  try {
151
- execFileSync("git", ["branch", "-D", session.branch], {
189
+ await execFileAsync("git", ["branch", "-D", session.branch], {
152
190
  cwd: repoRoot,
153
- stdio: ["ignore", "pipe", "pipe"],
154
191
  });
155
192
  }
156
193
  catch {
@@ -166,9 +203,8 @@ export function removeWorktree(session) {
166
203
  currentBranch !== "main" &&
167
204
  currentBranch !== "master") {
168
205
  try {
169
- execFileSync("git", ["branch", "-D", currentBranch], {
206
+ await execFileAsync("git", ["branch", "-D", currentBranch], {
170
207
  cwd: repoRoot,
171
- stdio: ["ignore", "pipe", "pipe"],
172
208
  });
173
209
  }
174
210
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "0.19.8",
3
+ "version": "1.0.0",
4
4
  "description": "CLI-based code assistant powered by AI, built with React and Ink",
5
5
  "repository": {
6
6
  "type": "git",
@@ -41,7 +41,7 @@
41
41
  "semver": "^7.7.4",
42
42
  "yargs": "^17.7.2",
43
43
  "zod": "^3.23.8",
44
- "wave-agent-sdk": "0.19.8"
44
+ "wave-agent-sdk": "1.0.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/react": "^19.1.8",
package/src/cli.tsx CHANGED
@@ -70,7 +70,7 @@ export async function startCli(options: CliOptions): Promise<void> {
70
70
  // Cleanup worktree if requested
71
71
  if (shouldRemoveWorktree && worktreeSession) {
72
72
  process.chdir(worktreeSession.repoRoot);
73
- removeWorktree(worktreeSession);
73
+ await removeWorktree(worktreeSession);
74
74
  }
75
75
 
76
76
  process.exit(0);
@@ -100,6 +100,8 @@ export const ChatInterface: React.FC = () => {
100
100
  <InputBox
101
101
  isLoading={isLoading}
102
102
  isCommandRunning={isCommandRunning}
103
+ isCompacting={isCompacting}
104
+ isGoalEvaluating={isGoalEvaluating}
103
105
  sendMessage={sendMessage}
104
106
  abortMessage={abortMessage}
105
107
  mcpServers={mcpServers}
@@ -74,11 +74,17 @@ export const HelpView: React.FC<HelpViewProps> = ({
74
74
  { key: "Ctrl+B", description: "Background current task" },
75
75
  { key: "Ctrl+V", description: "Paste image" },
76
76
  { key: "Ctrl+J", description: "Newline" },
77
+ { key: "Ctrl+A", description: "Cursor to line start" },
78
+ { key: "Ctrl+E", description: "Cursor to line end" },
79
+ { key: "Ctrl+U", description: "Delete to line start" },
80
+ { key: "Ctrl+K", description: "Delete to line end" },
81
+ { key: "Ctrl+W", description: "Delete word before cursor" },
77
82
  { key: "Shift+Tab", description: "Cycle permission mode" },
78
83
  {
79
84
  key: "Esc",
80
85
  description: "Interrupt AI or command / Cancel selector / Close help",
81
86
  },
87
+ { key: "Esc ×2", description: "Clear input (idle)" },
82
88
  ];
83
89
 
84
90
  const footerText = [
@@ -31,6 +31,8 @@ export const INPUT_PLACEHOLDER_TEXT_PREFIX = INPUT_PLACEHOLDER_TEXT.substring(
31
31
  export interface InputBoxProps {
32
32
  isLoading?: boolean;
33
33
  isCommandRunning?: boolean;
34
+ isCompacting?: boolean;
35
+ isGoalEvaluating?: boolean;
34
36
  workdir?: string;
35
37
  sendMessage?: (
36
38
  message: string,
@@ -54,6 +56,10 @@ export interface InputBoxProps {
54
56
  }
55
57
 
56
58
  export const InputBox: React.FC<InputBoxProps> = ({
59
+ isLoading,
60
+ isCommandRunning,
61
+ isCompacting,
62
+ isGoalEvaluating,
57
63
  sendMessage = () => {},
58
64
  abortMessage = () => {},
59
65
  mcpServers = [],
@@ -92,6 +98,15 @@ export const InputBox: React.FC<InputBoxProps> = ({
92
98
 
93
99
  const hasQueuedMessages = (queuedMessages?.length ?? 0) > 0;
94
100
 
101
+ // Idle means no AI work in flight. Esc double-press clear only applies when
102
+ // idle; while busy, Esc keeps its abort semantics.
103
+ const isIdle = !(
104
+ isLoading ||
105
+ isCommandRunning ||
106
+ isCompacting ||
107
+ isGoalEvaluating
108
+ );
109
+
95
110
  const onRecallQueuedMessage = useCallback(() => {
96
111
  const msg = recallQueuedMessage();
97
112
  if (msg) {
@@ -146,6 +161,8 @@ export const InputBox: React.FC<InputBoxProps> = ({
146
161
  setPermissionMode,
147
162
  // BTW state
148
163
  btwState,
164
+ // Esc double-press clear pending
165
+ escClearPending,
149
166
  // Main handler
150
167
  handleInput,
151
168
  // Manager ready state
@@ -166,6 +183,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
166
183
  workdir: workingDirectory,
167
184
  getFullMessageThread,
168
185
  hasQueuedMessages,
186
+ isIdle,
169
187
  onRecallQueuedMessage,
170
188
  });
171
189
 
@@ -342,6 +360,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
342
360
  showPluginManager ||
343
361
  showWorkflowManager || (
344
362
  <Box flexDirection="column">
363
+ {escClearPending && <Text color="gray">再次按 Esc 清空输入</Text>}
345
364
  <Box
346
365
  borderStyle="single"
347
366
  borderColor="gray"
@@ -3,6 +3,7 @@ import { Box, Text, useInput } from "ink";
3
3
  import type { Message } from "wave-agent-sdk";
4
4
  import { getMessageContent } from "wave-agent-sdk";
5
5
  import { rewindSelectorReducer } from "../reducers/rewindSelectorReducer.js";
6
+ import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
6
7
 
7
8
  export interface RewindCommandProps {
8
9
  messages: Message[];
@@ -32,10 +33,11 @@ export const RewindCommand: React.FC<RewindCommandProps> = ({
32
33
  }
33
34
  }, [getFullMessageThread]);
34
35
 
35
- // Filter user messages as checkpoints, excluding meta messages
36
+ // Filter user messages as checkpoints, excluding meta messages and
37
+ // system-generated user-role messages (task notifications, hook injections)
36
38
  const checkpoints = messages
37
39
  .map((msg, index) => ({ msg, index }))
38
- .filter(({ msg }) => msg.role === "user" && !msg.isMeta);
40
+ .filter(({ msg }) => isUserCheckpointMessage(msg));
39
41
 
40
42
  const MAX_VISIBLE_ITEMS = 3;
41
43
 
@@ -99,12 +99,9 @@ export const TaskList: React.FC = () => {
99
99
  const autoHideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
100
100
  null,
101
101
  );
102
- const [autoHidden, setAutoHidden] = React.useState(() => {
103
- // If all tasks are already completed on mount (e.g. session restore),
104
- // start hidden immediately instead of flashing for 5 seconds.
105
- const active = tasks.filter((t) => t.status !== "deleted");
106
- return active.length > 0 && active.every((t) => t.status === "completed");
107
- });
102
+ const [autoHidden, setAutoHidden] = React.useState(false);
103
+ // 是否在当前展示期间观察到过未完成任务;用于区分“任务在眼前完成”与“加载时已全部完成”
104
+ const hadIncompleteRef = React.useRef(false);
108
105
  const [, forceUpdate] = React.useState(0);
109
106
 
110
107
  const now = Date.now();
@@ -155,24 +152,45 @@ export const TaskList: React.FC = () => {
155
152
  activeTasks.length > 0 &&
156
153
  activeTasks.every((t) => t.status === "completed");
157
154
 
155
+ // 观察到任务从“存在未完成”变为“全部完成”时保留 5 秒再隐藏;
156
+ // 加载或恢复到已全部完成的会话时立即隐藏,避免先闪现再消失
158
157
  React.useEffect(() => {
159
- if (allCompleted && !autoHidden) {
158
+ if (activeTasks.length === 0) {
159
+ hadIncompleteRef.current = false;
160
+ return;
161
+ }
162
+ if (!allCompleted) {
163
+ hadIncompleteRef.current = true;
164
+ if (autoHidden) {
165
+ setAutoHidden(false);
166
+ }
167
+ return;
168
+ }
169
+ if (!hadIncompleteRef.current) {
170
+ if (!autoHidden) {
171
+ setAutoHidden(true);
172
+ }
173
+ return;
174
+ }
175
+ if (!autoHidden) {
160
176
  autoHideTimerRef.current = setTimeout(() => {
161
177
  setAutoHidden(true);
162
178
  }, AUTO_HIDE_DELAY_MS);
163
179
  }
164
- if (!allCompleted && autoHidden) {
165
- setAutoHidden(false);
166
- }
167
180
  return () => {
168
181
  if (autoHideTimerRef.current) {
169
182
  clearTimeout(autoHideTimerRef.current);
170
183
  autoHideTimerRef.current = null;
171
184
  }
172
185
  };
173
- }, [allCompleted, autoHidden]);
186
+ }, [allCompleted, autoHidden, activeTasks.length]);
174
187
 
175
- if (tasks.length === 0 || !isTaskListVisible || autoHidden) {
188
+ if (
189
+ tasks.length === 0 ||
190
+ !isTaskListVisible ||
191
+ autoHidden ||
192
+ (allCompleted && !hadIncompleteRef.current)
193
+ ) {
176
194
  return null;
177
195
  }
178
196