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.
- package/.clawt/postCreate.sh +0 -0
- package/dist/index.js +1 -0
- package/docs/status.md +2 -1
- package/package.json +12 -11
- package/scripts/release.sh +2 -2
- package/src/utils/interactive-panel.ts +3 -1
- package/tests/unit/utils/interactive-panel.test.ts +191 -0
package/.clawt/postCreate.sh
CHANGED
|
File without changes
|
package/dist/index.js
CHANGED
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.
|
|
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
|
+
}
|
package/scripts/release.sh
CHANGED
|
@@ -202,9 +202,9 @@ print_success "tag 已创建: ${TAG}"
|
|
|
202
202
|
# ────────────────────────────────────────
|
|
203
203
|
|
|
204
204
|
print_step "发布到 npm..."
|
|
205
|
-
# 临时关闭 set -e,手动捕获
|
|
205
|
+
# 临时关闭 set -e,手动捕获 npm publish 的退出码
|
|
206
206
|
set +e
|
|
207
|
-
NPM_OUTPUT=$(
|
|
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
|
+
});
|