clawt 3.9.5 → 3.9.7

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/index.js CHANGED
@@ -574,8 +574,10 @@ var TASKS_CMD_MESSAGES = {
574
574
  TASK_INIT_FILE_EXISTS: (path2) => `\u6587\u4EF6\u5DF2\u5B58\u5728: ${path2}\uFF0C\u5982\u9700\u8986\u76D6\u8BF7\u5148\u5220\u9664`,
575
575
  /** 任务模板生成成功 */
576
576
  TASK_INIT_SUCCESS: (path2) => `\u2713 \u4EFB\u52A1\u6A21\u677F\u5DF2\u751F\u6210: ${path2}`,
577
- /** 任务模板使用提示 */
578
- TASK_INIT_HINT: (path2) => `\u4F7F\u7528 clawt run -f ${path2} \u6267\u884C\u4EFB\u52A1`
577
+ /** 任务模板使用提示(分行列出 run 和 resume 两种用法) */
578
+ TASK_INIT_HINT: (path2) => `\u6267\u884C\u4EFB\u52A1:
579
+ clawt run -f ${path2} # \u521B\u5EFA worktree \u5E76\u6267\u884C\uFF08\u5206\u652F\u540D\u9700\u4E0D\u5B58\u5728\uFF09
580
+ clawt resume -f ${path2} # \u5728\u5DF2\u6709 worktree \u4E2D\u8FFD\u95EE\uFF08\u5206\u652F\u540D\u9700\u5DF2\u5B58\u5728\uFF09`
579
581
  };
580
582
 
581
583
  // src/constants/messages/post-create.ts
@@ -804,7 +806,7 @@ var GIT_INDEX_LOCK_RETRY = {
804
806
  /** 重试次数(用户反馈"重试一下就可以了",单次重试足够) */
805
807
  MAX_RETRIES: 1,
806
808
  /** 重试延迟毫秒数(让锁文件有时间被释放) */
807
- DELAY_MS: 150
809
+ DELAY_MS: 1e3
808
810
  };
809
811
 
810
812
  // src/constants/logger.ts
@@ -912,7 +914,7 @@ var TASK_TEMPLATE_CONTENT = `# Clawt \u4EFB\u52A1\u6587\u4EF6
912
914
  # \u683C\u5F0F\u8BF4\u660E: \u6807\u7B7E\u5916\u7684\u6587\u672C\u4F1A\u88AB\u5FFD\u7565\uFF0C\u6BCF\u4E2A\u4EFB\u52A1\u7528 START/END \u6807\u7B7E\u5305\u88F9
913
915
  #
914
916
  # \u89C4\u5219:
915
- # 1. \u6BCF\u4E2A\u4EFB\u52A1\u5757\u7528 <!-- CLAWT-TASKS:START --> \u548C <!-- CLAWT-TASKS:END --> \u5305\u88F9
917
+ # 1. \u6BCF\u4E2A\u4EFB\u52A1\u5757\u7528 <START> \u548C <END> \u6807\u7B7E\u5305\u88F9\uFF08\u5B9E\u9645\u6807\u7B7E\u89C1\u4E0B\u65B9\u793A\u4F8B\uFF09
916
918
  # 2. \u5757\u5185 # branch: <\u5206\u652F\u540D> \u58F0\u660E\u5206\u652F\u540D\uFF08\u4F7F\u7528 -b \u53C2\u6570\u65F6\u53EF\u7701\u7565\uFF09
917
919
  # 3. \u5757\u5185\u5176\u4F59\u884C\u4E3A\u4EFB\u52A1\u63CF\u8FF0\uFF08\u652F\u6301\u591A\u884C\uFF09
918
920
 
@@ -1001,7 +1003,8 @@ function enableConsoleTransport() {
1001
1003
  }
1002
1004
 
1003
1005
  // src/utils/shell.ts
1004
- import { execSync as execSync2, execFileSync, spawn, spawnSync } from "child_process";
1006
+ import { exec, execSync as execSync2, execFileSync, spawn, spawnSync } from "child_process";
1007
+ import { promisify } from "util";
1005
1008
 
1006
1009
  // src/utils/git-lock.ts
1007
1010
  import { join as join2, isAbsolute } from "path";
@@ -1067,6 +1070,7 @@ function waitForGitIndexLockRetrySync() {
1067
1070
  }
1068
1071
 
1069
1072
  // src/utils/shell.ts
1073
+ var execPromise = promisify(exec);
1070
1074
  function getEnvWithoutNestedSessionFlag() {
1071
1075
  const { CLAUDECODE: _, ...env } = process.env;
1072
1076
  return { ...env, CLAUDE_CODE_ENTRYPOINT: CLAUDE_CODE_ENTRYPOINT_VALUE };
@@ -1095,6 +1099,15 @@ function execCommand(command, options) {
1095
1099
  }
1096
1100
  }
1097
1101
  }
1102
+ async function execCommandAsync(command, options) {
1103
+ logger.debug(`\u6267\u884C\u5F02\u6B65\u547D\u4EE4: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
1104
+ const { stdout } = await execPromise(command, {
1105
+ cwd: options?.cwd,
1106
+ encoding: "utf-8",
1107
+ maxBuffer: EXEC_MAX_BUFFER
1108
+ });
1109
+ return stdout.trim();
1110
+ }
1098
1111
  function spawnProcess(command, args, options) {
1099
1112
  logger.debug(`\u542F\u52A8\u5B50\u8FDB\u7A0B: ${command} ${args.join(" ")}${options?.cwd ? ` (cwd: ${options.cwd})` : ""}`);
1100
1113
  return spawn(command, args, {
@@ -1262,6 +1275,9 @@ function getProjectName(cwd) {
1262
1275
  function getStatusPorcelain(cwd) {
1263
1276
  return execCommand("git status --porcelain", { cwd });
1264
1277
  }
1278
+ async function getStatusPorcelainAsync(cwd) {
1279
+ return execCommandAsync("git status --porcelain", { cwd });
1280
+ }
1265
1281
  function isWorkingDirClean(cwd) {
1266
1282
  return getStatusPorcelain(cwd) === "";
1267
1283
  }
@@ -1316,6 +1332,10 @@ function getDiffStat(worktreePath) {
1316
1332
  const output = execCommand("git diff --shortstat HEAD", { cwd: worktreePath });
1317
1333
  return parseShortStat(output);
1318
1334
  }
1335
+ async function getDiffStatAsync(worktreePath) {
1336
+ const output = await execCommandAsync("git diff --shortstat HEAD", { cwd: worktreePath });
1337
+ return parseShortStat(output);
1338
+ }
1319
1339
  function gitApplyCachedFromStdin(patchContent, cwd) {
1320
1340
  execCommandWithInput("git", ["apply", "--cached"], { input: patchContent, cwd });
1321
1341
  }
@@ -1423,12 +1443,16 @@ function getCommitCountAhead(branchName, cwd) {
1423
1443
  const output = execCommand(`git rev-list --count HEAD..${branchName}`, { cwd });
1424
1444
  return parseInt(output, 10) || 0;
1425
1445
  }
1426
- function getCommitCountBehind(branchName, cwd) {
1446
+ function parseDivergenceOutput(output) {
1447
+ const [leftStr, rightStr] = output.trim().split(/\s+/);
1448
+ return { ahead: parseInt(rightStr, 10) || 0, behind: parseInt(leftStr, 10) || 0 };
1449
+ }
1450
+ async function getCommitDivergenceAsync(branchName, cwd) {
1427
1451
  try {
1428
- const output = execCommand(`git rev-list --count ${branchName}..HEAD`, { cwd });
1429
- return parseInt(output, 10) || 0;
1452
+ const output = await execCommandAsync(`git rev-list --left-right --count HEAD...${branchName}`, { cwd });
1453
+ return parseDivergenceOutput(output);
1430
1454
  } catch {
1431
- return 0;
1455
+ return { ahead: 0, behind: 0 };
1432
1456
  }
1433
1457
  }
1434
1458
  function getCurrentBranch(cwd) {
@@ -3795,7 +3819,7 @@ function buildSeparatorWithHint(cols, hint) {
3795
3819
  const rightLen = remaining - leftLen;
3796
3820
  return `${chalk9.gray("\u2500".repeat(leftLen))} ${hint} ${chalk9.gray("\u2500".repeat(rightLen))}`;
3797
3821
  }
3798
- function buildPanelFrame(statusResult, selectedIndex, scrollOffset, rows, cols, countdown) {
3822
+ function buildPanelFrame(statusResult, selectedIndex, scrollOffset, rows, cols, countdown, cachedPanelLines) {
3799
3823
  const lines = [];
3800
3824
  lines.push(PANEL_TITLE(statusResult.main.projectName));
3801
3825
  lines.push(renderConfiguredBranchLine(statusResult.main));
@@ -3806,7 +3830,7 @@ function buildPanelFrame(statusResult, selectedIndex, scrollOffset, rows, cols,
3806
3830
  lines.push(PANEL_NO_WORKTREES);
3807
3831
  lines.push(buildSeparatorWithHint(cols, ""));
3808
3832
  } else {
3809
- const panelLines = buildGroupedWorktreeLines(statusResult.worktrees, selectedIndex);
3833
+ const panelLines = cachedPanelLines ?? buildGroupedWorktreeLines(statusResult.worktrees, selectedIndex);
3810
3834
  const hasOverflowUp = scrollOffset > 0;
3811
3835
  const hasOverflowDown = scrollOffset + visibleRows < panelLines.length;
3812
3836
  lines.push(buildSeparatorWithHint(cols, hasOverflowUp ? PANEL_OVERFLOW_UP_HINT : ""));
@@ -4002,8 +4026,11 @@ var PanelStateManager = class {
4002
4026
  displayOrder = [];
4003
4027
  /** 滚动偏移(基于行数) */
4004
4028
  scrollOffset = 0;
4029
+ /** 缓存的面板行列表,在 updateData 和导航时更新 */
4030
+ cachedPanelLines = [];
4005
4031
  /**
4006
4032
  * 更新状态数据
4033
+ * 一次性计算 displayOrder 和 cachedPanelLines,后续 adjustScrollForSelection 和 render 复用缓存
4007
4034
  * @param {StatusResult} newStatus - 新的状态数据
4008
4035
  * @param {string} [previousBranch] - 刷新前选中的分支名
4009
4036
  */
@@ -4022,6 +4049,7 @@ var PanelStateManager = class {
4022
4049
  } else {
4023
4050
  this.selectedDisplayIndex = 0;
4024
4051
  }
4052
+ this.rebuildCachedPanelLines();
4025
4053
  }
4026
4054
  /** 获取当前状态数据 */
4027
4055
  getStatusResult() {
@@ -4035,6 +4063,13 @@ var PanelStateManager = class {
4035
4063
  getScrollOffset() {
4036
4064
  return this.scrollOffset;
4037
4065
  }
4066
+ /**
4067
+ * 获取缓存的面板行列表
4068
+ * @returns {PanelLine[]} 缓存的面板行列表
4069
+ */
4070
+ getCachedPanelLines() {
4071
+ return this.cachedPanelLines;
4072
+ }
4038
4073
  /**
4039
4074
  * 向上导航
4040
4075
  * @returns {boolean} 是否发生变化
@@ -4043,6 +4078,7 @@ var PanelStateManager = class {
4043
4078
  if (!this.statusResult || this.displayOrder.length === 0) return false;
4044
4079
  if (this.selectedDisplayIndex > 0) {
4045
4080
  this.selectedDisplayIndex--;
4081
+ this.rebuildCachedPanelLines();
4046
4082
  this.adjustScrollForSelection();
4047
4083
  return true;
4048
4084
  }
@@ -4056,6 +4092,7 @@ var PanelStateManager = class {
4056
4092
  if (!this.statusResult || this.displayOrder.length === 0) return false;
4057
4093
  if (this.selectedDisplayIndex < this.displayOrder.length - 1) {
4058
4094
  this.selectedDisplayIndex++;
4095
+ this.rebuildCachedPanelLines();
4059
4096
  this.adjustScrollForSelection();
4060
4097
  return true;
4061
4098
  }
@@ -4072,13 +4109,14 @@ var PanelStateManager = class {
4072
4109
  }
4073
4110
  /**
4074
4111
  * 调整滚动位置以确保选中项在可见区域内
4112
+ * 复用 cachedPanelLines,不再重新调用 buildGroupedWorktreeLines
4075
4113
  */
4076
4114
  adjustScrollForSelection() {
4077
4115
  if (!this.statusResult || this.displayOrder.length === 0) return;
4078
4116
  const originalIndex = this.getSelectedOriginalIndex();
4079
4117
  const rows = process.stdout.rows || 24;
4080
4118
  const visibleRows = calculateVisibleRows(rows);
4081
- const panelLines = buildGroupedWorktreeLines(this.statusResult.worktrees, originalIndex);
4119
+ const panelLines = this.cachedPanelLines;
4082
4120
  let firstLine = -1;
4083
4121
  let lastLine = -1;
4084
4122
  for (let i = 0; i < panelLines.length; i++) {
@@ -4102,6 +4140,18 @@ var PanelStateManager = class {
4102
4140
  this.scrollOffset = groupStart;
4103
4141
  }
4104
4142
  }
4143
+ /**
4144
+ * 重建缓存的 panelLines
4145
+ * 在数据更新或导航变化时调用
4146
+ */
4147
+ rebuildCachedPanelLines() {
4148
+ if (!this.statusResult) {
4149
+ this.cachedPanelLines = [];
4150
+ return;
4151
+ }
4152
+ const originalIndex = this.getSelectedOriginalIndex();
4153
+ this.cachedPanelLines = buildGroupedWorktreeLines(this.statusResult.worktrees, originalIndex);
4154
+ }
4105
4155
  };
4106
4156
 
4107
4157
  // src/utils/interactive-panel.ts
@@ -4126,13 +4176,17 @@ var InteractivePanel = class {
4126
4176
  exitHandler;
4127
4177
  /** 操作锁(防止操作期间响应按键) */
4128
4178
  isOperating;
4179
+ /** 刷新锁(防止异步刷新期间触发重复刷新) */
4180
+ isRefreshing;
4129
4181
  /** Promise resolve 函数(stop 时调用以完成 start 返回的 Promise) */
4130
4182
  resolveStart;
4131
- /** 数据收集函数引用 */
4183
+ /** 数据收集函数引用(异步,支持并行收集 worktree 数据) */
4132
4184
  collectStatusFn;
4185
+ /** 上一帧的总行数,用于 footer-only 渲染时定位最后一行 */
4186
+ lastFrameLineCount = 0;
4133
4187
  /**
4134
4188
  * 创建交互式面板
4135
- * @param {() => StatusResult} collectStatusFn - 数据收集函数
4189
+ * @param {() => Promise<StatusResult>} collectStatusFn - 异步数据收集函数
4136
4190
  */
4137
4191
  constructor(collectStatusFn) {
4138
4192
  this.stateManager = new PanelStateManager();
@@ -4145,6 +4199,7 @@ var InteractivePanel = class {
4145
4199
  this.resizeHandler = null;
4146
4200
  this.exitHandler = null;
4147
4201
  this.isOperating = false;
4202
+ this.isRefreshing = false;
4148
4203
  this.resolveStart = null;
4149
4204
  this.collectStatusFn = collectStatusFn;
4150
4205
  }
@@ -4153,14 +4208,14 @@ var InteractivePanel = class {
4153
4208
  * 非 TTY 时打印提示并退出
4154
4209
  * @returns {Promise<void>} 面板关闭时 resolve
4155
4210
  */
4156
- start() {
4211
+ async start() {
4157
4212
  if (!this.isTTY) {
4158
4213
  console.log(PANEL_NOT_TTY);
4159
- return Promise.resolve();
4214
+ return;
4160
4215
  }
4216
+ this.stateManager.updateData(await this.collectStatusFn());
4161
4217
  return new Promise((resolve4) => {
4162
4218
  this.resolveStart = resolve4;
4163
- this.stateManager.updateData(this.collectStatusFn());
4164
4219
  this.initTerminal();
4165
4220
  this.keyboardController.start();
4166
4221
  this.startAutoRefresh();
@@ -4290,7 +4345,7 @@ var InteractivePanel = class {
4290
4345
  if (this.refreshCountdown > 0) {
4291
4346
  this.refreshCountdown--;
4292
4347
  }
4293
- this.render();
4348
+ this.renderFooterOnly();
4294
4349
  }, PANEL_COUNTDOWN_INTERVAL_MS);
4295
4350
  if (this.refreshTimer.unref) this.refreshTimer.unref();
4296
4351
  if (this.countdownTimer.unref) this.countdownTimer.unref();
@@ -4309,19 +4364,25 @@ var InteractivePanel = class {
4309
4364
  }
4310
4365
  }
4311
4366
  /**
4312
- * 刷新数据:记录当前选中分支 → 重新收集 → 恢复选中位置 → 重置倒计时 → 重绘
4367
+ * 刷新数据:记录当前选中分支 → 异步重新收集 → 恢复选中位置 → 重置倒计时 → 重绘
4368
+ * 使用 isRefreshing 锁防止异步刷新期间触发重复刷新
4313
4369
  */
4314
- refreshData() {
4315
- if (this.stopped || this.isOperating) return;
4316
- const previousBranch = this.stateManager.getSelectedBranch();
4317
- this.stateManager.updateData(this.collectStatusFn(), previousBranch || void 0);
4318
- this.stateManager.adjustScrollForSelection();
4319
- this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1e3;
4320
- this.render();
4370
+ async refreshData() {
4371
+ if (this.stopped || this.isOperating || this.isRefreshing) return;
4372
+ this.isRefreshing = true;
4373
+ try {
4374
+ const previousBranch = this.stateManager.getSelectedBranch();
4375
+ this.stateManager.updateData(await this.collectStatusFn(), previousBranch || void 0);
4376
+ this.stateManager.adjustScrollForSelection();
4377
+ this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1e3;
4378
+ this.render();
4379
+ } finally {
4380
+ this.isRefreshing = false;
4381
+ }
4321
4382
  }
4322
4383
  /**
4323
4384
  * 渲染一帧面板内容
4324
- * 使用同步输出防止闪烁
4385
+ * 使用同步输出防止闪烁,复用缓存的 panelLines 避免重复 groupWorktreesByDate 计算
4325
4386
  */
4326
4387
  render() {
4327
4388
  const statusResult = this.stateManager.getStatusResult();
@@ -4334,7 +4395,8 @@ var InteractivePanel = class {
4334
4395
  this.stateManager.getScrollOffset(),
4335
4396
  rows,
4336
4397
  cols,
4337
- this.refreshCountdown
4398
+ this.refreshCountdown,
4399
+ this.stateManager.getCachedPanelLines()
4338
4400
  );
4339
4401
  process.stdout.write(SYNC_OUTPUT_START);
4340
4402
  process.stdout.write(CLEAR_SCREEN);
@@ -4343,6 +4405,22 @@ var InteractivePanel = class {
4343
4405
  const suffix = i < frameLines.length - 1 ? "\n" : "";
4344
4406
  process.stdout.write(`${truncateToTerminalWidth(frameLines[i], cols)}${suffix}`);
4345
4407
  }
4408
+ this.lastFrameLineCount = frameLines.length;
4409
+ process.stdout.write(SYNC_OUTPUT_END);
4410
+ }
4411
+ /**
4412
+ * 仅更新 footer 行(倒计时文本)
4413
+ * 使用 ANSI 光标定位直接覆写最后一行,避免全量重绘
4414
+ */
4415
+ renderFooterOnly() {
4416
+ if (this.stopped || this.isOperating || this.lastFrameLineCount === 0) return;
4417
+ const cols = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
4418
+ const footerText = renderFooter(this.refreshCountdown);
4419
+ const truncated = truncateToTerminalWidth(footerText, cols);
4420
+ process.stdout.write(SYNC_OUTPUT_START);
4421
+ process.stdout.write(`\x1B[${this.lastFrameLineCount};1H`);
4422
+ process.stdout.write("\x1B[2K");
4423
+ process.stdout.write(truncated);
4346
4424
  process.stdout.write(SYNC_OUTPUT_END);
4347
4425
  }
4348
4426
  /**
@@ -4363,7 +4441,7 @@ var InteractivePanel = class {
4363
4441
  this.initTerminal();
4364
4442
  this.keyboardController.start();
4365
4443
  this.isOperating = false;
4366
- this.refreshData();
4444
+ await this.refreshData();
4367
4445
  this.startAutoRefresh();
4368
4446
  this.render();
4369
4447
  }
@@ -5507,7 +5585,7 @@ async function handleStatus(options) {
5507
5585
  await panel.start();
5508
5586
  return;
5509
5587
  }
5510
- const statusResult = collectStatus();
5588
+ const statusResult = await collectStatus();
5511
5589
  logger.info(`status \u547D\u4EE4\u6267\u884C\uFF0C\u9879\u76EE: ${statusResult.main.projectName}\uFF0C\u5171 ${statusResult.totalWorktrees} \u4E2A worktree`);
5512
5590
  if (options.json) {
5513
5591
  printStatusAsJson(statusResult);
@@ -5515,7 +5593,7 @@ async function handleStatus(options) {
5515
5593
  }
5516
5594
  printStatusAsText(statusResult);
5517
5595
  }
5518
- function collectStatus() {
5596
+ async function collectStatus() {
5519
5597
  const projectName = getProjectName();
5520
5598
  const currentBranch = getCurrentBranch();
5521
5599
  const isClean = isWorkingDirClean();
@@ -5533,7 +5611,9 @@ function collectStatus() {
5533
5611
  deletions
5534
5612
  };
5535
5613
  const worktrees = getProjectWorktrees();
5536
- const worktreeStatuses = worktrees.map((wt) => collectWorktreeDetailedStatus(wt, projectName));
5614
+ const worktreeStatuses = await Promise.all(
5615
+ worktrees.map((wt) => collectWorktreeDetailedStatusAsync(wt, projectName))
5616
+ );
5537
5617
  const snapshots = collectSnapshots(projectName, worktrees);
5538
5618
  return {
5539
5619
  main: main2,
@@ -5542,49 +5622,61 @@ function collectStatus() {
5542
5622
  totalWorktrees: worktrees.length
5543
5623
  };
5544
5624
  }
5545
- function collectWorktreeDetailedStatus(worktree, projectName) {
5546
- const changeStatus = detectChangeStatus(worktree);
5547
- const { commitsAhead, commitsBehind } = countCommitDivergence(worktree.branch);
5548
- const { insertions, deletions } = countDiffStat(worktree.path);
5625
+ async function collectWorktreeDetailedStatusAsync(worktree, projectName) {
5626
+ const [divergence, porcelain, diffStat] = await Promise.all([
5627
+ countCommitDivergenceAsync(worktree.branch),
5628
+ detectStatusPorcelainAsync(worktree.path),
5629
+ countDiffStatAsync(worktree.path)
5630
+ ]);
5631
+ const changeStatus = detectChangeStatusFromPorcelain(porcelain, divergence.commitsAhead);
5549
5632
  const createdAt = getWorktreeCreatedTime(worktree.path);
5550
5633
  return {
5551
5634
  path: worktree.path,
5552
5635
  branch: worktree.branch,
5553
5636
  changeStatus,
5554
- commitsAhead,
5555
- commitsBehind,
5637
+ commitsAhead: divergence.commitsAhead,
5638
+ commitsBehind: divergence.commitsBehind,
5556
5639
  snapshotTime: resolveSnapshotTime(projectName, worktree.branch),
5557
- insertions,
5558
- deletions,
5640
+ insertions: diffStat.insertions,
5641
+ deletions: diffStat.deletions,
5559
5642
  createdAt
5560
5643
  };
5561
5644
  }
5562
- function detectChangeStatus(worktree) {
5645
+ function detectChangeStatusFromPorcelain(porcelain, commitsAhead) {
5646
+ const hasConflict = porcelain.split("\n").some((line) => /^(UU|AA|DD|DU|UD|AU|UA)/.test(line));
5647
+ if (hasConflict) {
5648
+ return "conflict";
5649
+ }
5650
+ if (porcelain !== "") {
5651
+ return "uncommitted";
5652
+ }
5653
+ if (commitsAhead > 0) {
5654
+ return "committed";
5655
+ }
5656
+ return "clean";
5657
+ }
5658
+ async function detectStatusPorcelainAsync(worktreePath) {
5563
5659
  try {
5564
- if (hasMergeConflict(worktree.path)) {
5565
- return "conflict";
5566
- }
5567
- if (!isWorkingDirClean(worktree.path)) {
5568
- return "uncommitted";
5569
- }
5570
- if (hasLocalCommits(worktree.branch)) {
5571
- return "committed";
5572
- }
5573
- return "clean";
5660
+ return await getStatusPorcelainAsync(worktreePath);
5574
5661
  } catch {
5575
- return "clean";
5662
+ return "";
5576
5663
  }
5577
5664
  }
5578
- function countCommitDivergence(branchName) {
5665
+ async function countCommitDivergenceAsync(branchName) {
5579
5666
  try {
5580
- return {
5581
- commitsAhead: getCommitCountAhead(branchName),
5582
- commitsBehind: getCommitCountBehind(branchName)
5583
- };
5667
+ const { ahead, behind } = await getCommitDivergenceAsync(branchName);
5668
+ return { commitsAhead: ahead, commitsBehind: behind };
5584
5669
  } catch {
5585
5670
  return { commitsAhead: 0, commitsBehind: 0 };
5586
5671
  }
5587
5672
  }
5673
+ async function countDiffStatAsync(worktreePath) {
5674
+ try {
5675
+ return await getDiffStatAsync(worktreePath);
5676
+ } catch {
5677
+ return { insertions: 0, deletions: 0 };
5678
+ }
5679
+ }
5588
5680
  function countDiffStat(worktreePath) {
5589
5681
  try {
5590
5682
  return getDiffStat(worktreePath);
@@ -551,8 +551,10 @@ var TASKS_CMD_MESSAGES = {
551
551
  TASK_INIT_FILE_EXISTS: (path) => `\u6587\u4EF6\u5DF2\u5B58\u5728: ${path}\uFF0C\u5982\u9700\u8986\u76D6\u8BF7\u5148\u5220\u9664`,
552
552
  /** 任务模板生成成功 */
553
553
  TASK_INIT_SUCCESS: (path) => `\u2713 \u4EFB\u52A1\u6A21\u677F\u5DF2\u751F\u6210: ${path}`,
554
- /** 任务模板使用提示 */
555
- TASK_INIT_HINT: (path) => `\u4F7F\u7528 clawt run -f ${path} \u6267\u884C\u4EFB\u52A1`
554
+ /** 任务模板使用提示(分行列出 run 和 resume 两种用法) */
555
+ TASK_INIT_HINT: (path) => `\u6267\u884C\u4EFB\u52A1:
556
+ clawt run -f ${path} # \u521B\u5EFA worktree \u5E76\u6267\u884C\uFF08\u5206\u652F\u540D\u9700\u4E0D\u5B58\u5728\uFF09
557
+ clawt resume -f ${path} # \u5728\u5DF2\u6709 worktree \u4E2D\u8FFD\u95EE\uFF08\u5206\u652F\u540D\u9700\u5DF2\u5B58\u5728\uFF09`
556
558
  };
557
559
 
558
560
  // src/constants/messages/post-create.ts
package/docs/tasks.md CHANGED
@@ -46,7 +46,7 @@ clawt tasks init [path]
46
46
  # 格式说明: 标签外的文本会被忽略,每个任务用 START/END 标签包裹
47
47
  #
48
48
  # 规则:
49
- # 1. 每个任务块用 <!-- CLAWT-TASKS:START --> <!-- CLAWT-TASKS:END --> 包裹
49
+ # 1. 每个任务块用 <START><END> 标签包裹(实际标签见下方示例)
50
50
  # 2. 块内 # branch: <分支名> 声明分支名(使用 -b 参数时可省略)
51
51
  # 3. 块内其余行为任务描述(支持多行)
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.9.5",
3
+ "version": "3.9.7",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -9,11 +9,10 @@ import {
9
9
  getCurrentBranch,
10
10
  isWorkingDirClean,
11
11
  getProjectWorktrees,
12
- getCommitCountAhead,
13
- getCommitCountBehind,
12
+ getCommitDivergenceAsync,
13
+ getDiffStatAsync,
14
+ getStatusPorcelainAsync,
14
15
  getDiffStat,
15
- hasMergeConflict,
16
- hasLocalCommits,
17
16
  getSnapshotModifiedTime,
18
17
  getProjectSnapshotBranches,
19
18
  getWorktreeCreatedTime,
@@ -55,7 +54,7 @@ async function handleStatus(options: StatusOptions): Promise<void> {
55
54
  return;
56
55
  }
57
56
 
58
- const statusResult = collectStatus();
57
+ const statusResult = await collectStatus();
59
58
 
60
59
  logger.info(`status 命令执行,项目: ${statusResult.main.projectName},共 ${statusResult.totalWorktrees} 个 worktree`);
61
60
 
@@ -69,9 +68,10 @@ async function handleStatus(options: StatusOptions): Promise<void> {
69
68
 
70
69
  /**
71
70
  * 收集项目全局状态信息
72
- * @returns {StatusResult} 完整的状态数据
71
+ * worktree 的数据通过 Promise.all 并行收集,避免串行阻塞
72
+ * @returns {Promise<StatusResult>} 完整的状态数据
73
73
  */
74
- export function collectStatus(): StatusResult {
74
+ export async function collectStatus(): Promise<StatusResult> {
75
75
  const projectName = getProjectName();
76
76
  const currentBranch = getCurrentBranch();
77
77
  const isClean = isWorkingDirClean();
@@ -95,9 +95,11 @@ export function collectStatus(): StatusResult {
95
95
  deletions,
96
96
  };
97
97
 
98
- // 各 worktree 详细状态
98
+ // 各 worktree 详细状态(异步并行收集)
99
99
  const worktrees = getProjectWorktrees();
100
- const worktreeStatuses = worktrees.map((wt) => collectWorktreeDetailedStatus(wt, projectName));
100
+ const worktreeStatuses = await Promise.all(
101
+ worktrees.map((wt) => collectWorktreeDetailedStatusAsync(wt, projectName)),
102
+ );
101
103
 
102
104
  // 未清理的 validate 快照
103
105
  const snapshots = collectSnapshots(projectName, worktrees);
@@ -111,70 +113,100 @@ export function collectStatus(): StatusResult {
111
113
  }
112
114
 
113
115
  /**
114
- * 收集单个 worktree 的详细状态
115
- * 变更状态判断优先级:冲突 > 未提交 > 已提交 > 干净
116
+ * 异步收集单个 worktree 的详细状态
117
+ * 内部 3 git 命令通过 Promise.all 并行执行,每个 worktree 内部也是并行的
116
118
  * @param {WorktreeInfo} worktree - worktree 信息
117
119
  * @param {string} projectName - 项目名
118
- * @returns {WorktreeDetailedStatus} 详细状态
120
+ * @returns {Promise<WorktreeDetailedStatus>} 详细状态
119
121
  */
120
- function collectWorktreeDetailedStatus(worktree: WorktreeInfo, projectName: string): WorktreeDetailedStatus {
121
- const changeStatus = detectChangeStatus(worktree);
122
- const { commitsAhead, commitsBehind } = countCommitDivergence(worktree.branch);
123
- const { insertions, deletions } = countDiffStat(worktree.path);
122
+ async function collectWorktreeDetailedStatusAsync(worktree: WorktreeInfo, projectName: string): Promise<WorktreeDetailedStatus> {
123
+ // 3 git 命令并行执行:提交差异、工作区状态、diff 统计
124
+ const [divergence, porcelain, diffStat] = await Promise.all([
125
+ countCommitDivergenceAsync(worktree.branch),
126
+ detectStatusPorcelainAsync(worktree.path),
127
+ countDiffStatAsync(worktree.path),
128
+ ]);
129
+
130
+ const changeStatus = detectChangeStatusFromPorcelain(porcelain, divergence.commitsAhead);
124
131
  const createdAt = getWorktreeCreatedTime(worktree.path);
125
132
 
126
133
  return {
127
134
  path: worktree.path,
128
135
  branch: worktree.branch,
129
136
  changeStatus,
130
- commitsAhead,
131
- commitsBehind,
137
+ commitsAhead: divergence.commitsAhead,
138
+ commitsBehind: divergence.commitsBehind,
132
139
  snapshotTime: resolveSnapshotTime(projectName, worktree.branch),
133
- insertions,
134
- deletions,
140
+ insertions: diffStat.insertions,
141
+ deletions: diffStat.deletions,
135
142
  createdAt,
136
143
  };
137
144
  }
138
145
 
139
146
  /**
140
- * 检测 worktree 的变更状态
147
+ * porcelain 输出判断变更状态
141
148
  * 优先级:冲突 > 未提交 > 已提交 > 干净
142
- * @param {WorktreeInfo} worktree - worktree 信息
149
+ * @param {string} porcelain - git status --porcelain 输出
150
+ * @param {number} commitsAhead - 领先提交数
143
151
  * @returns {WorktreeDetailedStatus['changeStatus']} 变更状态
144
152
  */
145
- function detectChangeStatus(worktree: WorktreeInfo): WorktreeDetailedStatus['changeStatus'] {
153
+ function detectChangeStatusFromPorcelain(porcelain: string, commitsAhead: number): WorktreeDetailedStatus['changeStatus'] {
154
+ // 检测合并冲突(UU/AA/DD/DU/UD/AU/UA 开头的行)
155
+ const hasConflict = porcelain.split('\n').some((line) => /^(UU|AA|DD|DU|UD|AU|UA)/.test(line));
156
+ if (hasConflict) {
157
+ return 'conflict';
158
+ }
159
+ // 检测未提交修改(porcelain 非空即有未提交变更)
160
+ if (porcelain !== '') {
161
+ return 'uncommitted';
162
+ }
163
+ // 用 commitsAhead > 0 判断是否有本地提交
164
+ if (commitsAhead > 0) {
165
+ return 'committed';
166
+ }
167
+ return 'clean';
168
+ }
169
+
170
+ /**
171
+ * 异步获取工作区 porcelain 状态,出错时返回空字符串
172
+ * @param {string} worktreePath - worktree 目录路径
173
+ * @returns {Promise<string>} porcelain 格式输出
174
+ */
175
+ async function detectStatusPorcelainAsync(worktreePath: string): Promise<string> {
146
176
  try {
147
- if (hasMergeConflict(worktree.path)) {
148
- return 'conflict';
149
- }
150
- if (!isWorkingDirClean(worktree.path)) {
151
- return 'uncommitted';
152
- }
153
- if (hasLocalCommits(worktree.branch)) {
154
- return 'committed';
155
- }
156
- return 'clean';
177
+ return await getStatusPorcelainAsync(worktreePath);
157
178
  } catch {
158
- return 'clean';
179
+ return '';
159
180
  }
160
181
  }
161
182
 
162
183
  /**
163
- * 统计分支与主分支的提交差异(领先/落后数)
184
+ * 异步统计分支与主分支的提交差异(领先/落后数)
164
185
  * @param {string} branchName - 分支名
165
- * @returns {{ commitsAhead: number; commitsBehind: number }} 领先和落后的提交数
186
+ * @returns {Promise<{ commitsAhead: number; commitsBehind: number }>} 领先和落后的提交数
166
187
  */
167
- function countCommitDivergence(branchName: string): { commitsAhead: number; commitsBehind: number } {
188
+ async function countCommitDivergenceAsync(branchName: string): Promise<{ commitsAhead: number; commitsBehind: number }> {
168
189
  try {
169
- return {
170
- commitsAhead: getCommitCountAhead(branchName),
171
- commitsBehind: getCommitCountBehind(branchName),
172
- };
190
+ const { ahead, behind } = await getCommitDivergenceAsync(branchName);
191
+ return { commitsAhead: ahead, commitsBehind: behind };
173
192
  } catch {
174
193
  return { commitsAhead: 0, commitsBehind: 0 };
175
194
  }
176
195
  }
177
196
 
197
+ /**
198
+ * 异步统计 worktree 的差异行数
199
+ * @param {string} worktreePath - worktree 目录路径
200
+ * @returns {Promise<{ insertions: number; deletions: number }>} 新增和删除行数
201
+ */
202
+ async function countDiffStatAsync(worktreePath: string): Promise<{ insertions: number; deletions: number }> {
203
+ try {
204
+ return await getDiffStatAsync(worktreePath);
205
+ } catch {
206
+ return { insertions: 0, deletions: 0 };
207
+ }
208
+ }
209
+
178
210
  /**
179
211
  * 统计 worktree 的差异行数
180
212
  * @param {string} worktreePath - worktree 路径
@@ -12,5 +12,5 @@ export const GIT_INDEX_LOCK_RETRY = {
12
12
  /** 重试次数(用户反馈"重试一下就可以了",单次重试足够) */
13
13
  MAX_RETRIES: 1,
14
14
  /** 重试延迟毫秒数(让锁文件有时间被释放) */
15
- DELAY_MS: 150,
15
+ DELAY_MS: 1000,
16
16
  } as const;
@@ -4,6 +4,7 @@ export const TASKS_CMD_MESSAGES = {
4
4
  TASK_INIT_FILE_EXISTS: (path: string) => `文件已存在: ${path},如需覆盖请先删除`,
5
5
  /** 任务模板生成成功 */
6
6
  TASK_INIT_SUCCESS: (path: string) => `✓ 任务模板已生成: ${path}`,
7
- /** 任务模板使用提示 */
8
- TASK_INIT_HINT: (path: string) => `使用 clawt run -f ${path} 执行任务`,
7
+ /** 任务模板使用提示(分行列出 run 和 resume 两种用法) */
8
+ TASK_INIT_HINT: (path: string) =>
9
+ `执行任务:\n clawt run -f ${path} # 创建 worktree 并执行(分支名需不存在)\n clawt resume -f ${path} # 在已有 worktree 中追问(分支名需已存在)`,
9
10
  } as const;
@@ -11,7 +11,7 @@ export const TASK_TEMPLATE_CONTENT = `# Clawt 任务文件
11
11
  # 格式说明: 标签外的文本会被忽略,每个任务用 START/END 标签包裹
12
12
  #
13
13
  # 规则:
14
- # 1. 每个任务块用 <!-- CLAWT-TASKS:START --> <!-- CLAWT-TASKS:END --> 包裹
14
+ # 1. 每个任务块用 <START><END> 标签包裹(实际标签见下方示例)
15
15
  # 2. 块内 # branch: <分支名> 声明分支名(使用 -b 参数时可省略)
16
16
  # 3. 块内其余行为任务描述(支持多行)
17
17
 
@@ -1,4 +1,4 @@
1
- import { execCommand } from './shell.js';
1
+ import { execCommand, execCommandAsync } from './shell.js';
2
2
  import { logger } from '../logger/index.js';
3
3
 
4
4
  /**
@@ -68,6 +68,51 @@ export function getCommitCountBehind(branchName: string, cwd?: string): number {
68
68
  }
69
69
  }
70
70
 
71
+ /**
72
+ * 解析 git rev-list --left-right --count 的输出
73
+ * 输出格式:<left_count>\t<right_count>
74
+ * left = HEAD 侧独有提交数(即 behind),right = branch 侧独有提交数(即 ahead)
75
+ * @param {string} output - rev-list 命令输出
76
+ * @returns {{ ahead: number; behind: number }} 领先和落后的提交数
77
+ */
78
+ function parseDivergenceOutput(output: string): { ahead: number; behind: number } {
79
+ const [leftStr, rightStr] = output.trim().split(/\s+/);
80
+ return { ahead: parseInt(rightStr, 10) || 0, behind: parseInt(leftStr, 10) || 0 };
81
+ }
82
+
83
+ /**
84
+ * 获取当前分支与目标分支的双向提交差异
85
+ * 使用单条 git rev-list --left-right --count 命令同时获取领先和落后提交数,
86
+ * 替代分别调用 getCommitCountAhead 和 getCommitCountBehind 两条命令
87
+ * @param {string} branchName - 目标分支名
88
+ * @param {string} [cwd] - 工作目录
89
+ * @returns {{ ahead: number; behind: number }} 领先和落后的提交数
90
+ */
91
+ export function getCommitDivergence(branchName: string, cwd?: string): { ahead: number; behind: number } {
92
+ try {
93
+ const output = execCommand(`git rev-list --left-right --count HEAD...${branchName}`, { cwd });
94
+ return parseDivergenceOutput(output);
95
+ } catch {
96
+ return { ahead: 0, behind: 0 };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 异步获取当前分支与目标分支的双向提交差异
102
+ * 用于并行收集多个 worktree 的提交差异时避免串行阻塞
103
+ * @param {string} branchName - 目标分支名
104
+ * @param {string} [cwd] - 工作目录
105
+ * @returns {Promise<{ ahead: number; behind: number }>} 领先和落后的提交数
106
+ */
107
+ export async function getCommitDivergenceAsync(branchName: string, cwd?: string): Promise<{ ahead: number; behind: number }> {
108
+ try {
109
+ const output = await execCommandAsync(`git rev-list --left-right --count HEAD...${branchName}`, { cwd });
110
+ return parseDivergenceOutput(output);
111
+ } catch {
112
+ return { ahead: 0, behind: 0 };
113
+ }
114
+ }
115
+
71
116
  /**
72
117
  * 获取当前分支名
73
118
  * @param {string} [cwd] - 工作目录
@@ -1,6 +1,6 @@
1
1
  import { basename } from 'node:path';
2
2
  import { execSync, execFileSync } from 'node:child_process';
3
- import { execCommand, execCommandWithInput } from './shell.js';
3
+ import { execCommand, execCommandAsync, execCommandWithInput } from './shell.js';
4
4
  import { logger } from '../logger/index.js';
5
5
  import { EXEC_MAX_BUFFER, AUTO_SAVE_COMMIT_MESSAGE_PREFIX } from '../constants/git.js';
6
6
 
@@ -81,6 +81,16 @@ export function getStatusPorcelain(cwd?: string): string {
81
81
  return execCommand('git status --porcelain', { cwd });
82
82
  }
83
83
 
84
+ /**
85
+ * 异步获取工作区状态(git status --porcelain)
86
+ * 用于并行收集多个 worktree 状态时避免串行阻塞
87
+ * @param {string} cwd - 工作目录
88
+ * @returns {Promise<string>} porcelain 格式输出,为空表示干净
89
+ */
90
+ export async function getStatusPorcelainAsync(cwd?: string): Promise<string> {
91
+ return execCommandAsync('git status --porcelain', { cwd });
92
+ }
93
+
84
94
  /**
85
95
  * 判断工作区是否干净
86
96
  * @param {string} cwd - 工作目录
@@ -256,6 +266,17 @@ export function getDiffStat(worktreePath: string): { insertions: number; deletio
256
266
  return parseShortStat(output);
257
267
  }
258
268
 
269
+ /**
270
+ * 异步获取 worktree 中工作区和暂存区的变更统计
271
+ * 用于并行收集多个 worktree 的 diff 统计时避免串行阻塞
272
+ * @param {string} worktreePath - worktree 目录路径
273
+ * @returns {Promise<{ insertions: number; deletions: number }>} 新增和删除行数
274
+ */
275
+ export async function getDiffStatAsync(worktreePath: string): Promise<{ insertions: number; deletions: number }> {
276
+ const output = await execCommandAsync('git diff --shortstat HEAD', { cwd: worktreePath });
277
+ return parseShortStat(output);
278
+ }
279
+
259
280
  /**
260
281
  * 获取暂存区相对于 HEAD 的完整 diff(含二进制文件)
261
282
  * 注意:返回原始输出不做 trim,保留 patch 格式完整性
@@ -1,4 +1,4 @@
1
- export { execCommand, spawnProcess, killAllChildProcesses, execCommandWithInput, runCommandInherited, parseParallelCommands, runParallelCommands, runCommandWithStderrCapture, runParallelCommandsWithStderrCapture } from './shell.js';
1
+ export { execCommand, execCommandAsync, spawnProcess, killAllChildProcesses, execCommandWithInput, runCommandInherited, parseParallelCommands, runParallelCommands, runCommandWithStderrCapture, runParallelCommandsWithStderrCapture } from './shell.js';
2
2
  export type { ParallelCommandResult, CommandResultWithStderr, ParallelCommandResultWithStderr } from './shell.js';
3
3
  export { copyToClipboard } from './clipboard.js';
4
4
  export {
@@ -13,6 +13,7 @@ export {
13
13
  removeWorktreeByPath,
14
14
  deleteBranch,
15
15
  getStatusPorcelain,
16
+ getStatusPorcelainAsync,
16
17
  isWorkingDirClean,
17
18
  gitAddAll,
18
19
  gitCommit,
@@ -34,7 +35,10 @@ export {
34
35
  hasLocalCommits,
35
36
  getCommitCountAhead,
36
37
  getCommitCountBehind,
38
+ getCommitDivergence,
39
+ getCommitDivergenceAsync,
37
40
  getDiffStat,
41
+ getDiffStatAsync,
38
42
  gitDiffCachedBinary,
39
43
  gitApplyCachedFromStdin,
40
44
  getCurrentBranch,
@@ -69,6 +69,7 @@ function buildSeparatorWithHint(cols: number, hint: string): string {
69
69
  * @param {number} rows - 终端行数
70
70
  * @param {number} cols - 终端列数
71
71
  * @param {number} countdown - 刷新倒计时秒数
72
+ * @param {PanelLine[]} [cachedPanelLines] - 缓存的面板行列表(传入时复用,不传则重新构建)
72
73
  * @returns {string[]} 帧内容行数组
73
74
  */
74
75
  export function buildPanelFrame(
@@ -78,6 +79,7 @@ export function buildPanelFrame(
78
79
  rows: number,
79
80
  cols: number,
80
81
  countdown: number,
82
+ cachedPanelLines?: PanelLine[],
81
83
  ): string[] {
82
84
  const lines: string[] = [];
83
85
 
@@ -100,8 +102,8 @@ export function buildPanelFrame(
100
102
  // 底部分隔线(无溢出提示)
101
103
  lines.push(buildSeparatorWithHint(cols, ''));
102
104
  } else {
103
- // 构建分组的 worktree 行列表
104
- const panelLines = buildGroupedWorktreeLines(statusResult.worktrees, selectedIndex);
105
+ // 构建分组的 worktree 行列表(优先使用缓存)
106
+ const panelLines = cachedPanelLines ?? buildGroupedWorktreeLines(statusResult.worktrees, selectedIndex);
105
107
 
106
108
  // 判断溢出状态
107
109
  const hasOverflowUp = scrollOffset > 0;
@@ -1,9 +1,11 @@
1
1
  import type { StatusResult } from '../types/index.js';
2
2
  import { buildDisplayOrder, calculateVisibleRows, buildGroupedWorktreeLines } from './interactive-panel-render.js';
3
+ import type { PanelLine } from './interactive-panel-render.js';
3
4
 
4
5
  /**
5
6
  * 面板状态管理器
6
7
  * 负责维护面板的数据状态、滚动偏移和选中项
8
+ * 缓存 panelLines 和 displayOrder 避免重复计算 groupWorktreesByDate
7
9
  */
8
10
  export class PanelStateManager {
9
11
  /** 当前状态数据 */
@@ -14,16 +16,19 @@ export class PanelStateManager {
14
16
  private displayOrder: number[] = [];
15
17
  /** 滚动偏移(基于行数) */
16
18
  private scrollOffset: number = 0;
19
+ /** 缓存的面板行列表,在 updateData 和导航时更新 */
20
+ private cachedPanelLines: PanelLine[] = [];
17
21
 
18
22
  /**
19
23
  * 更新状态数据
24
+ * 一次性计算 displayOrder 和 cachedPanelLines,后续 adjustScrollForSelection 和 render 复用缓存
20
25
  * @param {StatusResult} newStatus - 新的状态数据
21
26
  * @param {string} [previousBranch] - 刷新前选中的分支名
22
27
  */
23
28
  updateData(newStatus: StatusResult, previousBranch?: string): void {
24
29
  this.statusResult = newStatus;
25
30
  this.displayOrder = buildDisplayOrder(this.statusResult.worktrees);
26
-
31
+
27
32
  if (previousBranch && this.displayOrder.length > 0) {
28
33
  const newDisplayIndex = this.displayOrder.findIndex(
29
34
  (origIdx) => this.statusResult!.worktrees[origIdx]?.branch === previousBranch,
@@ -36,6 +41,9 @@ export class PanelStateManager {
36
41
  } else {
37
42
  this.selectedDisplayIndex = 0;
38
43
  }
44
+
45
+ // 一次性构建缓存的 panelLines
46
+ this.rebuildCachedPanelLines();
39
47
  }
40
48
 
41
49
  /** 获取当前状态数据 */
@@ -53,6 +61,14 @@ export class PanelStateManager {
53
61
  return this.scrollOffset;
54
62
  }
55
63
 
64
+ /**
65
+ * 获取缓存的面板行列表
66
+ * @returns {PanelLine[]} 缓存的面板行列表
67
+ */
68
+ getCachedPanelLines(): PanelLine[] {
69
+ return this.cachedPanelLines;
70
+ }
71
+
56
72
  /**
57
73
  * 向上导航
58
74
  * @returns {boolean} 是否发生变化
@@ -62,6 +78,8 @@ export class PanelStateManager {
62
78
 
63
79
  if (this.selectedDisplayIndex > 0) {
64
80
  this.selectedDisplayIndex--;
81
+ // 导航后重建缓存(选中标记变化)
82
+ this.rebuildCachedPanelLines();
65
83
  this.adjustScrollForSelection();
66
84
  return true;
67
85
  }
@@ -77,6 +95,8 @@ export class PanelStateManager {
77
95
 
78
96
  if (this.selectedDisplayIndex < this.displayOrder.length - 1) {
79
97
  this.selectedDisplayIndex++;
98
+ // 导航后重建缓存(选中标记变化)
99
+ this.rebuildCachedPanelLines();
80
100
  this.adjustScrollForSelection();
81
101
  return true;
82
102
  }
@@ -95,6 +115,7 @@ export class PanelStateManager {
95
115
 
96
116
  /**
97
117
  * 调整滚动位置以确保选中项在可见区域内
118
+ * 复用 cachedPanelLines,不再重新调用 buildGroupedWorktreeLines
98
119
  */
99
120
  adjustScrollForSelection(): void {
100
121
  if (!this.statusResult || this.displayOrder.length === 0) return;
@@ -102,7 +123,7 @@ export class PanelStateManager {
102
123
  const originalIndex = this.getSelectedOriginalIndex();
103
124
  const rows = process.stdout.rows || 24;
104
125
  const visibleRows = calculateVisibleRows(rows);
105
- const panelLines = buildGroupedWorktreeLines(this.statusResult.worktrees, originalIndex);
126
+ const panelLines = this.cachedPanelLines;
106
127
 
107
128
  // 找到选中 worktree 对应的第一行和最后一行
108
129
  let firstLine = -1;
@@ -134,4 +155,17 @@ export class PanelStateManager {
134
155
  this.scrollOffset = groupStart;
135
156
  }
136
157
  }
158
+
159
+ /**
160
+ * 重建缓存的 panelLines
161
+ * 在数据更新或导航变化时调用
162
+ */
163
+ private rebuildCachedPanelLines(): void {
164
+ if (!this.statusResult) {
165
+ this.cachedPanelLines = [];
166
+ return;
167
+ }
168
+ const originalIndex = this.getSelectedOriginalIndex();
169
+ this.cachedPanelLines = buildGroupedWorktreeLines(this.statusResult.worktrees, originalIndex);
170
+ }
137
171
  }
@@ -20,7 +20,7 @@ import {
20
20
  } from '../constants/index.js';
21
21
  import { PANEL_NOT_TTY, PANEL_PRESS_ENTER_TO_RETURN } from '../constants/messages/index.js';
22
22
  import { runCommandInherited } from './shell.js';
23
- import { buildPanelFrame } from './interactive-panel-render.js';
23
+ import { buildPanelFrame, renderFooter } from './interactive-panel-render.js';
24
24
  import { truncateToTerminalWidth } from './progress-render.js';
25
25
  import type { StatusResult } from '../types/index.js';
26
26
  import { KeyboardController } from './keyboard-controller.js';
@@ -53,16 +53,20 @@ export class InteractivePanel {
53
53
  private exitHandler: (() => void) | null;
54
54
  /** 操作锁(防止操作期间响应按键) */
55
55
  private isOperating: boolean;
56
+ /** 刷新锁(防止异步刷新期间触发重复刷新) */
57
+ private isRefreshing: boolean;
56
58
  /** Promise resolve 函数(stop 时调用以完成 start 返回的 Promise) */
57
59
  private resolveStart: (() => void) | null;
58
- /** 数据收集函数引用 */
59
- private collectStatusFn: () => StatusResult;
60
+ /** 数据收集函数引用(异步,支持并行收集 worktree 数据) */
61
+ private collectStatusFn: () => Promise<StatusResult>;
62
+ /** 上一帧的总行数,用于 footer-only 渲染时定位最后一行 */
63
+ private lastFrameLineCount: number = 0;
60
64
 
61
65
  /**
62
66
  * 创建交互式面板
63
- * @param {() => StatusResult} collectStatusFn - 数据收集函数
67
+ * @param {() => Promise<StatusResult>} collectStatusFn - 异步数据收集函数
64
68
  */
65
- constructor(collectStatusFn: () => StatusResult) {
69
+ constructor(collectStatusFn: () => Promise<StatusResult>) {
66
70
  this.stateManager = new PanelStateManager();
67
71
  this.keyboardController = new KeyboardController(this.handleKeypress.bind(this));
68
72
  this.refreshTimer = null;
@@ -73,6 +77,7 @@ export class InteractivePanel {
73
77
  this.resizeHandler = null;
74
78
  this.exitHandler = null;
75
79
  this.isOperating = false;
80
+ this.isRefreshing = false;
76
81
  this.resolveStart = null;
77
82
  this.collectStatusFn = collectStatusFn;
78
83
  }
@@ -82,19 +87,19 @@ export class InteractivePanel {
82
87
  * 非 TTY 时打印提示并退出
83
88
  * @returns {Promise<void>} 面板关闭时 resolve
84
89
  */
85
- start(): Promise<void> {
90
+ async start(): Promise<void> {
86
91
  // 非 TTY 降级
87
92
  if (!this.isTTY) {
88
93
  console.log(PANEL_NOT_TTY);
89
- return Promise.resolve();
94
+ return;
90
95
  }
91
96
 
97
+ // 异步收集初始数据(在创建 Promise 之前完成,避免 async executor 反模式)
98
+ this.stateManager.updateData(await this.collectStatusFn());
99
+
92
100
  return new Promise<void>((resolve) => {
93
101
  this.resolveStart = resolve;
94
102
 
95
- // 收集初始数据
96
- this.stateManager.updateData(this.collectStatusFn());
97
-
98
103
  // 初始化终端
99
104
  this.initTerminal();
100
105
 
@@ -270,12 +275,12 @@ export class InteractivePanel {
270
275
  this.refreshData();
271
276
  }, PANEL_REFRESH_INTERVAL_MS);
272
277
 
273
- // 倒计时定时器(每秒更新显示)
278
+ // 倒计时定时器(每秒仅更新 footer 行,不触发全量重绘)
274
279
  this.countdownTimer = setInterval(() => {
275
280
  if (this.refreshCountdown > 0) {
276
281
  this.refreshCountdown--;
277
282
  }
278
- this.render();
283
+ this.renderFooterOnly();
279
284
  }, PANEL_COUNTDOWN_INTERVAL_MS);
280
285
 
281
286
  // 确保定时器不阻止进程退出
@@ -298,29 +303,35 @@ export class InteractivePanel {
298
303
  }
299
304
 
300
305
  /**
301
- * 刷新数据:记录当前选中分支 → 重新收集 → 恢复选中位置 → 重置倒计时 → 重绘
306
+ * 刷新数据:记录当前选中分支 → 异步重新收集 → 恢复选中位置 → 重置倒计时 → 重绘
307
+ * 使用 isRefreshing 锁防止异步刷新期间触发重复刷新
302
308
  */
303
- private refreshData(): void {
304
- if (this.stopped || this.isOperating) return;
309
+ private async refreshData(): Promise<void> {
310
+ if (this.stopped || this.isOperating || this.isRefreshing) return;
305
311
 
306
- // 记录当前选中分支名
307
- const previousBranch = this.stateManager.getSelectedBranch();
312
+ this.isRefreshing = true;
313
+ try {
314
+ // 记录当前选中分支名
315
+ const previousBranch = this.stateManager.getSelectedBranch();
308
316
 
309
- // 重新收集数据并更新状态
310
- this.stateManager.updateData(this.collectStatusFn(), previousBranch || undefined);
317
+ // 异步重新收集数据并更新状态
318
+ this.stateManager.updateData(await this.collectStatusFn(), previousBranch || undefined);
311
319
 
312
- // 在重绘前必须确保滚动状态正常
313
- this.stateManager.adjustScrollForSelection();
320
+ // 在重绘前必须确保滚动状态正常
321
+ this.stateManager.adjustScrollForSelection();
314
322
 
315
- // 重置倒计时
316
- this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1000;
323
+ // 重置倒计时
324
+ this.refreshCountdown = PANEL_REFRESH_INTERVAL_MS / 1000;
317
325
 
318
- this.render();
326
+ this.render();
327
+ } finally {
328
+ this.isRefreshing = false;
329
+ }
319
330
  }
320
331
 
321
332
  /**
322
333
  * 渲染一帧面板内容
323
- * 使用同步输出防止闪烁
334
+ * 使用同步输出防止闪烁,复用缓存的 panelLines 避免重复 groupWorktreesByDate 计算
324
335
  */
325
336
  private render(): void {
326
337
  const statusResult = this.stateManager.getStatusResult();
@@ -336,6 +347,7 @@ export class InteractivePanel {
336
347
  rows,
337
348
  cols,
338
349
  this.refreshCountdown,
350
+ this.stateManager.getCachedPanelLines(),
339
351
  );
340
352
 
341
353
  // 同步输出开始
@@ -349,10 +361,34 @@ export class InteractivePanel {
349
361
  process.stdout.write(`${truncateToTerminalWidth(frameLines[i], cols)}${suffix}`);
350
362
  }
351
363
 
364
+ // 记录帧行数,供 renderFooterOnly 定位最后一行
365
+ this.lastFrameLineCount = frameLines.length;
366
+
352
367
  // 同步输出结束
353
368
  process.stdout.write(SYNC_OUTPUT_END);
354
369
  }
355
370
 
371
+ /**
372
+ * 仅更新 footer 行(倒计时文本)
373
+ * 使用 ANSI 光标定位直接覆写最后一行,避免全量重绘
374
+ */
375
+ private renderFooterOnly(): void {
376
+ if (this.stopped || this.isOperating || this.lastFrameLineCount === 0) return;
377
+
378
+ const cols = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
379
+ const footerText = renderFooter(this.refreshCountdown);
380
+ const truncated = truncateToTerminalWidth(footerText, cols);
381
+
382
+ // 使用 ANSI 转义序列定位到最后一行并覆写
383
+ // \x1b[<row>;1H 移动光标到第 <row> 行第 1 列
384
+ // \x1b[2K 清除当前行
385
+ process.stdout.write(SYNC_OUTPUT_START);
386
+ process.stdout.write(`\x1b[${this.lastFrameLineCount};1H`);
387
+ process.stdout.write('\x1b[2K');
388
+ process.stdout.write(truncated);
389
+ process.stdout.write(SYNC_OUTPUT_END);
390
+ }
391
+
356
392
  /**
357
393
  * 执行操作:暂停面板 → 恢复终端 → 执行命令 → 等待回车 → 恢复面板
358
394
  * @param {() => void} action - 要执行的操作
@@ -389,8 +425,8 @@ export class InteractivePanel {
389
425
 
390
426
  this.isOperating = false;
391
427
 
392
- // 刷新数据并重新启动自动刷新
393
- this.refreshData();
428
+ // 异步刷新数据并重新启动自动刷新
429
+ await this.refreshData();
394
430
  this.startAutoRefresh();
395
431
 
396
432
  // 渲染
@@ -1,9 +1,13 @@
1
- import { execSync, execFileSync, spawn, spawnSync, type ChildProcess, type SpawnSyncReturns, type StdioOptions } from 'node:child_process';
1
+ import { exec, execSync, execFileSync, spawn, spawnSync, type ChildProcess, type SpawnSyncReturns, type StdioOptions } from 'node:child_process';
2
+ import { promisify } from 'node:util';
2
3
  import { logger } from '../logger/index.js';
3
4
  import { EXEC_MAX_BUFFER } from '../constants/git.js';
4
5
  import { CLAUDE_CODE_ENTRYPOINT_VALUE } from '../constants/index.js';
5
6
  import { throwIfGitIndexLockError, shouldRetryGitIndexLockError, waitForGitIndexLockRetrySync } from './git-lock.js';
6
7
 
8
+ /** promisified 版本的 exec */
9
+ const execPromise = promisify(exec);
10
+
7
11
  /**
8
12
  * 获取移除了 CLAUDECODE 嵌套会话标记的环境变量副本,并注入 CLAUDE_CODE_ENTRYPOINT 标识
9
13
  * 仅用于 claude -p 等非交互式子进程:
@@ -81,6 +85,26 @@ export function execCommand(command: string, options?: { cwd?: string }): string
81
85
  }
82
86
  }
83
87
 
88
+ /**
89
+ * 异步执行 shell 命令并返回 stdout
90
+ * 基于 child_process.exec 的 promisified 版本,适用于只读 git 命令的并行执行场景
91
+ * 不包含 index.lock 重试逻辑(只读命令不触发 index.lock)
92
+ * @param {string} command - 要执行的命令
93
+ * @param {object} options - 可选配置
94
+ * @param {string} options.cwd - 工作目录
95
+ * @returns {Promise<string>} 命令的标准输出(已 trim)
96
+ * @throws {Error} 命令执行失败时抛出
97
+ */
98
+ export async function execCommandAsync(command: string, options?: { cwd?: string }): Promise<string> {
99
+ logger.debug(`执行异步命令: ${command}${options?.cwd ? ` (cwd: ${options.cwd})` : ''}`);
100
+ const { stdout } = await execPromise(command, {
101
+ cwd: options?.cwd,
102
+ encoding: 'utf-8',
103
+ maxBuffer: EXEC_MAX_BUFFER,
104
+ });
105
+ return (stdout as string).trim();
106
+ }
107
+
84
108
  /**
85
109
  * 以子进程方式异步执行命令
86
110
  * @param {string} command - 要执行的命令
@@ -39,11 +39,10 @@ vi.mock('../../../src/utils/index.js', () => ({
39
39
  getCurrentBranch: vi.fn(),
40
40
  isWorkingDirClean: vi.fn(),
41
41
  getProjectWorktrees: vi.fn(),
42
- getCommitCountAhead: vi.fn(),
43
- getCommitCountBehind: vi.fn(),
42
+ getCommitDivergenceAsync: vi.fn(),
44
43
  getDiffStat: vi.fn(),
45
- hasMergeConflict: vi.fn(),
46
- hasLocalCommits: vi.fn(),
44
+ getDiffStatAsync: vi.fn(),
45
+ getStatusPorcelainAsync: vi.fn(),
47
46
  getSnapshotModifiedTime: vi.fn(),
48
47
  getProjectSnapshotBranches: vi.fn(),
49
48
  getWorktreeCreatedTime: vi.fn(),
@@ -61,11 +60,10 @@ import {
61
60
  getCurrentBranch,
62
61
  isWorkingDirClean,
63
62
  getProjectWorktrees,
64
- getCommitCountAhead,
65
- getCommitCountBehind,
63
+ getCommitDivergenceAsync,
66
64
  getDiffStat,
67
- hasMergeConflict,
68
- hasLocalCommits,
65
+ getDiffStatAsync,
66
+ getStatusPorcelainAsync,
69
67
  getSnapshotModifiedTime,
70
68
  getProjectSnapshotBranches,
71
69
  getWorktreeCreatedTime,
@@ -77,11 +75,10 @@ const mockedGetProjectName = vi.mocked(getProjectName);
77
75
  const mockedGetCurrentBranch = vi.mocked(getCurrentBranch);
78
76
  const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
79
77
  const mockedGetProjectWorktrees = vi.mocked(getProjectWorktrees);
80
- const mockedGetCommitCountAhead = vi.mocked(getCommitCountAhead);
81
- const mockedGetCommitCountBehind = vi.mocked(getCommitCountBehind);
78
+ const mockedGetCommitDivergenceAsync = vi.mocked(getCommitDivergenceAsync);
82
79
  const mockedGetDiffStat = vi.mocked(getDiffStat);
83
- const mockedHasMergeConflict = vi.mocked(hasMergeConflict);
84
- const mockedHasLocalCommits = vi.mocked(hasLocalCommits);
80
+ const mockedGetDiffStatAsync = vi.mocked(getDiffStatAsync);
81
+ const mockedGetStatusPorcelainAsync = vi.mocked(getStatusPorcelainAsync);
85
82
  const mockedGetSnapshotModifiedTime = vi.mocked(getSnapshotModifiedTime);
86
83
  const mockedGetProjectSnapshotBranches = vi.mocked(getProjectSnapshotBranches);
87
84
  const mockedGetWorktreeCreatedTime = vi.mocked(getWorktreeCreatedTime);
@@ -94,11 +91,10 @@ beforeEach(() => {
94
91
  mockedIsWorkingDirClean.mockReturnValue(true);
95
92
  mockedGetProjectWorktrees.mockReturnValue([]);
96
93
  mockedGetProjectSnapshotBranches.mockReturnValue([]);
97
- mockedGetCommitCountAhead.mockReturnValue(0);
98
- mockedGetCommitCountBehind.mockReturnValue(0);
94
+ mockedGetCommitDivergenceAsync.mockResolvedValue({ ahead: 0, behind: 0 });
99
95
  mockedGetDiffStat.mockReturnValue({ insertions: 0, deletions: 0 });
100
- mockedHasMergeConflict.mockReturnValue(false);
101
- mockedHasLocalCommits.mockReturnValue(false);
96
+ mockedGetDiffStatAsync.mockResolvedValue({ insertions: 0, deletions: 0 });
97
+ mockedGetStatusPorcelainAsync.mockResolvedValue('');
102
98
  mockedGetSnapshotModifiedTime.mockReturnValue(null);
103
99
  mockedGetWorktreeCreatedTime.mockReturnValue(null);
104
100
  mockedFormatRelativeTime.mockReturnValue('3 天前');
@@ -128,9 +124,8 @@ describe('handleStatus', () => {
128
124
  mockedGetProjectWorktrees.mockReturnValue([
129
125
  { path: '/path/feature', branch: 'feature' },
130
126
  ]);
131
- mockedGetCommitCountAhead.mockReturnValue(2);
132
- mockedGetDiffStat.mockReturnValue({ insertions: 10, deletions: 5 });
133
- mockedHasLocalCommits.mockReturnValue(true);
127
+ mockedGetCommitDivergenceAsync.mockResolvedValue({ ahead: 2, behind: 0 });
128
+ mockedGetDiffStatAsync.mockResolvedValue({ insertions: 10, deletions: 5 });
134
129
 
135
130
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
136
131
 
@@ -155,8 +150,8 @@ describe('handleStatus', () => {
155
150
  mockedGetProjectWorktrees.mockReturnValue([
156
151
  { path: '/path/feature', branch: 'feature' },
157
152
  ]);
158
- // 模拟冲突状态
159
- mockedHasMergeConflict.mockReturnValue(true);
153
+ // 模拟冲突状态:porcelain 输出包含 UU 前缀的行
154
+ mockedGetStatusPorcelainAsync.mockResolvedValue('UU src/conflict-file.ts');
160
155
 
161
156
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
162
157
 
@@ -214,10 +209,8 @@ describe('handleStatus', () => {
214
209
  mockedGetProjectWorktrees.mockReturnValue([
215
210
  { path: '/path/feature', branch: 'feature' },
216
211
  ]);
217
- mockedHasMergeConflict.mockReturnValue(false);
218
- mockedIsWorkingDirClean
219
- .mockReturnValueOnce(true) // 主 worktree
220
- .mockReturnValueOnce(false); // 目标 worktree 不干净
212
+ // 模拟未提交修改:porcelain 输出包含修改但非冲突的行
213
+ mockedGetStatusPorcelainAsync.mockResolvedValue(' M src/file.ts'); // 目标 worktree 有未提交修改
221
214
 
222
215
  const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
223
216