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.
- package/dist/cli.js +1 -1
- package/dist/components/ChatInterface.js +1 -1
- package/dist/components/HelpView.js +6 -0
- package/dist/components/InputBox.d.ts +2 -0
- package/dist/components/InputBox.js +11 -2
- package/dist/components/RewindCommand.js +4 -2
- package/dist/components/TaskList.js +28 -12
- package/dist/hooks/useInputManager.d.ts +1 -0
- package/dist/hooks/useInputManager.js +23 -20
- package/dist/index.js +1 -1
- package/dist/managers/inputHandlers.js +5 -8
- package/dist/managers/inputReducer.d.ts +10 -20
- package/dist/managers/inputReducer.js +309 -173
- package/dist/print-cli.js +36 -10
- package/dist/stdio/agentBridge.d.ts +6 -0
- package/dist/stdio/agentBridge.js +141 -1
- package/dist/stdio/protocol.d.ts +1 -1
- package/dist/utils/rewindCheckpoints.d.ts +8 -0
- package/dist/utils/rewindCheckpoints.js +15 -0
- package/dist/utils/worktree.d.ts +12 -2
- package/dist/utils/worktree.js +61 -25
- package/package.json +2 -2
- package/src/cli.tsx +1 -1
- package/src/components/ChatInterface.tsx +2 -0
- package/src/components/HelpView.tsx +6 -0
- package/src/components/InputBox.tsx +19 -0
- package/src/components/RewindCommand.tsx +4 -2
- package/src/components/TaskList.tsx +30 -12
- package/src/hooks/useInputManager.ts +27 -21
- package/src/index.ts +1 -1
- package/src/managers/inputHandlers.ts +6 -10
- package/src/managers/inputReducer.ts +381 -208
- package/src/print-cli.ts +48 -11
- package/src/stdio/agentBridge.ts +210 -0
- package/src/stdio/protocol.ts +6 -1
- package/src/utils/rewindCheckpoints.ts +15 -0
- 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
|
}
|
package/dist/stdio/protocol.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/utils/worktree.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|
package/dist/utils/worktree.js
CHANGED
|
@@ -1,21 +1,58 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
115
|
+
const branchNameOnly = resolvedBaseBranch.split("/").pop();
|
|
81
116
|
try {
|
|
82
|
-
|
|
117
|
+
await execFileAsync("git", ["fetch", "origin", branchNameOnly], {
|
|
83
118
|
cwd: repoRoot,
|
|
84
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
85
119
|
});
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
136
175
|
cwd: session.path,
|
|
137
176
|
encoding: "utf8",
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
});
|
|
178
|
+
currentBranch = stdout.trim();
|
|
140
179
|
}
|
|
141
180
|
catch {
|
|
142
181
|
// Ignore errors getting current branch
|
|
143
182
|
}
|
|
144
183
|
// Remove worktree
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
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 (
|
|
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 (
|
|
188
|
+
if (
|
|
189
|
+
tasks.length === 0 ||
|
|
190
|
+
!isTaskListVisible ||
|
|
191
|
+
autoHidden ||
|
|
192
|
+
(allCompleted && !hadIncompleteRef.current)
|
|
193
|
+
) {
|
|
176
194
|
return null;
|
|
177
195
|
}
|
|
178
196
|
|