clawt 3.9.12 → 3.9.13

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.
File without changes
package/dist/index.js CHANGED
@@ -4495,6 +4495,7 @@ var InteractivePanel = class {
4495
4495
  this.initTerminal();
4496
4496
  this.keyboardController.start();
4497
4497
  this.isOperating = false;
4498
+ this.render();
4498
4499
  await this.refreshData();
4499
4500
  this.startAutoRefresh();
4500
4501
  this.render();
package/docs/status.md CHANGED
@@ -280,7 +280,7 @@ Worktree 按创建日期分组(复用 `groupWorktreesByDate()`),每组前
280
280
  3. 以继承 stdio 的方式执行对应的 clawt 子命令(如 `clawt validate -b <branch>`)。其中 `c`(cover)命令不需要指定分支,直接执行 `clawt cover`
281
281
  4. 命令完成后,输出 `按 Enter 返回面板...` 提示
282
282
  5. 等待用户按 Enter 键
283
- 6. 重新进入备选屏幕,刷新数据,恢复面板
283
+ 6. 重新进入备选屏幕,立即用旧数据渲染一帧(消除白屏等待),再异步刷新数据,恢复面板
284
284
 
285
285
  执行操作期间设置操作锁(`isOperating`),阻止其他按键响应。
286
286
 
@@ -303,6 +303,7 @@ Worktree 按创建日期分组(复用 `groupWorktreesByDate()`),每组前
303
303
  - 使用同步输出序列(`SYNC_OUTPUT_START` / `SYNC_OUTPUT_END`)防止闪烁
304
304
  - 隐藏光标、禁用行换行,确保渲染效果整洁
305
305
  - 注册 `exit` 事件兜底处理器,确保异常退出时终端状态被恢复
306
+ - 操作返回面板时,先立即用旧数据渲染一帧(消除备选屏幕进入后的白屏),再异步刷新数据并重新渲染
306
307
  - 每行通过 `truncateToTerminalWidth()` 截断以适配终端宽度
307
308
 
308
309
  **实现要点:**
package/package.json CHANGED
@@ -1,12 +1,21 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.9.12",
3
+ "version": "3.9.13",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
8
  "clawt": "dist/index.js"
9
9
  },
10
+ "scripts": {
11
+ "build": "tsup",
12
+ "dev": "tsup --watch",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
15
+ "test:coverage": "vitest run --coverage",
16
+ "postinstall": "node dist/postinstall.js",
17
+ "release": "bash scripts/release.sh"
18
+ },
10
19
  "keywords": [
11
20
  "claude",
12
21
  "worktree",
@@ -30,16 +39,8 @@
30
39
  "typescript": "^5.7.3",
31
40
  "vitest": "^4.0.18"
32
41
  },
42
+ "packageManager": "pnpm@10.14.0",
33
43
  "engines": {
34
44
  "node": ">=18"
35
- },
36
- "scripts": {
37
- "build": "tsup",
38
- "dev": "tsup --watch",
39
- "test": "vitest run",
40
- "test:watch": "vitest",
41
- "test:coverage": "vitest run --coverage",
42
- "postinstall": "node dist/postinstall.js",
43
- "release": "bash scripts/release.sh"
44
45
  }
45
- }
46
+ }
@@ -202,9 +202,9 @@ print_success "tag 已创建: ${TAG}"
202
202
  # ────────────────────────────────────────
203
203
 
204
204
  print_step "发布到 npm..."
205
- # 临时关闭 set -e,手动捕获 pnpm publish 的退出码
205
+ # 临时关闭 set -e,手动捕获 npm publish 的退出码
206
206
  set +e
207
- NPM_OUTPUT=$(pnpm publish --access public --registry https://registry.npmjs.org/ --no-git-checks 2>&1)
207
+ NPM_OUTPUT=$(npm publish --access public --registry https://registry.npmjs.org/ 2>&1)
208
208
  NPM_EXIT_CODE=$?
209
209
  set -e
210
210
 
@@ -425,11 +425,13 @@ export class InteractivePanel {
425
425
 
426
426
  this.isOperating = false;
427
427
 
428
+ // 立即用旧数据渲染一帧,消除备选屏幕进入后的白屏
429
+ this.render();
430
+
428
431
  // 异步刷新数据并重新启动自动刷新
429
432
  await this.refreshData();
430
433
  this.startAutoRefresh();
431
434
 
432
- // 渲染
433
435
  this.render();
434
436
  }
435
437
 
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // mock 所有依赖,避免真实 git 命令执行
4
+ vi.mock('../../../src/constants/index.js', () => ({
5
+ CURSOR_HIDE: '',
6
+ CURSOR_SHOW: '',
7
+ LINE_WRAP_DISABLE: '',
8
+ LINE_WRAP_ENABLE: '',
9
+ SYNC_OUTPUT_START: '',
10
+ SYNC_OUTPUT_END: '',
11
+ ALT_SCREEN_ENTER: '',
12
+ ALT_SCREEN_LEAVE: '',
13
+ CLEAR_SCREEN: '',
14
+ CURSOR_HOME: '',
15
+ DEFAULT_TERMINAL_COLUMNS: 80,
16
+ PANEL_REFRESH_INTERVAL_MS: 5000,
17
+ PANEL_COUNTDOWN_INTERVAL_MS: 1000,
18
+ KEY_ARROW_UP: '\x1b[A',
19
+ KEY_ARROW_DOWN: '\x1b[B',
20
+ KEY_CTRL_C: 3,
21
+ PANEL_SHORTCUT_KEYS: {
22
+ VALIDATE: 'v',
23
+ MERGE: 'm',
24
+ DELETE: 'd',
25
+ RESUME: 'r',
26
+ SYNC: 's',
27
+ COVER: 'c',
28
+ REFRESH: 'f',
29
+ QUIT: 'q',
30
+ },
31
+ }));
32
+
33
+ vi.mock('../../../src/constants/messages/index.js', () => ({
34
+ PANEL_NOT_TTY: 'not tty',
35
+ PANEL_PRESS_ENTER_TO_RETURN: 'press enter',
36
+ }));
37
+
38
+ vi.mock('../../../src/utils/shell.js', () => ({
39
+ runCommandInherited: vi.fn(),
40
+ }));
41
+
42
+ vi.mock('../../../src/utils/interactive-panel-render.js', () => ({
43
+ buildPanelFrame: vi.fn(() => ['line1', 'line2']),
44
+ renderFooter: vi.fn(() => 'footer'),
45
+ }));
46
+
47
+ vi.mock('../../../src/utils/progress-render.js', () => ({
48
+ truncateToTerminalWidth: vi.fn((s: string) => s),
49
+ }));
50
+
51
+ vi.mock('../../../src/utils/keyboard-controller.js', () => ({
52
+ KeyboardController: vi.fn(function () {
53
+ return {
54
+ start: vi.fn(),
55
+ stop: vi.fn(),
56
+ };
57
+ }),
58
+ }));
59
+
60
+ vi.mock('../../../src/utils/interactive-panel-state.js', () => ({
61
+ PanelStateManager: vi.fn(function () {
62
+ return {
63
+ updateData: vi.fn(),
64
+ getStatusResult: vi.fn(() => ({ main: {}, worktrees: [], snapshots: {}, totalWorktrees: 0 })),
65
+ getSelectedOriginalIndex: vi.fn(() => 0),
66
+ getSelectedBranch: vi.fn(() => 'feat-test'),
67
+ getScrollOffset: vi.fn(() => 0),
68
+ getCachedPanelLines: vi.fn(() => []),
69
+ adjustScrollForSelection: vi.fn(),
70
+ navigateUp: vi.fn(() => false),
71
+ navigateDown: vi.fn(() => false),
72
+ };
73
+ }),
74
+ }));
75
+
76
+ vi.mock('../../../src/logger/index.js', () => ({
77
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
78
+ }));
79
+
80
+ import { InteractivePanel } from '../../../src/utils/interactive-panel.js';
81
+ import type { StatusResult } from '../../../src/types/index.js';
82
+
83
+ /**
84
+ * 构造最小可用的 StatusResult mock
85
+ */
86
+ function makeStatusResult(): StatusResult {
87
+ return {
88
+ main: {
89
+ branch: 'main',
90
+ isClean: true,
91
+ projectName: 'test-project',
92
+ configuredMainBranch: 'main',
93
+ configuredBranchExists: true,
94
+ insertions: 0,
95
+ deletions: 0,
96
+ },
97
+ worktrees: [],
98
+ snapshots: { total: 0, orphaned: 0 },
99
+ totalWorktrees: 0,
100
+ };
101
+ }
102
+
103
+ describe('InteractivePanel.executeOperation()', () => {
104
+ let panel: InteractivePanel;
105
+ let collectStatusMock: ReturnType<typeof vi.fn>;
106
+ let renderSpy: ReturnType<typeof vi.spyOn>;
107
+ let refreshDataSpy: ReturnType<typeof vi.spyOn>;
108
+
109
+ beforeEach(() => {
110
+ // 模拟 TTY 环境
111
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
112
+ Object.defineProperty(process.stdout, 'columns', { value: 80, configurable: true });
113
+ Object.defineProperty(process.stdout, 'rows', { value: 24, configurable: true });
114
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
115
+
116
+ collectStatusMock = vi.fn().mockResolvedValue(makeStatusResult());
117
+ panel = new InteractivePanel(collectStatusMock);
118
+
119
+ // 监视 render 和 refreshData,记录调用顺序
120
+ renderSpy = vi.spyOn(panel as any, 'render');
121
+ refreshDataSpy = vi.spyOn(panel as any, 'refreshData').mockResolvedValue(undefined);
122
+ });
123
+
124
+ it('应在 refreshData 之前调用一次 render(立即渲染旧数据消除白屏)', async () => {
125
+ const callOrder: string[] = [];
126
+ renderSpy.mockImplementation(() => { callOrder.push('render'); });
127
+ refreshDataSpy.mockImplementation(async () => { callOrder.push('refreshData'); });
128
+
129
+ // 直接调用私有方法 executeOperation
130
+ const action = vi.fn();
131
+ // 绕过 waitForEnter,直接 resolve
132
+ vi.spyOn(panel as any, 'waitForEnter').mockResolvedValue(undefined);
133
+ vi.spyOn(panel as any, 'initTerminal').mockImplementation(() => {});
134
+ vi.spyOn(panel as any, 'restoreTerminal').mockImplementation(() => {});
135
+ vi.spyOn(panel as any, 'removeTerminalListeners').mockImplementation(() => {});
136
+ vi.spyOn(panel as any, 'startAutoRefresh').mockImplementation(() => {});
137
+ vi.spyOn(panel as any, 'clearTimers').mockImplementation(() => {});
138
+
139
+ // stateManager.getStatusResult() 需要返回非 null 才能让 render 不被 guard 拦截
140
+ // executeOperation 顶部也检查 statusResult 非 null
141
+ await (panel as any).executeOperation(action);
142
+
143
+ // render 必须在 refreshData 之前被调用至少一次
144
+ const firstRenderIdx = callOrder.indexOf('render');
145
+ const refreshDataIdx = callOrder.indexOf('refreshData');
146
+ expect(firstRenderIdx).toBeGreaterThanOrEqual(0);
147
+ expect(refreshDataIdx).toBeGreaterThanOrEqual(0);
148
+ expect(firstRenderIdx).toBeLessThan(refreshDataIdx);
149
+ });
150
+
151
+ it('应在 refreshData 之后再调用一次 render(刷新为最新数据)', async () => {
152
+ const callOrder: string[] = [];
153
+ renderSpy.mockImplementation(() => { callOrder.push('render'); });
154
+ refreshDataSpy.mockImplementation(async () => { callOrder.push('refreshData'); });
155
+
156
+ vi.spyOn(panel as any, 'waitForEnter').mockResolvedValue(undefined);
157
+ vi.spyOn(panel as any, 'initTerminal').mockImplementation(() => {});
158
+ vi.spyOn(panel as any, 'restoreTerminal').mockImplementation(() => {});
159
+ vi.spyOn(panel as any, 'removeTerminalListeners').mockImplementation(() => {});
160
+ vi.spyOn(panel as any, 'startAutoRefresh').mockImplementation(() => {});
161
+ vi.spyOn(panel as any, 'clearTimers').mockImplementation(() => {});
162
+
163
+ await (panel as any).executeOperation(vi.fn());
164
+
165
+ // refreshData 之后必须还有一次 render
166
+ const refreshDataIdx = callOrder.lastIndexOf('refreshData');
167
+ const lastRenderIdx = callOrder.lastIndexOf('render');
168
+ expect(lastRenderIdx).toBeGreaterThan(refreshDataIdx);
169
+ });
170
+
171
+ it('isOperating 应在 render 之前被设为 false', async () => {
172
+ const isOperatingAtRender: boolean[] = [];
173
+
174
+ renderSpy.mockImplementation(() => {
175
+ isOperatingAtRender.push((panel as any).isOperating);
176
+ });
177
+ refreshDataSpy.mockResolvedValue(undefined);
178
+
179
+ vi.spyOn(panel as any, 'waitForEnter').mockResolvedValue(undefined);
180
+ vi.spyOn(panel as any, 'initTerminal').mockImplementation(() => {});
181
+ vi.spyOn(panel as any, 'restoreTerminal').mockImplementation(() => {});
182
+ vi.spyOn(panel as any, 'removeTerminalListeners').mockImplementation(() => {});
183
+ vi.spyOn(panel as any, 'startAutoRefresh').mockImplementation(() => {});
184
+ vi.spyOn(panel as any, 'clearTimers').mockImplementation(() => {});
185
+
186
+ await (panel as any).executeOperation(vi.fn());
187
+
188
+ // 第一次 render 调用时 isOperating 必须为 false
189
+ expect(isOperatingAtRender[0]).toBe(false);
190
+ });
191
+ });