clawt 2.11.1 → 2.12.1

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.
@@ -8,7 +8,7 @@
8
8
  - run 命令对应 `5.2 批量创建 Worktree + 执行 Claude Code 任务`,流程按步骤编号描述
9
9
  - merge 命令对应 `5.6 合并验证过的分支`,-b 可选,支持模糊匹配(与 resume/validate 共享匹配逻辑),流程按步骤编号描述
10
10
  - config 命令对应 `5.10 查看和管理全局配置`,包含查看配置和 config reset 子命令两部分(使用 `####` 子标题区分)
11
- - resume 命令对应 `5.11 在已有 Worktree 中恢复会话`,支持模糊匹配和交互式分支选择(-b 可选)
11
+ - resume 命令对应 `5.11 在已有 Worktree 中恢复会话`,统一使用多选交互(resolveTargetWorktrees),选 1 个当前终端恢复,选多个在独立终端 Tab 批量恢复(-b 可选)
12
12
  - validate 命令对应 `5.4 在主 Worktree 验证其他分支`,-b 可选,支持模糊匹配(与 resume 共享匹配逻辑)
13
13
  - sync 命令对应 `5.12 将主分支代码同步到目标 Worktree`,-b 可选,支持模糊匹配(与 resume/validate/merge 共享匹配逻辑)
14
14
  - status 命令对应 `5.14 项目全局状态总览`,支持 `--json` 格式输出,展示主 worktree 状态、各 worktree 详细状态、未清理快照
@@ -35,7 +35,11 @@
35
35
  - remove 批量操作时收集错误继续处理,最后汇总报告
36
36
  - 文档中文风格,技术术语保留英文(worktree, merge, branch, SIGINT 等)
37
37
  - cleanupWorktrees 是 merge 和 run 共用的公共清理函数(在 src/utils/worktree.ts)
38
- - `launchInteractiveClaude` 是 run(交互式模式)和 resume 共用的公共函数(在 src/utils/claude.ts),启动前自动检测会话历史并追加 `--continue`
38
+ - `launchInteractiveClaude` 是 run(交互式模式)和 resume(单选模式)共用的公共函数(在 src/utils/claude.ts),启动前自动检测会话历史并追加 `--continue`
39
+ - `launchInteractiveClaudeInNewTerminal` 是 resume 批量模式使用的函数(在 src/utils/claude.ts),通过 AppleScript 在新终端 Tab 中启动
40
+ - `buildClaudeCommand` 构建完整 shell 命令字符串(在 src/utils/claude.ts),被 `launchInteractiveClaudeInNewTerminal` 调用
41
+ - `openCommandInNewTerminalTab` 终端 Tab 管理函数(在 src/utils/terminal.ts),支持 iTerm2 和 Terminal.app
42
+ - `detectTerminalApp` 终端类型检测函数(在 src/utils/terminal.ts),读取 `terminalApp` 配置项
39
43
  - `hasClaudeSessionHistory` 检测 `~/.claude/projects/<encoded-path>/` 下是否有 `.jsonl` 文件(在 src/utils/claude.ts)
40
44
  - `CLAUDE_PROJECTS_DIR` 常量(`~/.claude/projects/`)定义在 `src/constants/paths.ts`
41
45
  - killAllChildProcesses 是 run 专用的子进程终止函数(在 src/utils/shell.ts)
@@ -76,14 +80,17 @@ Notes:
76
80
  - resume 和 run(交互式模式)共用 `launchInteractiveClaude()`,该函数从 run.ts 提取到 src/utils/claude.ts
77
81
  - `claudeCodeCommand` 配置项同时影响 run 交互式模式和 resume 命令
78
82
  - reset 命令与 validate --clean 的区别:reset 不删除快照文件,validate --clean 会删除快照
79
- - `resolveTargetWorktree()` 是 resume、validate、merge 和 sync 共用的单选分支匹配函数(在 src/utils/worktree-matcher.ts)
80
- - `resolveTargetWorktrees()` 是多选分支匹配函数(在 src/utils/worktree-matcher.ts),目前被 remove 命令使用
83
+ - `resolveTargetWorktree()` 是 validate、merge 和 sync 共用的单选分支匹配函数(在 src/utils/worktree-matcher.ts)
84
+ - `resolveTargetWorktrees()` 是多选分支匹配函数(在 src/utils/worktree-matcher.ts),被 remove 和 resume 命令使用
81
85
  - `WorktreeResolveMessages` 接口用于单选命令的消息解耦,`WorktreeMultiResolveMessages` 接口用于多选命令的消息解耦
82
86
  - `promptSelectBranch()`(Enquirer.Select)用于单选交互,`promptMultiSelectBranches()`(Enquirer.MultiSelect)用于多选交互
83
- - resume 的消息常量在 `MESSAGES.RESUME_*`,validate 的消息常量在 `MESSAGES.VALIDATE_*`,merge 的消息常量在 `MESSAGES.MERGE_*`,sync 的消息常量在 `MESSAGES.SYNC_*`,status 的消息常量在 `MESSAGES.STATUS_*`,remove 的 fuzzy search 消息在 `MESSAGES.REMOVE_*`
84
- - resumevalidate、merge 和 sync 的 `-b` 参数均为可选,匹配策略一致:精确→模糊(子串,大小写不敏感)→交互单选
87
+ - resume 的消息常量在 `MESSAGES.RESUME_*`(含 RESUME_ALL_CONFIRM / RESUME_ALL_SUCCESS 等批量恢复消息),validate 的消息常量在 `MESSAGES.VALIDATE_*`,merge 的消息常量在 `MESSAGES.MERGE_*`,sync 的消息常量在 `MESSAGES.SYNC_*`,status 的消息常量在 `MESSAGES.STATUS_*`,remove 的 fuzzy search 消息在 `MESSAGES.REMOVE_*`
88
+ - resume 使用多选匹配策略:精确→模糊→交互多选(与 remove 一致),validate、merge 和 sync 使用单选匹配策略:精确→模糊→交互单选
85
89
  - remove 的 `-b` 参数可选,匹配策略:精确→模糊→交互多选;不传 `-b` 时列出所有分支供多选
86
- - validate 的交互式选择和 resume 使用同一个 `promptSelectBranch()`(Enquirer.Select);remove 使用 `promptMultiSelectBranches()`(Enquirer.MultiSelect)
90
+ - validate 的交互式选择使用 `promptSelectBranch()`(Enquirer.Select);resume 和 remove 使用 `promptMultiSelectBranches()`(Enquirer.MultiSelect)
91
+ - `promptMultiSelectBranches` 支持「全选」选项(顶部 [select-all]),通过扩展 MultiSelect 覆写 space() 实现全选 toggle
92
+ - `SELECT_ALL_NAME` 和 `SELECT_ALL_LABEL` 常量定义在 `src/constants/prompt.ts`
93
+ - `VALID_TERMINAL_APPS` 和 `ITERM2_APP_PATH` 常量定义在 `src/constants/terminal.ts`
87
94
 
88
95
  ## validate 快照机制
89
96
 
package/README.md CHANGED
@@ -19,22 +19,19 @@ npm i -g clawt
19
19
  ```bash
20
20
  # 1. 在项目根目录(包含 .git 的目录)下执行
21
21
  # 2. 并行执行 3 个任务,每个任务在独立的 worktree 中运行
22
- clawt run -b feature-auth \
23
- --tasks "实现用户登录功能" \
24
- --tasks "实现用户注册功能" \
25
- --tasks "实现密码重置功能"
22
+ clawt run -b <branch-1>
23
+ clawt run -b <branch-2>
24
+ clawt run -b <branch-3>
26
25
 
27
26
  # 3. 查看所有 worktree 状态
28
27
  clawt status
29
28
 
30
29
  # 4. 验证某个分支的变更(在主 worktree 中测试)
31
- clawt validate -b feature-auth-1
30
+ clawt validate -b branch-1
32
31
 
33
32
  # 5. 确认无误后合并到主分支
34
- clawt merge -b feature-auth-1 -m "feat: 实现用户登录功能"
33
+ clawt merge -b branch-1 -m "feat: 实现xxx功能"
35
34
 
36
- # 6. 清理不需要的 worktree
37
- clawt remove -b feature-auth
38
35
  ```
39
36
 
40
37
  ## 命令一览
@@ -80,11 +77,15 @@ clawt run -f tasks.md -b feat
80
77
 
81
78
  ```bash
82
79
  clawt resume -b <branch> # 指定分支
83
- clawt resume # 交互式选择
80
+ clawt resume # 交互式多选
84
81
  ```
85
82
 
83
+ 支持多选:选 1 个在当前终端恢复,选多个自动在独立终端 Tab 中批量恢复(仅 macOS)。
84
+
86
85
  如果目标 worktree 存在历史会话,会自动继续上次对话(`--continue`)。
87
86
 
87
+ > **注意:** 使用 Terminal.app 批量恢复时,需要在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用。iTerm2 无需额外授权。终端类型可通过配置项 `terminalApp` 指定。
88
+
88
89
  ### `clawt create` — 仅创建 worktree(不执行任务)
89
90
 
90
91
  ```bash
@@ -159,6 +160,8 @@ clawt config reset # 恢复默认配置
159
160
  | `claudeCodeCommand` | `"claude"` | Claude Code CLI 启动命令 |
160
161
  | `autoPullPush` | `false` | merge 后自动 pull/push |
161
162
  | `confirmDestructiveOps` | `true` | 破坏性操作前确认 |
163
+ | `maxConcurrency` | `0` | run 命令最大并发数,`0` 为不限制 |
164
+ | `terminalApp` | `"auto"` | 批量 resume 使用的终端:`auto` / `iterm2` / `terminal` |
162
165
 
163
166
  ## 全局选项
164
167
 
package/dist/index.js CHANGED
@@ -119,7 +119,7 @@ var MERGE_MESSAGES = {
119
119
  /** merge 后清理 worktree 和分支成功 */
120
120
  WORKTREE_CLEANED: (branch) => `\u2713 \u5DF2\u6E05\u7406 worktree \u548C\u5206\u652F: ${branch}`,
121
121
  /** 目标 worktree 有未提交修改但未指定 -m */
122
- TARGET_WORKTREE_DIRTY_NO_MESSAGE: "\u76EE\u6807 worktree \u6709\u672A\u63D0\u4EA4\u7684\u4FEE\u6539\uFF0C\u8BF7\u901A\u8FC7 -m \u53C2\u6570\u63D0\u4F9B\u63D0\u4EA4\u4FE1\u606F",
122
+ TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath) => `${worktreePath} \u6709\u672A\u63D0\u4EA4\u7684\u4FEE\u6539\uFF0C\u8BF7\u901A\u8FC7 -m \u53C2\u6570\u63D0\u4F9B\u63D0\u4EA4\u4FE1\u606F`,
123
123
  /** 目标 worktree 既干净又无本地提交 */
124
124
  TARGET_WORKTREE_NO_CHANGES: "\u76EE\u6807 worktree \u6CA1\u6709\u4EFB\u4F55\u53EF\u5408\u5E76\u7684\u53D8\u66F4\uFF08\u5DE5\u4F5C\u533A\u5E72\u51C0\u4E14\u65E0\u672C\u5730\u63D0\u4EA4\uFF09",
125
125
  /** merge 命令检测到 validate 状态的提示 */
@@ -209,10 +209,18 @@ var RESUME_MESSAGES = {
209
209
  RESUME_NO_MATCH: (name, branches) => `\u672A\u627E\u5230\u4E0E "${name}" \u5339\u914D\u7684\u5206\u652F
210
210
  \u53EF\u7528\u5206\u652F\uFF1A
211
211
  ${branches.map((b) => ` - ${b}`).join("\n")}`,
212
- /** resume 交互选择提示 */
213
- RESUME_SELECT_BRANCH: "\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\u5206\u652F",
214
- /** resume 模糊匹配到多个结果提示 */
215
- RESUME_MULTIPLE_MATCHES: (name) => `"${name}" \u5339\u914D\u5230\u591A\u4E2A\u5206\u652F\uFF0C\u8BF7\u9009\u62E9\uFF1A`
212
+ /** resume 多选交互提示 */
213
+ RESUME_SELECT_BRANCH: "\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\u5206\u652F\uFF08\u7A7A\u683C\u9009\u62E9\uFF0C\u56DE\u8F66\u786E\u8BA4\uFF09",
214
+ /** resume 模糊匹配到多个结果的多选提示 */
215
+ RESUME_MULTIPLE_MATCHES: (keyword) => `"${keyword}" \u5339\u914D\u5230\u591A\u4E2A\u5206\u652F\uFF0C\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\uFF1A`,
216
+ /** 批量 resume 确认提示 */
217
+ RESUME_ALL_CONFIRM: (count) => `\u5373\u5C06\u5728 ${count} \u4E2A\u72EC\u7ACB\u7EC8\u7AEF Tab \u4E2D\u6062\u590D Claude Code \u4F1A\u8BDD\uFF0C\u662F\u5426\u7EE7\u7EED\uFF1F`,
218
+ /** 批量 resume 完成提示 */
219
+ RESUME_ALL_SUCCESS: (count) => `\u5DF2\u5728 ${count} \u4E2A\u7EC8\u7AEF Tab \u4E2D\u542F\u52A8 Claude Code \u4F1A\u8BDD`,
220
+ /** 批量 resume 非 macOS 平台提示 */
221
+ RESUME_ALL_PLATFORM_UNSUPPORTED: "\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09",
222
+ /** 批量 resume 无匹配分支提示 */
223
+ RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F`
216
224
  };
217
225
 
218
226
  // src/constants/messages/remove.ts
@@ -297,6 +305,10 @@ var EXIT_CODES = {
297
305
  ARGUMENT_ERROR: 2
298
306
  };
299
307
 
308
+ // src/constants/terminal.ts
309
+ var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal"];
310
+ var ITERM2_APP_PATH = "/Applications/iTerm.app";
311
+
300
312
  // src/constants/config.ts
301
313
  var APPEND_SYSTEM_PROMPT = "After the code execution is completed, it is prohibited to build the project for verification.";
302
314
  var CONFIG_DEFINITIONS = {
@@ -319,6 +331,10 @@ var CONFIG_DEFINITIONS = {
319
331
  maxConcurrency: {
320
332
  defaultValue: 0,
321
333
  description: "run \u547D\u4EE4\u9ED8\u8BA4\u6700\u5927\u5E76\u53D1\u6570\uFF0C0 \u8868\u793A\u4E0D\u9650\u5236"
334
+ },
335
+ terminalApp: {
336
+ defaultValue: "auto",
337
+ description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\uFF08macOS\uFF09"
322
338
  }
323
339
  };
324
340
  function deriveDefaultConfig(definitions) {
@@ -368,6 +384,10 @@ var TASK_STATUS_LABELS = {
368
384
  FAILED: "\u5931\u8D25"
369
385
  };
370
386
 
387
+ // src/constants/prompt.ts
388
+ var SELECT_ALL_NAME = "__select_all__";
389
+ var SELECT_ALL_LABEL = "[select-all]";
390
+
371
391
  // src/errors/index.ts
372
392
  var ClawtError = class extends Error {
373
393
  /** 退出码 */
@@ -905,15 +925,88 @@ import Enquirer from "enquirer";
905
925
 
906
926
  // src/utils/claude.ts
907
927
  import { spawnSync } from "child_process";
908
- import { existsSync as existsSync5, readdirSync as readdirSync3 } from "fs";
928
+ import { existsSync as existsSync6, readdirSync as readdirSync3 } from "fs";
909
929
  import { join as join3 } from "path";
930
+
931
+ // src/utils/terminal.ts
932
+ import { execFileSync as execFileSync2 } from "child_process";
933
+ import { existsSync as existsSync5 } from "fs";
934
+ function isITerm2Installed() {
935
+ return existsSync5(ITERM2_APP_PATH);
936
+ }
937
+ function detectTerminalApp() {
938
+ const configured = getConfigValue("terminalApp");
939
+ if (configured === "iterm2" || configured === "terminal") {
940
+ return configured;
941
+ }
942
+ if (!VALID_TERMINAL_APPS.includes(configured)) {
943
+ logger.warn(`terminalApp \u914D\u7F6E\u503C "${configured}" \u65E0\u6548\uFF0C\u6709\u6548\u503C: ${VALID_TERMINAL_APPS.join(", ")}\uFF0C\u5C06\u4F7F\u7528\u81EA\u52A8\u68C0\u6D4B`);
944
+ }
945
+ if (isITerm2Installed()) {
946
+ return "iterm2";
947
+ }
948
+ return "terminal";
949
+ }
950
+ function escapeAppleScriptString(str) {
951
+ return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
952
+ }
953
+ function buildTerminalAppleScript(command, title) {
954
+ const escapedCommand = escapeAppleScriptString(command);
955
+ const escapedTitle = escapeAppleScriptString(title);
956
+ return `
957
+ tell application "Terminal"
958
+ activate
959
+ tell application "System Events" to tell process "Terminal" to keystroke "t" using command down
960
+ delay 0.3
961
+ do script "${escapedCommand}" in front window's selected tab
962
+ set custom title of front window's selected tab to "${escapedTitle}"
963
+ end tell
964
+ `.trim();
965
+ }
966
+ function buildITermAppleScript(command, title) {
967
+ const escapedCommand = escapeAppleScriptString(command);
968
+ const escapedTitle = escapeAppleScriptString(title);
969
+ return `
970
+ tell application "iTerm"
971
+ activate
972
+ tell current window
973
+ create tab with default profile
974
+ tell current session
975
+ set name to "${escapedTitle}"
976
+ write text "${escapedCommand}"
977
+ end tell
978
+ end tell
979
+ end tell
980
+ `.trim();
981
+ }
982
+ function openCommandInNewTerminalTab(command, tabTitle) {
983
+ if (process.platform !== "darwin") {
984
+ throw new ClawtError("\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09");
985
+ }
986
+ const terminalApp = detectTerminalApp();
987
+ const script = terminalApp === "iterm2" ? buildITermAppleScript(command, tabTitle) : buildTerminalAppleScript(command, tabTitle);
988
+ logger.debug(`\u6253\u5F00\u7EC8\u7AEF Tab [${terminalApp}]: ${tabTitle}`);
989
+ logger.debug(`\u6267\u884C\u547D\u4EE4: ${command}`);
990
+ try {
991
+ execFileSync2("osascript", ["-e", script], {
992
+ encoding: "utf-8",
993
+ stdio: ["pipe", "pipe", "pipe"]
994
+ });
995
+ } catch (error) {
996
+ const message = error instanceof Error ? error.message : String(error);
997
+ const accessibilityHint = terminalApp === "terminal" ? "\n\u63D0\u793A\uFF1ATerminal.app \u9700\u8981\u8F85\u52A9\u529F\u80FD\u6743\u9650\uFF0C\u8BF7\u5728\u300C\u7CFB\u7EDF\u8BBE\u7F6E \u2192 \u9690\u79C1\u4E0E\u5B89\u5168\u6027 \u2192 \u8F85\u52A9\u529F\u80FD\u300D\u4E2D\u6388\u6743\u7EC8\u7AEF\u5E94\u7528" : "";
998
+ throw new ClawtError(`\u6253\u5F00\u7EC8\u7AEF Tab \u5931\u8D25: ${message}${accessibilityHint}`);
999
+ }
1000
+ }
1001
+
1002
+ // src/utils/claude.ts
910
1003
  function encodeClaudeProjectPath(absolutePath) {
911
1004
  return absolutePath.replace(/[^a-zA-Z0-9]/g, "-");
912
1005
  }
913
1006
  function hasClaudeSessionHistory(worktreePath) {
914
1007
  const encodedName = encodeClaudeProjectPath(worktreePath);
915
1008
  const projectDir = join3(CLAUDE_PROJECTS_DIR, encodedName);
916
- if (!existsSync5(projectDir)) {
1009
+ if (!existsSync6(projectDir)) {
917
1010
  return false;
918
1011
  }
919
1012
  const entries = readdirSync3(projectDir);
@@ -951,10 +1044,26 @@ function launchInteractiveClaude(worktree, options = {}) {
951
1044
  printWarning(`Claude Code \u9000\u51FA\u7801: ${result.status}`);
952
1045
  }
953
1046
  }
1047
+ function escapeShellSingleQuote(str) {
1048
+ return str.replace(/'/g, "'\\''");
1049
+ }
1050
+ function buildClaudeCommand(worktree, hasPreviousSession) {
1051
+ const commandStr = getConfigValue("claudeCodeCommand");
1052
+ const escapedPath = escapeShellSingleQuote(worktree.path);
1053
+ const escapedPrompt = escapeShellSingleQuote(APPEND_SYSTEM_PROMPT);
1054
+ const continueFlag = hasPreviousSession ? " --continue" : "";
1055
+ return `cd '${escapedPath}' && ${commandStr} --append-system-prompt '${escapedPrompt}'${continueFlag}`;
1056
+ }
1057
+ function launchInteractiveClaudeInNewTerminal(worktree, hasPreviousSession) {
1058
+ const command = buildClaudeCommand(worktree, hasPreviousSession);
1059
+ const modeLabel = hasPreviousSession ? "\u7EE7\u7EED" : "\u65B0\u5BF9\u8BDD";
1060
+ const tabTitle = `clawt: ${worktree.branch}`;
1061
+ openCommandInNewTerminalTab(command, tabTitle);
1062
+ }
954
1063
 
955
1064
  // src/utils/validate-snapshot.ts
956
1065
  import { join as join4 } from "path";
957
- import { existsSync as existsSync6, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, readdirSync as readdirSync4, rmdirSync as rmdirSync2 } from "fs";
1066
+ import { existsSync as existsSync7, readFileSync as readFileSync2, writeFileSync as writeFileSync2, unlinkSync, readdirSync as readdirSync4, rmdirSync as rmdirSync2 } from "fs";
958
1067
  function getSnapshotPath(projectName, branchName) {
959
1068
  return join4(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.tree`);
960
1069
  }
@@ -962,14 +1071,14 @@ function getSnapshotHeadPath(projectName, branchName) {
962
1071
  return join4(VALIDATE_SNAPSHOTS_DIR, projectName, `${branchName}.head`);
963
1072
  }
964
1073
  function hasSnapshot(projectName, branchName) {
965
- return existsSync6(getSnapshotPath(projectName, branchName));
1074
+ return existsSync7(getSnapshotPath(projectName, branchName));
966
1075
  }
967
1076
  function readSnapshot(projectName, branchName) {
968
1077
  const snapshotPath = getSnapshotPath(projectName, branchName);
969
1078
  const headPath = getSnapshotHeadPath(projectName, branchName);
970
1079
  logger.debug(`\u8BFB\u53D6 validate \u5FEB\u7167: ${snapshotPath}`);
971
- const treeHash = existsSync6(snapshotPath) ? readFileSync2(snapshotPath, "utf-8").trim() : "";
972
- const headCommitHash = existsSync6(headPath) ? readFileSync2(headPath, "utf-8").trim() : "";
1080
+ const treeHash = existsSync7(snapshotPath) ? readFileSync2(snapshotPath, "utf-8").trim() : "";
1081
+ const headCommitHash = existsSync7(headPath) ? readFileSync2(headPath, "utf-8").trim() : "";
973
1082
  return { treeHash, headCommitHash };
974
1083
  }
975
1084
  function writeSnapshot(projectName, branchName, treeHash, headCommitHash) {
@@ -984,18 +1093,18 @@ function writeSnapshot(projectName, branchName, treeHash, headCommitHash) {
984
1093
  function removeSnapshot(projectName, branchName) {
985
1094
  const snapshotPath = getSnapshotPath(projectName, branchName);
986
1095
  const headPath = getSnapshotHeadPath(projectName, branchName);
987
- if (existsSync6(snapshotPath)) {
1096
+ if (existsSync7(snapshotPath)) {
988
1097
  unlinkSync(snapshotPath);
989
1098
  logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${snapshotPath}`);
990
1099
  }
991
- if (existsSync6(headPath)) {
1100
+ if (existsSync7(headPath)) {
992
1101
  unlinkSync(headPath);
993
1102
  logger.info(`\u5DF2\u5220\u9664 validate \u5FEB\u7167: ${headPath}`);
994
1103
  }
995
1104
  }
996
1105
  function getProjectSnapshotBranches(projectName) {
997
1106
  const projectDir = join4(VALIDATE_SNAPSHOTS_DIR, projectName);
998
- if (!existsSync6(projectDir)) {
1107
+ if (!existsSync7(projectDir)) {
999
1108
  return [];
1000
1109
  }
1001
1110
  const files = readdirSync4(projectDir);
@@ -1003,7 +1112,7 @@ function getProjectSnapshotBranches(projectName) {
1003
1112
  }
1004
1113
  function removeProjectSnapshots(projectName) {
1005
1114
  const projectDir = join4(VALIDATE_SNAPSHOTS_DIR, projectName);
1006
- if (!existsSync6(projectDir)) {
1115
+ if (!existsSync7(projectDir)) {
1007
1116
  return;
1008
1117
  }
1009
1118
  const files = readdirSync4(projectDir);
@@ -1037,12 +1146,37 @@ async function promptSelectBranch(worktrees, message) {
1037
1146
  return worktrees.find((wt) => wt.branch === selectedBranch);
1038
1147
  }
1039
1148
  async function promptMultiSelectBranches(worktrees, message) {
1040
- const selectedBranches = await new Enquirer2.MultiSelect({
1149
+ const branchChoices = worktrees.map((wt) => ({
1150
+ name: wt.branch,
1151
+ message: wt.branch
1152
+ }));
1153
+ const choices = [
1154
+ { name: SELECT_ALL_NAME, message: SELECT_ALL_LABEL },
1155
+ ...branchChoices
1156
+ ];
1157
+ const MultiSelect = Enquirer2.MultiSelect;
1158
+ class MultiSelectWithSelectAll extends MultiSelect {
1159
+ space() {
1160
+ if (!this.focused) return;
1161
+ if (this.focused.name === SELECT_ALL_NAME) {
1162
+ const willEnable = !this.focused.enabled;
1163
+ for (const ch of this.choices) {
1164
+ ch.enabled = willEnable;
1165
+ }
1166
+ return this.render();
1167
+ }
1168
+ this.toggle(this.focused);
1169
+ const selectAllChoice = this.choices.find((ch) => ch.name === SELECT_ALL_NAME);
1170
+ const branchItems = this.choices.filter((ch) => ch.name !== SELECT_ALL_NAME);
1171
+ if (selectAllChoice) {
1172
+ selectAllChoice.enabled = branchItems.every((ch) => ch.enabled);
1173
+ }
1174
+ return this.render();
1175
+ }
1176
+ }
1177
+ const selectedBranches = await new MultiSelectWithSelectAll({
1041
1178
  message,
1042
- choices: worktrees.map((wt) => ({
1043
- name: wt.branch,
1044
- message: wt.branch
1045
- })),
1179
+ choices,
1046
1180
  // 使用空心圆/实心圆作为选中指示符
1047
1181
  symbols: {
1048
1182
  indicator: { on: "\u25CF", off: "\u25CB" }
@@ -1305,7 +1439,7 @@ var ProgressRenderer = class {
1305
1439
 
1306
1440
  // src/utils/task-file.ts
1307
1441
  import { resolve } from "path";
1308
- import { existsSync as existsSync7, readFileSync as readFileSync3 } from "fs";
1442
+ import { existsSync as existsSync8, readFileSync as readFileSync3 } from "fs";
1309
1443
  var TASK_BLOCK_REGEX = /<!-- CLAWT-TASKS:START -->([\s\S]*?)<!-- CLAWT-TASKS:END -->/g;
1310
1444
  var BRANCH_LINE_REGEX = /^#\s*branch:\s*(.+)$/;
1311
1445
  function parseTaskFile(content, options) {
@@ -1343,7 +1477,7 @@ function parseTaskFile(content, options) {
1343
1477
  }
1344
1478
  function loadTaskFile(filePath, options) {
1345
1479
  const absolutePath = resolve(filePath);
1346
- if (!existsSync7(absolutePath)) {
1480
+ if (!existsSync8(absolutePath)) {
1347
1481
  throw new ClawtError(MESSAGES.TASK_FILE_NOT_FOUND(absolutePath));
1348
1482
  }
1349
1483
  const content = readFileSync3(absolutePath, "utf-8");
@@ -1783,10 +1917,44 @@ function registerResumeCommand(program2) {
1783
1917
  async function handleResume(options) {
1784
1918
  validateMainWorktree();
1785
1919
  validateClaudeCodeInstalled();
1786
- logger.info(`resume \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F: ${options.branch ?? "(\u672A\u6307\u5B9A)"}`);
1920
+ logger.info(`resume \u547D\u4EE4\u6267\u884C\uFF0C\u5206\u652F\u8FC7\u6EE4: ${options.branch ?? "(\u65E0)"}`);
1787
1921
  const worktrees = getProjectWorktrees();
1788
- const worktree = await resolveTargetWorktree(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
1789
- launchInteractiveClaude(worktree, { autoContinue: true });
1922
+ const targetWorktrees = await resolveTargetWorktrees(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
1923
+ if (targetWorktrees.length === 0) {
1924
+ return;
1925
+ }
1926
+ if (targetWorktrees.length === 1) {
1927
+ launchInteractiveClaude(targetWorktrees[0], { autoContinue: true });
1928
+ } else {
1929
+ await handleBatchResume(targetWorktrees);
1930
+ }
1931
+ }
1932
+ function printBatchResumePreview(worktrees, sessionMap) {
1933
+ printInfo("\u5373\u5C06\u6062\u590D\u7684\u5206\u652F\uFF1A");
1934
+ for (const wt of worktrees) {
1935
+ const modeLabel = sessionMap.get(wt.path) ? "\u7EE7\u7EED\u4E0A\u6B21\u5BF9\u8BDD" : "\u65B0\u5BF9\u8BDD";
1936
+ printInfo(` - ${wt.branch} (${modeLabel})`);
1937
+ }
1938
+ printInfo("");
1939
+ }
1940
+ function buildSessionMap(worktrees) {
1941
+ const map = /* @__PURE__ */ new Map();
1942
+ for (const wt of worktrees) {
1943
+ map.set(wt.path, hasClaudeSessionHistory(wt.path));
1944
+ }
1945
+ return map;
1946
+ }
1947
+ async function handleBatchResume(worktrees) {
1948
+ const sessionMap = buildSessionMap(worktrees);
1949
+ printBatchResumePreview(worktrees, sessionMap);
1950
+ const confirmed = await confirmAction(MESSAGES.RESUME_ALL_CONFIRM(worktrees.length));
1951
+ if (!confirmed) {
1952
+ return;
1953
+ }
1954
+ for (const wt of worktrees) {
1955
+ launchInteractiveClaudeInNewTerminal(wt, sessionMap.get(wt.path) ?? false);
1956
+ }
1957
+ printSuccess(MESSAGES.RESUME_ALL_SUCCESS(worktrees.length));
1790
1958
  }
1791
1959
 
1792
1960
  // src/commands/validate.ts
@@ -2037,7 +2205,7 @@ async function handleMerge(options) {
2037
2205
  const targetClean = isWorkingDirClean(targetWorktreePath);
2038
2206
  if (!targetClean) {
2039
2207
  if (!options.message) {
2040
- throw new ClawtError(MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE);
2208
+ throw new ClawtError(MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE(targetWorktreePath));
2041
2209
  }
2042
2210
  gitAddAll(targetWorktreePath);
2043
2211
  gitCommit(options.message, targetWorktreePath);
@@ -111,7 +111,7 @@ var MERGE_MESSAGES = {
111
111
  /** merge 后清理 worktree 和分支成功 */
112
112
  WORKTREE_CLEANED: (branch) => `\u2713 \u5DF2\u6E05\u7406 worktree \u548C\u5206\u652F: ${branch}`,
113
113
  /** 目标 worktree 有未提交修改但未指定 -m */
114
- TARGET_WORKTREE_DIRTY_NO_MESSAGE: "\u76EE\u6807 worktree \u6709\u672A\u63D0\u4EA4\u7684\u4FEE\u6539\uFF0C\u8BF7\u901A\u8FC7 -m \u53C2\u6570\u63D0\u4F9B\u63D0\u4EA4\u4FE1\u606F",
114
+ TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath) => `${worktreePath} \u6709\u672A\u63D0\u4EA4\u7684\u4FEE\u6539\uFF0C\u8BF7\u901A\u8FC7 -m \u53C2\u6570\u63D0\u4F9B\u63D0\u4EA4\u4FE1\u606F`,
115
115
  /** 目标 worktree 既干净又无本地提交 */
116
116
  TARGET_WORKTREE_NO_CHANGES: "\u76EE\u6807 worktree \u6CA1\u6709\u4EFB\u4F55\u53EF\u5408\u5E76\u7684\u53D8\u66F4\uFF08\u5DE5\u4F5C\u533A\u5E72\u51C0\u4E14\u65E0\u672C\u5730\u63D0\u4EA4\uFF09",
117
117
  /** merge 命令检测到 validate 状态的提示 */
@@ -201,10 +201,18 @@ var RESUME_MESSAGES = {
201
201
  RESUME_NO_MATCH: (name, branches) => `\u672A\u627E\u5230\u4E0E "${name}" \u5339\u914D\u7684\u5206\u652F
202
202
  \u53EF\u7528\u5206\u652F\uFF1A
203
203
  ${branches.map((b) => ` - ${b}`).join("\n")}`,
204
- /** resume 交互选择提示 */
205
- RESUME_SELECT_BRANCH: "\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\u5206\u652F",
206
- /** resume 模糊匹配到多个结果提示 */
207
- RESUME_MULTIPLE_MATCHES: (name) => `"${name}" \u5339\u914D\u5230\u591A\u4E2A\u5206\u652F\uFF0C\u8BF7\u9009\u62E9\uFF1A`
204
+ /** resume 多选交互提示 */
205
+ RESUME_SELECT_BRANCH: "\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\u5206\u652F\uFF08\u7A7A\u683C\u9009\u62E9\uFF0C\u56DE\u8F66\u786E\u8BA4\uFF09",
206
+ /** resume 模糊匹配到多个结果的多选提示 */
207
+ RESUME_MULTIPLE_MATCHES: (keyword) => `"${keyword}" \u5339\u914D\u5230\u591A\u4E2A\u5206\u652F\uFF0C\u8BF7\u9009\u62E9\u8981\u6062\u590D\u7684\uFF1A`,
208
+ /** 批量 resume 确认提示 */
209
+ RESUME_ALL_CONFIRM: (count) => `\u5373\u5C06\u5728 ${count} \u4E2A\u72EC\u7ACB\u7EC8\u7AEF Tab \u4E2D\u6062\u590D Claude Code \u4F1A\u8BDD\uFF0C\u662F\u5426\u7EE7\u7EED\uFF1F`,
210
+ /** 批量 resume 完成提示 */
211
+ RESUME_ALL_SUCCESS: (count) => `\u5DF2\u5728 ${count} \u4E2A\u7EC8\u7AEF Tab \u4E2D\u542F\u52A8 Claude Code \u4F1A\u8BDD`,
212
+ /** 批量 resume 非 macOS 平台提示 */
213
+ RESUME_ALL_PLATFORM_UNSUPPORTED: "\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09",
214
+ /** 批量 resume 无匹配分支提示 */
215
+ RESUME_ALL_NO_MATCH: (keyword) => `\u672A\u627E\u5230\u4E0E "${keyword}" \u5339\u914D\u7684\u5206\u652F`
208
216
  };
209
217
 
210
218
  // src/constants/messages/remove.ts
@@ -300,6 +308,10 @@ var CONFIG_DEFINITIONS = {
300
308
  maxConcurrency: {
301
309
  defaultValue: 0,
302
310
  description: "run \u547D\u4EE4\u9ED8\u8BA4\u6700\u5927\u5E76\u53D1\u6570\uFF0C0 \u8868\u793A\u4E0D\u9650\u5236"
311
+ },
312
+ terminalApp: {
313
+ defaultValue: "auto",
314
+ description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\uFF08macOS\uFF09"
303
315
  }
304
316
  };
305
317
  function deriveDefaultConfig(definitions) {
package/docs/spec.md CHANGED
@@ -175,7 +175,7 @@ git show-ref --verify refs/heads/<branchName> 2>/dev/null
175
175
  | `clawt list` | 列出当前项目所有 worktree(支持 `--json` 格式输出) | 5.8 |
176
176
  | `clawt config` | 查看全局配置 | 5.10 |
177
177
  | `clawt config reset` | 将配置恢复为默认值 | 5.10 |
178
- | `clawt resume` | 在已有 worktree 中恢复 Claude Code 交互式会话 | 5.11 |
178
+ | `clawt resume` | 在已有 worktree 中恢复 Claude Code 会话(支持多选批量恢复) | 5.11 |
179
179
  | `clawt sync` | 将主分支最新代码同步到目标 worktree | 5.12 |
180
180
  | `clawt reset` | 重置主 worktree 工作区和暂存区 | 5.13 |
181
181
  | `clawt status` | 显示项目全局状态总览(支持 `--json` 格式输出) | 5.14 |
@@ -751,7 +751,7 @@ clawt merge [-m <commitMessage>]
751
751
  5. **根据目标 worktree 状态决定是否需要提交**
752
752
  - 检测目标 worktree 工作区是否干净(`git status --porcelain`)
753
753
  - **工作区有未提交修改**:
754
- - 如果用户未提供 `-m`,提示 `目标 worktree 有未提交的修改,请通过 -m 参数提供提交信息`,退出
754
+ - 如果用户未提供 `-m`,提示 `<worktreePath> 有未提交的修改,请通过 -m 参数提供提交信息`(其中 `<worktreePath>` 为目标 worktree 的完整路径),退出
755
755
  - 提供了 `-m` → 执行提交:
756
756
  ```bash
757
757
  cd ~/.clawt/worktrees/<project>/<branchName>
@@ -840,7 +840,9 @@ clawt merge [-m <commitMessage>]
840
840
  "autoDeleteBranch": false,
841
841
  "claudeCodeCommand": "claude",
842
842
  "autoPullPush": false,
843
- "confirmDestructiveOps": true
843
+ "confirmDestructiveOps": true,
844
+ "maxConcurrency": 0,
845
+ "terminalApp": "auto"
844
846
  }
845
847
  ```
846
848
 
@@ -852,6 +854,8 @@ clawt merge [-m <commitMessage>]
852
854
  | `claudeCodeCommand` | `string` | `"claude"` | Claude Code CLI 启动指令,用于 `clawt run` 不传 `--tasks` 时和 `clawt resume` 在 worktree 中打开交互式界面 |
853
855
  | `autoPullPush` | `boolean` | `false` | merge 成功后是否自动执行 git pull 和 git push |
854
856
  | `confirmDestructiveOps` | `boolean` | `true` | 执行破坏性操作(reset、validate --clean、config reset)前是否提示确认 |
857
+ | `maxConcurrency` | `number` | `0` | run 命令默认最大并发数,`0` 表示不限制 |
858
+ | `terminalApp` | `string` | `"auto"` | 批量 resume 使用的终端应用:`auto`(自动检测)、`iterm2`、`terminal`(macOS) |
855
859
 
856
860
  ---
857
861
 
@@ -1054,7 +1058,7 @@ clawt config reset
1054
1058
  # 指定分支名(支持模糊匹配)
1055
1059
  clawt resume -b <branchName>
1056
1060
 
1057
- # 不指定分支名(列出所有分支供选择)
1061
+ # 不指定分支名(列出所有分支供多选)
1058
1062
  clawt resume
1059
1063
  ```
1060
1064
 
@@ -1062,29 +1066,54 @@ clawt resume
1062
1066
 
1063
1067
  | 参数 | 必填 | 说明 |
1064
1068
  | ---- | ---- | ----------------------------------------------------- |
1065
- | `-b` | 否 | 要恢复的分支名(支持模糊匹配,不传则列出所有分支供选择) |
1069
+ | `-b` | 否 | 要恢复的分支名(支持模糊匹配,不传则列出所有分支供多选) |
1066
1070
 
1067
1071
  **使用场景:**
1068
1072
 
1069
- 当用户之前通过 `clawt run` 或 `clawt create` 创建了 worktree 但会话已结束,希望在该 worktree 中重新打开 Claude Code 交互式界面继续工作。
1073
+ 当用户之前通过 `clawt run` 或 `clawt create` 创建了 worktree 但会话已结束,希望在该 worktree 中重新打开 Claude Code 交互式界面继续工作。支持一次选中多个分支,自动在独立终端 Tab 中批量恢复。
1070
1074
 
1071
1075
  **运行流程:**
1072
1076
 
1073
1077
  1. **主 worktree 校验** (2.1)
1074
1078
  2. **Claude Code CLI 校验**:确认 `claude` CLI 可用
1075
- 3. **解析目标 worktree**:根据 `-b` 参数解析目标 worktree,匹配策略如下:
1079
+ 3. **解析目标 worktree(多选模式)**:统一使用 `resolveTargetWorktrees`(多选版本)解析目标 worktree,匹配策略如下:
1076
1080
  - **未传 `-b` 参数**:
1077
1081
  - 获取当前项目所有 worktree
1078
1082
  - 无可用 worktree → 报错退出
1079
1083
  - 仅 1 个 worktree → 直接使用,无需选择
1080
- - 多个 worktree → 通过交互式列表(Enquirer.Select)让用户选择
1084
+ - 多个 worktree → 通过交互式多选列表(Enquirer.MultiSelect)让用户选择(空格选择,回车确认),顶部提供「全选」选项
1081
1085
  - **传了 `-b` 参数**:
1082
1086
  1. **精确匹配优先**:在 worktree 列表中查找分支名完全相同的 worktree,找到则直接使用
1083
1087
  2. **模糊匹配**(子串匹配,大小写不敏感):
1084
1088
  - 唯一匹配 → 直接使用
1085
- - 多个匹配 → 通过交互式列表让用户从匹配结果中选择
1089
+ - 多个匹配 → 通过交互式多选列表让用户从匹配结果中选择
1086
1090
  3. **无匹配** → 报错退出,并列出所有可用分支名
1087
- 4. **启动 Claude Code 交互式界面**:通过 `launchInteractiveClaude()` 在目标 worktree 中启动 Claude Code CLI 交互式界面(使用 `spawnSync` + `inherit stdio`)
1091
+ 4. **根据选中数量自动分发**:
1092
+ - **用户未选择任何分支** → 直接退出
1093
+ - **选中 1 个** → 在当前终端恢复(同原有行为),通过 `launchInteractiveClaude()` 启动(使用 `spawnSync` + `inherit stdio`)
1094
+ - **选中多个** → 进入批量恢复流程(见下文)
1095
+
1096
+ **批量恢复流程:**
1097
+
1098
+ 1. **计算会话状态**:一次性遍历所有选中的 worktree,通过 `hasClaudeSessionHistory()` 检测是否存在历史会话,构建 sessionMap 避免重复 I/O
1099
+ 2. **输出预览**:列出即将恢复的分支及其会话状态("继续上次对话"或"新对话")
1100
+ 3. **用户确认**:提示即将在 N 个独立终端 Tab 中恢复会话,等待用户确认
1101
+ 4. **逐个在新终端 Tab 中启动**:通过 `launchInteractiveClaudeInNewTerminal()` 构建 shell 命令并通过 AppleScript 在新终端 Tab 中执行
1102
+ 5. **输出完成提示**
1103
+
1104
+ **终端 Tab 管理:**
1105
+
1106
+ 批量恢复通过 `openCommandInNewTerminalTab()`(`src/utils/terminal.ts`)在新终端 Tab 中启动 Claude Code。终端类型由配置项 `terminalApp` 控制:
1107
+
1108
+ | 配置值 | 行为 |
1109
+ | ---------- | ------------------------------------------------------------ |
1110
+ | `auto` | 自动检测:优先检测 iTerm2 是否已安装(`/Applications/iTerm.app`),已安装则使用 iTerm2,否则降级到 Terminal.app |
1111
+ | `iterm2` | 强制使用 iTerm2 |
1112
+ | `terminal` | 强制使用 Terminal.app |
1113
+
1114
+ **平台限制:** 批量恢复目前仅支持 macOS 平台(通过 AppleScript 打开终端 Tab)。非 macOS 平台会抛出错误。
1115
+
1116
+ **权限要求:** Terminal.app 通过 System Events 模拟键盘操作(`Cmd+T`)新建 Tab,需要在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用。iTerm2 使用原生 AppleScript 接口,无需辅助功能权限。
1088
1117
 
1089
1118
  启动命令通过配置项 `claudeCodeCommand`(默认值 `claude`)指定,与 `clawt run` 不传 `--tasks` 时的交互式界面行为一致。
1090
1119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "2.11.1",
3
+ "version": "2.12.1",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -165,7 +165,7 @@ async function handleMerge(options: MergeOptions): Promise<void> {
165
165
  if (!targetClean) {
166
166
  // 目标 worktree 有未提交修改,必须提供 -m
167
167
  if (!options.message) {
168
- throw new ClawtError(MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE);
168
+ throw new ClawtError(MESSAGES.TARGET_WORKTREE_DIRTY_NO_MESSAGE(targetWorktreePath));
169
169
  }
170
170
  gitAddAll(targetWorktreePath);
171
171
  gitCommit(options.message, targetWorktreePath);
@@ -2,17 +2,23 @@ import type { Command } from 'commander';
2
2
  import { logger } from '../logger/index.js';
3
3
  import { MESSAGES } from '../constants/index.js';
4
4
  import type { ResumeOptions } from '../types/index.js';
5
+ import type { WorktreeInfo } from '../types/index.js';
5
6
  import {
6
7
  validateMainWorktree,
7
8
  validateClaudeCodeInstalled,
8
9
  getProjectWorktrees,
9
10
  launchInteractiveClaude,
10
- resolveTargetWorktree,
11
+ launchInteractiveClaudeInNewTerminal,
12
+ hasClaudeSessionHistory,
13
+ resolveTargetWorktrees,
14
+ printInfo,
15
+ printSuccess,
16
+ confirmAction,
11
17
  } from '../utils/index.js';
12
- import type { WorktreeResolveMessages } from '../utils/index.js';
18
+ import type { WorktreeMultiResolveMessages } from '../utils/index.js';
13
19
 
14
- /** resume 命令的分支解析消息配置 */
15
- const RESUME_RESOLVE_MESSAGES: WorktreeResolveMessages = {
20
+ /** resume 命令的多选分支解析消息配置 */
21
+ const RESUME_RESOLVE_MESSAGES: WorktreeMultiResolveMessages = {
16
22
  noWorktrees: MESSAGES.RESUME_NO_WORKTREES,
17
23
  selectBranch: MESSAGES.RESUME_SELECT_BRANCH,
18
24
  multipleMatches: MESSAGES.RESUME_MULTIPLE_MATCHES,
@@ -35,19 +41,83 @@ export function registerResumeCommand(program: Command): void {
35
41
 
36
42
  /**
37
43
  * 执行 resume 命令的核心逻辑
38
- * 解析目标 worktree 并恢复 Claude Code 会话
44
+ * 统一走多选交互,根据选中数量自动分发:选 1 个在当前终端恢复,选多个在独立终端 Tab 中批量恢复
39
45
  * @param {ResumeOptions} options - 命令选项
40
46
  */
41
47
  async function handleResume(options: ResumeOptions): Promise<void> {
42
48
  validateMainWorktree();
43
49
  validateClaudeCodeInstalled();
44
50
 
45
- logger.info(`resume 命令执行,分支: ${options.branch ?? '(未指定)'}`);
46
-
47
- // 解析目标 worktree(精确匹配 / 模糊匹配 / 交互选择)
51
+ logger.info(`resume 命令执行,分支过滤: ${options.branch ?? '()'}`);
48
52
  const worktrees = getProjectWorktrees();
49
- const worktree = await resolveTargetWorktree(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
50
53
 
51
- // 启动 Claude Code 交互式界面(resume 自动续接历史会话)
52
- launchInteractiveClaude(worktree, { autoContinue: true });
54
+ // 统一走多选解析(精确匹配 / 模糊匹配 / 交互多选)
55
+ const targetWorktrees = await resolveTargetWorktrees(worktrees, RESUME_RESOLVE_MESSAGES, options.branch);
56
+
57
+ // 用户未选择任何分支时直接退出
58
+ if (targetWorktrees.length === 0) {
59
+ return;
60
+ }
61
+
62
+ if (targetWorktrees.length === 1) {
63
+ // 选中 1 个 → 当前终端恢复(resume 自动续接历史会话)
64
+ launchInteractiveClaude(targetWorktrees[0], { autoContinue: true });
65
+ } else {
66
+ // 选中多个 → 逐个在新终端 Tab 中启动
67
+ await handleBatchResume(targetWorktrees);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 输出即将恢复的分支列表(含会话状态:继续/新对话)
73
+ * @param {WorktreeInfo[]} worktrees - 待恢复的 worktree 列表
74
+ * @param {Map<string, boolean>} sessionMap - worktree 路径 → 是否存在历史会话的映射
75
+ */
76
+ function printBatchResumePreview(worktrees: WorktreeInfo[], sessionMap: Map<string, boolean>): void {
77
+ printInfo('即将恢复的分支:');
78
+ for (const wt of worktrees) {
79
+ const modeLabel = sessionMap.get(wt.path) ? '继续上次对话' : '新对话';
80
+ printInfo(` - ${wt.branch} (${modeLabel})`);
81
+ }
82
+ printInfo('');
83
+ }
84
+
85
+ /**
86
+ * 批量计算 worktree 的会话历史状态
87
+ * 一次性遍历所有 worktree,避免后续流程中重复调用 hasClaudeSessionHistory 产生多余 I/O
88
+ * @param {WorktreeInfo[]} worktrees - worktree 列表
89
+ * @returns {Map<string, boolean>} worktree 路径 → 是否存在历史会话的映射
90
+ */
91
+ function buildSessionMap(worktrees: WorktreeInfo[]): Map<string, boolean> {
92
+ const map = new Map<string, boolean>();
93
+ for (const wt of worktrees) {
94
+ map.set(wt.path, hasClaudeSessionHistory(wt.path));
95
+ }
96
+ return map;
97
+ }
98
+
99
+ /**
100
+ * 批量恢复多个 worktree 的 Claude Code 会话
101
+ * 逐个在新终端 Tab 中启动
102
+ * @param {WorktreeInfo[]} worktrees - 待恢复的 worktree 列表
103
+ */
104
+ async function handleBatchResume(worktrees: WorktreeInfo[]): Promise<void> {
105
+ // 一次性计算所有 worktree 的会话状态,后续传递使用避免重复 I/O
106
+ const sessionMap = buildSessionMap(worktrees);
107
+
108
+ // 输出即将恢复的分支列表
109
+ printBatchResumePreview(worktrees, sessionMap);
110
+
111
+ // 确认操作
112
+ const confirmed = await confirmAction(MESSAGES.RESUME_ALL_CONFIRM(worktrees.length));
113
+ if (!confirmed) {
114
+ return;
115
+ }
116
+
117
+ // 逐个在新终端 Tab 中启动 Claude Code
118
+ for (const wt of worktrees) {
119
+ launchInteractiveClaudeInNewTerminal(wt, sessionMap.get(wt.path) ?? false);
120
+ }
121
+
122
+ printSuccess(MESSAGES.RESUME_ALL_SUCCESS(worktrees.length));
53
123
  }
@@ -29,6 +29,10 @@ export const CONFIG_DEFINITIONS: ConfigDefinitions = {
29
29
  defaultValue: 0,
30
30
  description: 'run 命令默认最大并发数,0 表示不限制',
31
31
  },
32
+ terminalApp: {
33
+ defaultValue: 'auto',
34
+ description: '批量 resume 使用的终端应用:auto(自动检测)、iterm2、terminal(macOS)',
35
+ },
32
36
  };
33
37
 
34
38
  /**
@@ -2,7 +2,7 @@ export { CLAWT_HOME, CONFIG_PATH, LOGS_DIR, WORKTREES_DIR, VALIDATE_SNAPSHOTS_DI
2
2
  export { INVALID_BRANCH_CHARS } from './branch.js';
3
3
  export { MESSAGES } from './messages/index.js';
4
4
  export { EXIT_CODES } from './exitCodes.js';
5
- export { ENABLE_BRACKETED_PASTE, DISABLE_BRACKETED_PASTE, PASTE_THRESHOLD_MS } from './terminal.js';
5
+ export { ENABLE_BRACKETED_PASTE, DISABLE_BRACKETED_PASTE, PASTE_THRESHOLD_MS, VALID_TERMINAL_APPS, ITERM2_APP_PATH } from './terminal.js';
6
6
  export { DEFAULT_CONFIG, CONFIG_DESCRIPTIONS, APPEND_SYSTEM_PROMPT } from './config.js';
7
7
  export { AUTO_SAVE_COMMIT_MESSAGE } from './git.js';
8
8
  export { DEBUG_LOG_PREFIX, DEBUG_TIMESTAMP_FORMAT } from './logger.js';
@@ -16,3 +16,4 @@ export {
16
16
  TASK_STATUS_ICONS,
17
17
  TASK_STATUS_LABELS,
18
18
  } from './progress.js';
19
+ export { SELECT_ALL_NAME, SELECT_ALL_LABEL } from './prompt.js';
@@ -11,7 +11,8 @@ export const MERGE_MESSAGES = {
11
11
  /** merge 后清理 worktree 和分支成功 */
12
12
  WORKTREE_CLEANED: (branch: string) => `✓ 已清理 worktree 和分支: ${branch}`,
13
13
  /** 目标 worktree 有未提交修改但未指定 -m */
14
- TARGET_WORKTREE_DIRTY_NO_MESSAGE: '目标 worktree 有未提交的修改,请通过 -m 参数提供提交信息',
14
+ TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath: string) =>
15
+ `${worktreePath} 有未提交的修改,请通过 -m 参数提供提交信息`,
15
16
  /** 目标 worktree 既干净又无本地提交 */
16
17
  TARGET_WORKTREE_NO_CHANGES: '目标 worktree 没有任何可合并的变更(工作区干净且无本地提交)',
17
18
  /** merge 命令检测到 validate 状态的提示 */
@@ -5,8 +5,17 @@ export const RESUME_MESSAGES = {
5
5
  /** resume 模糊匹配无结果,列出可用分支 */
6
6
  RESUME_NO_MATCH: (name: string, branches: string[]) =>
7
7
  `未找到与 "${name}" 匹配的分支\n 可用分支:\n${branches.map((b) => ` - ${b}`).join('\n')}`,
8
- /** resume 交互选择提示 */
9
- RESUME_SELECT_BRANCH: '请选择要恢复的分支',
10
- /** resume 模糊匹配到多个结果提示 */
11
- RESUME_MULTIPLE_MATCHES: (name: string) => `"${name}" 匹配到多个分支,请选择:`,
8
+ /** resume 多选交互提示 */
9
+ RESUME_SELECT_BRANCH: '请选择要恢复的分支(空格选择,回车确认)',
10
+ /** resume 模糊匹配到多个结果的多选提示 */
11
+ RESUME_MULTIPLE_MATCHES: (keyword: string) => `"${keyword}" 匹配到多个分支,请选择要恢复的:`,
12
+
13
+ /** 批量 resume 确认提示 */
14
+ RESUME_ALL_CONFIRM: (count: number) => `即将在 ${count} 个独立终端 Tab 中恢复 Claude Code 会话,是否继续?`,
15
+ /** 批量 resume 完成提示 */
16
+ RESUME_ALL_SUCCESS: (count: number) => `已在 ${count} 个终端 Tab 中启动 Claude Code 会话`,
17
+ /** 批量 resume 非 macOS 平台提示 */
18
+ RESUME_ALL_PLATFORM_UNSUPPORTED: '批量 resume 目前仅支持 macOS 平台(通过 AppleScript 打开终端 Tab)',
19
+ /** 批量 resume 无匹配分支提示 */
20
+ RESUME_ALL_NO_MATCH: (keyword: string) => `未找到与 "${keyword}" 匹配的分支`,
12
21
  } as const;
@@ -45,7 +45,8 @@ export const MESSAGES = {
45
45
  /** 请提供提交信息 */
46
46
  COMMIT_MESSAGE_REQUIRED: '请提供提交信息(-m 参数)',
47
47
  /** 目标 worktree 有未提交修改但未指定 -m */
48
- TARGET_WORKTREE_DIRTY_NO_MESSAGE: '目标 worktree 有未提交的修改,请通过 -m 参数提供提交信息',
48
+ TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath: string) =>
49
+ `${worktreePath} 有未提交的修改,请通过 -m 参数提供提交信息`,
49
50
  /** 目标 worktree 既干净又无本地提交 */
50
51
  TARGET_WORKTREE_NO_CHANGES: '目标 worktree 没有任何可合并的变更(工作区干净且无本地提交)',
51
52
  /** 检测到用户中断 */
@@ -0,0 +1,5 @@
1
+ /** 多选列表中全选选项的标识名称 */
2
+ export const SELECT_ALL_NAME = '__select_all__';
3
+
4
+ /** 多选列表中全选选项的显示文本 */
5
+ export const SELECT_ALL_LABEL = '[select-all]';
@@ -11,3 +11,9 @@ export const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
11
11
  * 作为 Bracketed Paste Mode 不可用时的降级方案
12
12
  */
13
13
  export const PASTE_THRESHOLD_MS = 10;
14
+
15
+ /** 配置项 terminalApp 允许的有效值 */
16
+ export const VALID_TERMINAL_APPS: readonly string[] = ['auto', 'iterm2', 'terminal'];
17
+
18
+ /** iTerm2 应用路径,用于 auto 模式检测是否已安装 */
19
+ export const ITERM2_APP_PATH = '/Applications/iTerm.app';
@@ -10,6 +10,8 @@ export interface ClawtConfig {
10
10
  confirmDestructiveOps: boolean;
11
11
  /** run 命令默认最大并发数,0 表示不限制 */
12
12
  maxConcurrency: number;
13
+ /** 批量 resume 使用的终端应用:'auto'(自动检测)、'iterm2'、'terminal'(macOS) */
14
+ terminalApp: string;
13
15
  }
14
16
 
15
17
  /** 单个配置项的完整定义(默认值 + 描述) */
@@ -5,6 +5,7 @@ import { ClawtError } from '../errors/index.js';
5
5
  import { APPEND_SYSTEM_PROMPT, CLAUDE_PROJECTS_DIR } from '../constants/index.js';
6
6
  import { getConfigValue } from './config.js';
7
7
  import { printInfo, printWarning } from './formatter.js';
8
+ import { openCommandInNewTerminalTab } from './terminal.js';
8
9
  import type { WorktreeInfo } from '../types/index.js';
9
10
 
10
11
  /**
@@ -87,3 +88,44 @@ export function launchInteractiveClaude(worktree: WorktreeInfo, options: LaunchC
87
88
  printWarning(`Claude Code 退出码: ${result.status}`);
88
89
  }
89
90
  }
91
+
92
+ /**
93
+ * 转义 shell 单引号
94
+ * 将字符串中的单引号替换为 '\'' 以安全嵌入单引号包裹的 shell 字符串
95
+ * @param {string} str - 原始字符串
96
+ * @returns {string} 转义后的字符串
97
+ */
98
+ function escapeShellSingleQuote(str: string): string {
99
+ return str.replace(/'/g, "'\\''");
100
+ }
101
+
102
+ /**
103
+ * 构建在指定 worktree 中启动 Claude Code 的完整 shell 命令
104
+ * 生成格式:cd <path> && <claudeCommand> --append-system-prompt '...' [--continue]
105
+ * @param {WorktreeInfo} worktree - worktree 信息
106
+ * @param {boolean} hasPreviousSession - 是否存在历史会话(由调用方预计算,避免重复 I/O)
107
+ * @returns {string} 完整的 shell 命令字符串
108
+ */
109
+ export function buildClaudeCommand(worktree: WorktreeInfo, hasPreviousSession: boolean): string {
110
+ const commandStr = getConfigValue('claudeCodeCommand');
111
+
112
+ const escapedPath = escapeShellSingleQuote(worktree.path);
113
+ const escapedPrompt = escapeShellSingleQuote(APPEND_SYSTEM_PROMPT);
114
+ const continueFlag = hasPreviousSession ? ' --continue' : '';
115
+
116
+ return `cd '${escapedPath}' && ${commandStr} --append-system-prompt '${escapedPrompt}'${continueFlag}`;
117
+ }
118
+
119
+ /**
120
+ * 在新终端 Tab 中启动 Claude Code 交互式会话
121
+ * 通过 AppleScript 打开独立终端 Tab,支持 Terminal.app 和 iTerm2
122
+ * @param {WorktreeInfo} worktree - worktree 信息
123
+ * @param {boolean} hasPreviousSession - 是否存在历史会话(由调用方预计算,避免重复 I/O)
124
+ */
125
+ export function launchInteractiveClaudeInNewTerminal(worktree: WorktreeInfo, hasPreviousSession: boolean): void {
126
+ const command = buildClaudeCommand(worktree, hasPreviousSession);
127
+ const modeLabel = hasPreviousSession ? '继续' : '新对话';
128
+ const tabTitle = `clawt: ${worktree.branch}`;
129
+
130
+ openCommandInNewTerminalTab(command, tabTitle);
131
+ }
@@ -52,11 +52,12 @@ export { loadConfig, writeDefaultConfig, getConfigValue, ensureClawtDirs } from
52
52
  export { printSuccess, printError, printWarning, printInfo, printSeparator, printDoubleSeparator, confirmAction, confirmDestructiveAction, formatWorktreeStatus, isWorktreeIdle, formatDuration } from './formatter.js';
53
53
  export { ensureDir, removeEmptyDir } from './fs.js';
54
54
  export { multilineInput } from './prompt.js';
55
- export { launchInteractiveClaude, hasClaudeSessionHistory } from './claude.js';
55
+ export { launchInteractiveClaude, hasClaudeSessionHistory, launchInteractiveClaudeInNewTerminal } from './claude.js';
56
56
  export { getSnapshotPath, hasSnapshot, readSnapshotTreeHash, readSnapshot, writeSnapshot, removeSnapshot, removeProjectSnapshots, getProjectSnapshotBranches } from './validate-snapshot.js';
57
57
  export { findExactMatch, findFuzzyMatches, promptSelectBranch, promptMultiSelectBranches, resolveTargetWorktree, resolveTargetWorktrees } from './worktree-matcher.js';
58
58
  export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './worktree-matcher.js';
59
59
  export { ProgressRenderer } from './progress.js';
60
60
  export { parseTaskFile, loadTaskFile } from './task-file.js';
61
61
  export { executeBatchTasks } from './task-executor.js';
62
+ export { detectTerminalApp, openCommandInNewTerminalTab } from './terminal.js';
62
63
 
@@ -0,0 +1,134 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { ClawtError } from '../errors/index.js';
4
+ import { logger } from '../logger/index.js';
5
+ import { VALID_TERMINAL_APPS, ITERM2_APP_PATH } from '../constants/index.js';
6
+ import { getConfigValue } from './config.js';
7
+
8
+ /** 终端应用类型 */
9
+ type TerminalApp = 'iterm2' | 'terminal';
10
+
11
+ /**
12
+ * 检测系统是否安装了 iTerm2
13
+ * 通过检查 /Applications/iTerm.app 是否存在来判断
14
+ * @returns {boolean} 是否安装了 iTerm2
15
+ */
16
+ function isITerm2Installed(): boolean {
17
+ return existsSync(ITERM2_APP_PATH);
18
+ }
19
+
20
+ /**
21
+ * 检测当前使用的终端应用
22
+ * 优先读取配置项 terminalApp;值为 'auto' 时优先检测 iTerm2 是否已安装,
23
+ * 已安装则使用 iTerm2,否则降级到 Terminal.app
24
+ * @returns {TerminalApp} 终端类型:'iterm2' 或 'terminal'
25
+ */
26
+ export function detectTerminalApp(): TerminalApp {
27
+ const configured = getConfigValue('terminalApp');
28
+
29
+ // 配置了明确的终端类型,直接使用
30
+ if (configured === 'iterm2' || configured === 'terminal') {
31
+ return configured;
32
+ }
33
+
34
+ // 配置值无效时给出警告(auto 除外)
35
+ if (!VALID_TERMINAL_APPS.includes(configured)) {
36
+ logger.warn(`terminalApp 配置值 "${configured}" 无效,有效值: ${VALID_TERMINAL_APPS.join(', ')},将使用自动检测`);
37
+ }
38
+
39
+ // auto 模式:优先检测 iTerm2 是否已安装
40
+ if (isITerm2Installed()) {
41
+ return 'iterm2';
42
+ }
43
+ return 'terminal';
44
+ }
45
+
46
+ /**
47
+ * 转义 AppleScript 字符串中的特殊字符
48
+ * 将反斜杠和双引号进行转义,防止注入
49
+ * @param {string} str - 原始字符串
50
+ * @returns {string} 转义后的字符串
51
+ */
52
+ function escapeAppleScriptString(str: string): string {
53
+ return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
54
+ }
55
+
56
+ /**
57
+ * 构建 Terminal.app 的 AppleScript 脚本
58
+ * 在当前窗口新建 Tab 并执行命令
59
+ * @param {string} command - 要执行的 shell 命令
60
+ * @param {string} title - Tab 标题
61
+ * @returns {string} AppleScript 脚本内容
62
+ */
63
+ function buildTerminalAppleScript(command: string, title: string): string {
64
+ const escapedCommand = escapeAppleScriptString(command);
65
+ const escapedTitle = escapeAppleScriptString(title);
66
+ return `
67
+ tell application "Terminal"
68
+ activate
69
+ tell application "System Events" to tell process "Terminal" to keystroke "t" using command down
70
+ delay 0.3
71
+ do script "${escapedCommand}" in front window's selected tab
72
+ set custom title of front window's selected tab to "${escapedTitle}"
73
+ end tell
74
+ `.trim();
75
+ }
76
+
77
+ /**
78
+ * 构建 iTerm2 的 AppleScript 脚本
79
+ * 在当前窗口新建 Tab 并执行命令
80
+ * @param {string} command - 要执行的 shell 命令
81
+ * @param {string} title - Tab 标题
82
+ * @returns {string} AppleScript 脚本内容
83
+ */
84
+ function buildITermAppleScript(command: string, title: string): string {
85
+ const escapedCommand = escapeAppleScriptString(command);
86
+ const escapedTitle = escapeAppleScriptString(title);
87
+ return `
88
+ tell application "iTerm"
89
+ activate
90
+ tell current window
91
+ create tab with default profile
92
+ tell current session
93
+ set name to "${escapedTitle}"
94
+ write text "${escapedCommand}"
95
+ end tell
96
+ end tell
97
+ end tell
98
+ `.trim();
99
+ }
100
+
101
+ /**
102
+ * 在新终端 Tab 中执行命令
103
+ * 自动检测终端类型(iTerm2 / Terminal.app),通过 AppleScript 打开新 Tab
104
+ * @param {string} command - 要执行的 shell 命令
105
+ * @param {string} tabTitle - Tab 标题
106
+ * @throws {ClawtError} 非 macOS 平台或 AppleScript 执行失败时抛出
107
+ */
108
+ export function openCommandInNewTerminalTab(command: string, tabTitle: string): void {
109
+ if (process.platform !== 'darwin') {
110
+ throw new ClawtError('批量 resume 目前仅支持 macOS 平台(通过 AppleScript 打开终端 Tab)');
111
+ }
112
+
113
+ const terminalApp = detectTerminalApp();
114
+ const script = terminalApp === 'iterm2'
115
+ ? buildITermAppleScript(command, tabTitle)
116
+ : buildTerminalAppleScript(command, tabTitle);
117
+
118
+ logger.debug(`打开终端 Tab [${terminalApp}]: ${tabTitle}`);
119
+ logger.debug(`执行命令: ${command}`);
120
+
121
+ try {
122
+ execFileSync('osascript', ['-e', script], {
123
+ encoding: 'utf-8',
124
+ stdio: ['pipe', 'pipe', 'pipe'],
125
+ });
126
+ } catch (error) {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ // Terminal.app 通过 System Events 模拟键盘操作需要辅助功能权限
129
+ const accessibilityHint = terminalApp === 'terminal'
130
+ ? '\n提示:Terminal.app 需要辅助功能权限,请在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用'
131
+ : '';
132
+ throw new ClawtError(`打开终端 Tab 失败: ${message}${accessibilityHint}`);
133
+ }
134
+ }
@@ -1,7 +1,27 @@
1
1
  import Enquirer from 'enquirer';
2
2
  import { ClawtError } from '../errors/index.js';
3
+ import { SELECT_ALL_NAME, SELECT_ALL_LABEL } from '../constants/index.js';
3
4
  import type { WorktreeInfo } from '../types/index.js';
4
5
 
6
+ /** enquirer MultiSelect 选项条目的运行时结构 */
7
+ interface MultiSelectChoice {
8
+ name: string;
9
+ message: string;
10
+ enabled: boolean;
11
+ }
12
+
13
+ /**
14
+ * enquirer MultiSelect 实例的运行时接口
15
+ * enquirer 类型声明未导出 MultiSelect,手动声明以消除 TypeScript 类型错误
16
+ */
17
+ interface MultiSelectInstance {
18
+ focused: MultiSelectChoice | undefined;
19
+ choices: MultiSelectChoice[];
20
+ render(): void;
21
+ toggle(choice: MultiSelectChoice): void;
22
+ run(): Promise<string[]>;
23
+ }
24
+
5
25
  /**
6
26
  * 分支解析时使用的消息文案配置
7
27
  * 通过此接口实现命令间的消息解耦,不同命令可传入各自的提示文案
@@ -74,25 +94,68 @@ export async function promptSelectBranch(worktrees: WorktreeInfo[], message: str
74
94
 
75
95
  /**
76
96
  * 通过交互式多选列表让用户从 worktree 列表中选择多个分支
97
+ * 顶部提供「全选」选项,点击可切换全选/全不选
77
98
  * 用户可通过空格键选择/取消,回车键确认
78
99
  * @param {WorktreeInfo[]} worktrees - 可供选择的 worktree 列表
79
100
  * @param {string} message - 选择提示信息
80
101
  * @returns {Promise<WorktreeInfo[]>} 用户选择的 worktree 列表
81
102
  */
82
103
  export async function promptMultiSelectBranches(worktrees: WorktreeInfo[], message: string): Promise<WorktreeInfo[]> {
104
+ // 构建 choices 列表,顶部插入全选选项
105
+ const branchChoices = worktrees.map((wt) => ({
106
+ name: wt.branch,
107
+ message: wt.branch,
108
+ }));
109
+
110
+ const choices = [
111
+ { name: SELECT_ALL_NAME, message: SELECT_ALL_LABEL },
112
+ ...branchChoices,
113
+ ];
114
+
83
115
  // @ts-expect-error enquirer 类型声明未导出 MultiSelect 类,但运行时存在
84
- const selectedBranches: string[] = await new Enquirer.MultiSelect({
116
+ const MultiSelect: new (options: Record<string, unknown>) => MultiSelectInstance = Enquirer.MultiSelect;
117
+
118
+ /**
119
+ * 扩展 MultiSelect,覆写 space() 方法实现全选 toggle
120
+ * 当焦点在「全选」选项上按空格时,切换所有分支选项的选中状态
121
+ */
122
+ class MultiSelectWithSelectAll extends MultiSelect {
123
+ space(this: MultiSelectInstance) {
124
+ if (!this.focused) return;
125
+
126
+ if (this.focused.name === SELECT_ALL_NAME) {
127
+ // 切换全选:如果全选项当前未选中则全选,否则全不选
128
+ const willEnable = !this.focused.enabled;
129
+ for (const ch of this.choices) {
130
+ ch.enabled = willEnable;
131
+ }
132
+ return this.render();
133
+ }
134
+
135
+ // 非全选选项:执行默认的 toggle 行为
136
+ this.toggle(this.focused);
137
+
138
+ // 同步全选选项状态:所有分支选项都选中时自动勾选全选,否则取消
139
+ const selectAllChoice = this.choices.find((ch) => ch.name === SELECT_ALL_NAME);
140
+ const branchItems = this.choices.filter((ch) => ch.name !== SELECT_ALL_NAME);
141
+ if (selectAllChoice) {
142
+ selectAllChoice.enabled = branchItems.every((ch) => ch.enabled);
143
+ }
144
+
145
+ return this.render();
146
+ }
147
+ }
148
+
149
+ const selectedBranches: string[] = await new MultiSelectWithSelectAll({
85
150
  message,
86
- choices: worktrees.map((wt) => ({
87
- name: wt.branch,
88
- message: wt.branch,
89
- })),
151
+ choices,
90
152
  // 使用空心圆/实心圆作为选中指示符
91
153
  symbols: {
92
154
  indicator: { on: '●', off: '○' },
93
155
  },
94
156
  }).run();
95
157
 
158
+ // 过滤掉全选选项,只返回实际的 worktree
96
159
  return worktrees.filter((wt) => selectedBranches.includes(wt.branch));
97
160
  }
98
161
 
@@ -26,7 +26,8 @@ vi.mock('../../../src/constants/index.js', () => ({
26
26
  MERGE_SQUASH_PENDING: (path: string, branch: string) => `请手动提交: ${path}`,
27
27
  MERGE_VALIDATE_STATE_HINT: (branch: string) => `分支 ${branch} 存在 validate 状态`,
28
28
  MAIN_WORKTREE_DIRTY: '主 worktree 有未提交的更改',
29
- TARGET_WORKTREE_DIRTY_NO_MESSAGE: '目标 worktree 有未提交修改,请提供 -m 参数',
29
+ TARGET_WORKTREE_DIRTY_NO_MESSAGE: (worktreePath: string) =>
30
+ `${worktreePath} 有未提交修改,请提供 -m 参数`,
30
31
  TARGET_WORKTREE_NO_CHANGES: '没有可合并的变更',
31
32
  MERGE_CONFLICT: '合并冲突',
32
33
  PULL_CONFLICT: 'pull 冲突',