clawt 2.16.3 → 2.16.5

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.
@@ -7,6 +7,8 @@ import { cleanupWorktrees } from './worktree.js';
7
7
  import { getConfigValue } from './config.js';
8
8
  import { printSuccess, printWarning, printInfo, printDoubleSeparator, confirmAction } from './formatter.js';
9
9
  import { ProgressRenderer } from './progress.js';
10
+ import { createLineBuffer, parseStreamLine, parseStreamEvent, truncateText } from './stream-parser.js';
11
+ import { RESULT_PREVIEW_MAX_LENGTH } from '../constants/index.js';
10
12
 
11
13
  /** executeClaudeTask 的返回结构,包含子进程引用和结果 Promise */
12
14
  interface ClaudeTaskHandle {
@@ -17,15 +19,23 @@ interface ClaudeTaskHandle {
17
19
  }
18
20
 
19
21
  /**
20
- * 在指定 worktree 中执行 Claude Code 任务,由于是--output-format json形式,所以这里固定claude code cli的启动命令
22
+ * 活动更新回调函数类型
23
+ * @param {string} activityText - 活动描述文本
24
+ */
25
+ type ActivityCallback = (activityText: string) => void;
26
+
27
+ /**
28
+ * 在指定 worktree 中执行 Claude Code 任务,使用 stream-json 格式获取实时事件
21
29
  * @param {WorktreeInfo} worktree - worktree 信息
22
30
  * @param {string} task - 任务描述
31
+ * @param {ActivityCallback} [onActivity] - 活动更新回调(可选)
23
32
  * @returns {ClaudeTaskHandle} 包含子进程引用和结果 Promise
24
33
  */
25
- function executeClaudeTask(worktree: WorktreeInfo, task: string): ClaudeTaskHandle {
34
+ function executeClaudeTask(worktree: WorktreeInfo, task: string, onActivity?: ActivityCallback): ClaudeTaskHandle {
35
+ // 旧版使用 --output-format json,现改为 stream-json --verbose 以支持实时活动信息
26
36
  const child = spawnProcess(
27
37
  'claude',
28
- ['-p', task, '--output-format', 'json', '--permission-mode', 'bypassPermissions'],
38
+ ['-p', task, '--output-format', 'stream-json', '--verbose', '--permission-mode', 'bypassPermissions'],
29
39
  {
30
40
  cwd: worktree.path,
31
41
  // stdin 必须设置为 'ignore',不能用 'pipe'
@@ -37,11 +47,27 @@ function executeClaudeTask(worktree: WorktreeInfo, task: string): ClaudeTaskHand
37
47
  );
38
48
 
39
49
  const promise = new Promise<TaskResult>((resolve) => {
40
- let stdout = '';
41
50
  let stderr = '';
51
+ let finalResult: ClaudeCodeResult | null = null;
52
+ const lineBuffer = createLineBuffer();
42
53
 
43
54
  child.stdout?.on('data', (data: Buffer) => {
44
- stdout += data.toString();
55
+ const lines = lineBuffer.push(data.toString());
56
+ for (const line of lines) {
57
+ const event = parseStreamLine(line);
58
+ if (!event) continue;
59
+
60
+ const parsed = parseStreamEvent(event);
61
+ if (!parsed) continue;
62
+
63
+ if (parsed.kind === 'result') {
64
+ // 最终结果事件
65
+ finalResult = parsed.result ?? null;
66
+ } else if (parsed.activityText && onActivity) {
67
+ // 活动事件,回调通知
68
+ onActivity(parsed.activityText);
69
+ }
70
+ }
45
71
  });
46
72
 
47
73
  child.stderr?.on('data', (data: Buffer) => {
@@ -49,16 +75,21 @@ function executeClaudeTask(worktree: WorktreeInfo, task: string): ClaudeTaskHand
49
75
  });
50
76
 
51
77
  child.on('close', (code) => {
52
- let result: ClaudeCodeResult | null = null;
53
- let success = code === 0;
54
-
55
- try {
56
- if (stdout.trim()) {
57
- result = JSON.parse(stdout.trim()) as ClaudeCodeResult;
58
- success = !result.is_error;
78
+ // 处理缓冲区中可能残留的最后一行
79
+ const remaining = lineBuffer.flush();
80
+ if (remaining) {
81
+ const event = parseStreamLine(remaining);
82
+ if (event) {
83
+ const parsed = parseStreamEvent(event);
84
+ if (parsed?.kind === 'result') {
85
+ finalResult = parsed.result ?? null;
86
+ }
59
87
  }
60
- } catch {
61
- logger.warn(`解析 Claude Code 输出失败: ${stdout.substring(0, 200)}`);
88
+ }
89
+
90
+ let success = code === 0;
91
+ if (finalResult) {
92
+ success = !finalResult.is_error;
62
93
  }
63
94
 
64
95
  resolve({
@@ -66,7 +97,7 @@ function executeClaudeTask(worktree: WorktreeInfo, task: string): ClaudeTaskHand
66
97
  branch: worktree.branch,
67
98
  worktreePath: worktree.path,
68
99
  success,
69
- result,
100
+ result: finalResult,
70
101
  error: success ? undefined : stderr || '任务执行失败',
71
102
  });
72
103
  });
@@ -134,16 +165,25 @@ async function handleInterruptCleanup(worktrees: WorktreeInfo[]): Promise<void>
134
165
  * @param {number} startTime - 任务批次启动时间戳
135
166
  */
136
167
  function updateRendererStatus(renderer: ProgressRenderer, index: number, result: TaskResult, startTime: number): void {
168
+ // 从 ClaudeCodeResult.result 中提取结果预览文本
169
+ // 先清理换行符和多余空白(结果文本可能包含多行),再截断到最大长度
170
+ const rawResultText = result.result?.result;
171
+ const resultPreview = rawResultText
172
+ ? truncateText(rawResultText.replace(/[\n\r\t]+/g, ' ').trim(), RESULT_PREVIEW_MAX_LENGTH)
173
+ : undefined;
174
+
137
175
  if (result.success) {
138
176
  renderer.markDone(
139
177
  index,
140
178
  result.result?.duration_ms ?? (Date.now() - startTime),
141
179
  result.result?.total_cost_usd ?? 0,
180
+ resultPreview,
142
181
  );
143
182
  } else {
144
183
  renderer.markFailed(
145
184
  index,
146
185
  result.result?.duration_ms ?? (Date.now() - startTime),
186
+ resultPreview,
147
187
  );
148
188
  }
149
189
  }
@@ -192,13 +232,10 @@ async function executeWithConcurrency(
192
232
  // 标记为运行中
193
233
  renderer.markRunning(index);
194
234
 
195
- const handle = executeClaudeTask(wt, task);
196
- childProcesses.push(handle.child);
197
-
198
- // 监听 stderr 输出,更新任务活动时间戳
199
- handle.child.stderr?.on('data', () => {
200
- renderer.updateActivity(index);
235
+ const handle = executeClaudeTask(wt, task, (activityText) => {
236
+ renderer.updateActivityText(index, activityText);
201
237
  });
238
+ childProcesses.push(handle.child);
202
239
 
203
240
  handle.promise.then((result) => {
204
241
  results[index] = result;
@@ -248,13 +285,10 @@ async function executeAllParallel(
248
285
  const handles = worktrees.map((wt, index) => {
249
286
  const task = tasks[index];
250
287
  logger.info(`启动任务 ${index + 1}: ${task} (worktree: ${wt.path})`);
251
- const handle = executeClaudeTask(wt, task);
252
- childProcesses.push(handle.child);
253
-
254
- // 监听 stderr 输出,更新任务活动时间戳
255
- handle.child.stderr?.on('data', () => {
256
- renderer.updateActivity(index);
288
+ const handle = executeClaudeTask(wt, task, (activityText) => {
289
+ renderer.updateActivityText(index, activityText);
257
290
  });
291
+ childProcesses.push(handle.child);
258
292
 
259
293
  return handle;
260
294
  });
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import chalk from 'chalk';
3
+ import { truncateToTerminalWidth } from '../../../src/utils/progress-render.js';
4
+
5
+ describe('truncateToTerminalWidth', () => {
6
+ describe('纯文本截断', () => {
7
+ it('文本未超限时原样返回', () => {
8
+ const text = 'hello world';
9
+ const result = truncateToTerminalWidth(text, 20);
10
+ expect(result).toBe('hello world');
11
+ });
12
+
13
+ it('文本恰好等于宽度时原样返回', () => {
14
+ const text = 'abcde';
15
+ const result = truncateToTerminalWidth(text, 5);
16
+ expect(result).toBe('abcde');
17
+ });
18
+
19
+ it('文本超限时截断到指定宽度', () => {
20
+ const text = 'hello world, this is a long string';
21
+ const result = truncateToTerminalWidth(text, 11);
22
+ // 截断后追加 ANSI 重置序列
23
+ expect(result).toBe('hello world\x1B[0m');
24
+ });
25
+
26
+ it('空字符串直接返回', () => {
27
+ const result = truncateToTerminalWidth('', 10);
28
+ expect(result).toBe('');
29
+ });
30
+
31
+ it('宽度为 0 时返回空内容加重置', () => {
32
+ const result = truncateToTerminalWidth('abc', 0);
33
+ expect(result).toBe('\x1B[0m');
34
+ });
35
+ });
36
+
37
+ describe('含 ANSI 颜色码的截断', () => {
38
+ it('含颜色的文本未超限时原样返回', () => {
39
+ // chalk.red('hi') 的可见宽度只有 2
40
+ const text = chalk.red('hi');
41
+ const result = truncateToTerminalWidth(text, 10);
42
+ expect(result).toBe(text);
43
+ });
44
+
45
+ it('含颜色的文本超限时正确截断', () => {
46
+ // 构造一个已知可见宽度的带颜色字符串
47
+ const text = chalk.red('abcdefghij'); // 可见宽度 10
48
+ const result = truncateToTerminalWidth(text, 5);
49
+ // 截断后的可见内容应只有 5 个字符
50
+ const stripped = result.replace(/\x1B\[[0-9;]*m/g, '');
51
+ expect(stripped.length).toBe(5);
52
+ expect(stripped).toBe('abcde');
53
+ });
54
+
55
+ it('多段颜色混合时正确截断', () => {
56
+ const text = chalk.red('abc') + chalk.green('defgh');
57
+ const result = truncateToTerminalWidth(text, 5);
58
+ const stripped = result.replace(/\x1B\[[0-9;]*m/g, '');
59
+ expect(stripped.length).toBe(5);
60
+ expect(stripped).toBe('abcde');
61
+ });
62
+
63
+ it('截断后追加 ANSI 重置序列', () => {
64
+ const text = chalk.red('abcdefghij');
65
+ const result = truncateToTerminalWidth(text, 5);
66
+ // 应以 \x1B[0m 结尾
67
+ expect(result).toMatch(/\x1B\[0m$/);
68
+ });
69
+ });
70
+
71
+ describe('中文/宽字符截断', () => {
72
+ it('中文字符占 2 列宽度', () => {
73
+ const text = '你好世界测试';
74
+ const result = truncateToTerminalWidth(text, 8);
75
+ // 4 个中文字符 = 8 列宽
76
+ const stripped = result.replace(/\x1B\[[0-9;]*m/g, '');
77
+ expect(stripped).toBe('你好世界');
78
+ });
79
+
80
+ it('宽度刚好不够放下一个中文字符时不截断一半', () => {
81
+ const text = '你好世界';
82
+ const result = truncateToTerminalWidth(text, 5);
83
+ // 5 列只能放 2 个中文字符(4列),第 3 个字符需要 6 列放不下
84
+ const stripped = result.replace(/\x1B\[[0-9;]*m/g, '');
85
+ expect(stripped).toBe('你好');
86
+ });
87
+
88
+ it('中英混合文本正确截断', () => {
89
+ const text = 'hi你好world';
90
+ const result = truncateToTerminalWidth(text, 6);
91
+ // 'h'=1, 'i'=1, '你'=2, '好'=2 => 总计 6
92
+ const stripped = result.replace(/\x1B\[[0-9;]*m/g, '');
93
+ expect(stripped).toBe('hi你好');
94
+ });
95
+ });
96
+ });