clawt 2.11.0 → 2.12.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.
@@ -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 交互式界面
52
- launchInteractiveClaude(worktree);
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
  /**
@@ -1,8 +1,8 @@
1
- export { CLAWT_HOME, CONFIG_PATH, LOGS_DIR, WORKTREES_DIR, VALIDATE_SNAPSHOTS_DIR } from './paths.js';
1
+ export { CLAWT_HOME, CONFIG_PATH, LOGS_DIR, WORKTREES_DIR, VALIDATE_SNAPSHOTS_DIR, CLAUDE_PROJECTS_DIR } from './paths.js';
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';
@@ -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;
@@ -15,3 +15,6 @@ export const WORKTREES_DIR = join(CLAWT_HOME, 'worktrees');
15
15
 
16
16
  /** validate 快照目录 ~/.clawt/validate-snapshots/ */
17
17
  export const VALIDATE_SNAPSHOTS_DIR = join(CLAWT_HOME, 'validate-snapshots');
18
+
19
+ /** Claude Code 项目会话目录 ~/.claude/projects/ */
20
+ export const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
@@ -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
  /** 单个配置项的完整定义(默认值 + 描述) */
@@ -1,16 +1,56 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import { ClawtError } from '../errors/index.js';
3
- import { APPEND_SYSTEM_PROMPT } from '../constants/index.js';
5
+ import { APPEND_SYSTEM_PROMPT, CLAUDE_PROJECTS_DIR } from '../constants/index.js';
4
6
  import { getConfigValue } from './config.js';
5
7
  import { printInfo, printWarning } from './formatter.js';
8
+ import { openCommandInNewTerminalTab } from './terminal.js';
6
9
  import type { WorktreeInfo } from '../types/index.js';
7
10
 
11
+ /**
12
+ * 将路径编码为 Claude Code 项目目录名
13
+ * 规则:将所有非字母数字字符替换为 -(与 Claude Code 源码中的编码逻辑一致)
14
+ * @param {string} absolutePath - 绝对路径
15
+ * @returns {string} 编码后的目录名
16
+ */
17
+ function encodeClaudeProjectPath(absolutePath: string): string {
18
+ return absolutePath.replace(/[^a-zA-Z0-9]/g, '-');
19
+ }
20
+
21
+ /**
22
+ * 检测指定 worktree 路径是否存在 Claude Code 会话历史
23
+ * 通过检查 ~/.claude/projects/<encoded-path>/ 下是否有 .jsonl 文件来判断
24
+ * @param {string} worktreePath - worktree 的绝对路径
25
+ * @returns {boolean} 是否存在会话历史
26
+ */
27
+ export function hasClaudeSessionHistory(worktreePath: string): boolean {
28
+ const encodedName = encodeClaudeProjectPath(worktreePath);
29
+ const projectDir = join(CLAUDE_PROJECTS_DIR, encodedName);
30
+
31
+ if (!existsSync(projectDir)) {
32
+ return false;
33
+ }
34
+
35
+ const entries = readdirSync(projectDir);
36
+ return entries.some((entry) => entry.endsWith('.jsonl'));
37
+ }
38
+
8
39
  /**
9
40
  * 在指定 worktree 中启动 Claude Code CLI 交互式界面
10
41
  * 使用 spawnSync + inherit stdio,让用户直接与 Claude Code 交互
11
42
  * @param {WorktreeInfo} worktree - worktree 信息
12
43
  */
13
- export function launchInteractiveClaude(worktree: WorktreeInfo): void {
44
+ /**
45
+ * @typedef {Object} LaunchClaudeOptions
46
+ * @property {boolean} [autoContinue] - 是否自动检测历史会话并续接
47
+ */
48
+ interface LaunchClaudeOptions {
49
+ /** 是否自动检测历史会话并追加 --continue 参数 */
50
+ autoContinue?: boolean;
51
+ }
52
+
53
+ export function launchInteractiveClaude(worktree: WorktreeInfo, options: LaunchClaudeOptions = {}): void {
14
54
  const commandStr = getConfigValue('claudeCodeCommand');
15
55
  const parts = commandStr.split(/\s+/).filter(Boolean);
16
56
  const cmd = parts[0];
@@ -20,10 +60,19 @@ export function launchInteractiveClaude(worktree: WorktreeInfo): void {
20
60
  APPEND_SYSTEM_PROMPT,
21
61
  ];
22
62
 
63
+ // 仅在启用 autoContinue 时检测历史会话并追加 --continue
64
+ const hasPreviousSession = options.autoContinue === true && hasClaudeSessionHistory(worktree.path);
65
+ if (hasPreviousSession) {
66
+ args.push('--continue');
67
+ }
68
+
23
69
  printInfo(`正在 worktree 中启动 Claude Code 交互式界面...`);
24
70
  printInfo(` 分支: ${worktree.branch}`);
25
71
  printInfo(` 路径: ${worktree.path}`);
26
72
  printInfo(` 指令: ${commandStr}`);
73
+ if (options.autoContinue) {
74
+ printInfo(` 模式: ${hasPreviousSession ? '继续上次对话' : '新对话'}`);
75
+ }
27
76
  printInfo('');
28
77
 
29
78
  const result = spawnSync(cmd, args, {
@@ -39,3 +88,44 @@ export function launchInteractiveClaude(worktree: WorktreeInfo): void {
39
88
  printWarning(`Claude Code 退出码: ${result.status}`);
40
89
  }
41
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 } 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
 
@@ -68,7 +68,7 @@ describe('handleResume', () => {
68
68
  expect(mockedValidateMainWorktree).toHaveBeenCalled();
69
69
  expect(mockedValidateClaudeCodeInstalled).toHaveBeenCalled();
70
70
  expect(mockedResolveTargetWorktree).toHaveBeenCalled();
71
- expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree);
71
+ expect(mockedLaunchInteractiveClaude).toHaveBeenCalledWith(worktree, { autoContinue: true });
72
72
  });
73
73
 
74
74
  it('不传 -b 时也能调用 resolveTargetWorktree', async () => {