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.
- package/.claude/agent-memory/docs-sync-updater/MEMORY.md +13 -1
- package/README.md +4 -0
- package/dist/index.js +379 -119
- package/dist/postinstall.js +12 -3
- package/docs/spec.md +97 -12
- package/package.json +2 -1
- package/src/commands/sync.ts +34 -14
- package/src/commands/validate.ts +50 -8
- package/src/constants/index.ts +12 -2
- package/src/constants/messages/run.ts +4 -4
- package/src/constants/messages/validate.ts +12 -0
- package/src/constants/progress.ts +36 -6
- package/src/utils/index.ts +2 -0
- package/src/utils/progress-render.ts +77 -13
- package/src/utils/progress.ts +110 -24
- package/src/utils/stream-parser.ts +251 -0
- package/src/utils/task-executor.ts +61 -27
- package/tests/unit/utils/progress-render.test.ts +96 -0
- package/tests/unit/utils/progress.test.ts +391 -10
- package/tests/unit/utils/stream-parser.test.ts +375 -0
|
@@ -1,7 +1,63 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import stringWidth from 'string-width';
|
|
2
3
|
import { TASK_STATUS_ICONS, TASK_STATUS_LABELS } from '../constants/index.js';
|
|
3
4
|
import { formatDuration } from './formatter.js';
|
|
4
5
|
|
|
6
|
+
/** ANSI 颜色/样式转义序列的匹配正则 */
|
|
7
|
+
const ANSI_ESCAPE_RE = /\x1B\[[0-9;]*m/g;
|
|
8
|
+
|
|
9
|
+
/** ANSI 样式重置序列,用于截断后关闭未闭合的颜色 */
|
|
10
|
+
const ANSI_RESET = '\x1B[0m';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 将含 ANSI 转义码的字符串截断到指定可见宽度
|
|
14
|
+
*
|
|
15
|
+
* 逐字符遍历原始字符串,跳过 ANSI 序列不计入可见宽度,
|
|
16
|
+
* 在可见宽度达到上限时截断并追加样式重置序列防止颜色泄漏。
|
|
17
|
+
* @param {string} text - 可能含 ANSI 颜色码的字符串
|
|
18
|
+
* @param {number} maxWidth - 终端可见列数上限
|
|
19
|
+
* @returns {string} 截断后的字符串(不超过 maxWidth 可见列)
|
|
20
|
+
*/
|
|
21
|
+
export function truncateToTerminalWidth(text: string, maxWidth: number): string {
|
|
22
|
+
// 快速路径:实际可见宽度未超限时直接返回
|
|
23
|
+
if (stringWidth(text) <= maxWidth) {
|
|
24
|
+
return text;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let visibleWidth = 0;
|
|
28
|
+
let result = '';
|
|
29
|
+
let i = 0;
|
|
30
|
+
|
|
31
|
+
while (i < text.length) {
|
|
32
|
+
// 检查当前位置是否是 ANSI 转义序列的起始
|
|
33
|
+
if (text[i] === '\x1B' && text[i + 1] === '[') {
|
|
34
|
+
// 匹配完整的 ANSI 序列(格式: ESC [ <数字;...> m)
|
|
35
|
+
const match = text.slice(i).match(/^\x1B\[[0-9;]*m/);
|
|
36
|
+
if (match) {
|
|
37
|
+
// ANSI 序列不占可见宽度,直接保留
|
|
38
|
+
result += match[0];
|
|
39
|
+
i += match[0].length;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 计算当前字符的可见宽度(中文/emoji 为 2,ASCII 为 1)
|
|
45
|
+
const charWidth = stringWidth(text[i]);
|
|
46
|
+
|
|
47
|
+
// 加上当前字符后是否会超出上限
|
|
48
|
+
if (visibleWidth + charWidth > maxWidth) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
result += text[i];
|
|
53
|
+
visibleWidth += charWidth;
|
|
54
|
+
i++;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 追加样式重置,防止截断后颜色泄漏到下一行
|
|
58
|
+
return result + ANSI_RESET;
|
|
59
|
+
}
|
|
60
|
+
|
|
5
61
|
/** 单个任务的进度状态 */
|
|
6
62
|
export interface TaskProgress {
|
|
7
63
|
/** 任务序号(从 1 开始) */
|
|
@@ -22,47 +78,55 @@ export interface TaskProgress {
|
|
|
22
78
|
durationMs: number | null;
|
|
23
79
|
/** 费用(美元),完成后由 ClaudeCodeResult 填入 */
|
|
24
80
|
costUsd: number | null;
|
|
81
|
+
/** 当前活动描述文本(仅 running 状态有效) */
|
|
82
|
+
activity: string | null;
|
|
83
|
+
/** 结果预览文本(完成/失败后显示) */
|
|
84
|
+
resultPreview: string | null;
|
|
25
85
|
}
|
|
26
86
|
|
|
27
87
|
/**
|
|
28
|
-
*
|
|
88
|
+
* 计算路径的最大显示宽度,用于对齐
|
|
29
89
|
* @param {TaskProgress[]} tasks - 任务列表
|
|
30
|
-
* @returns {number}
|
|
90
|
+
* @returns {number} 最大路径长度
|
|
31
91
|
*/
|
|
32
|
-
export function
|
|
33
|
-
return Math.max(...tasks.map((t) => t.
|
|
92
|
+
export function getMaxPathWidth(tasks: TaskProgress[]): number {
|
|
93
|
+
return Math.max(...tasks.map((t) => t.path.length));
|
|
34
94
|
}
|
|
35
95
|
|
|
36
96
|
/**
|
|
37
97
|
* 渲染单个任务行(TTY 模式)
|
|
38
|
-
* 格式: [1/3]
|
|
39
|
-
*
|
|
98
|
+
* 格式: [1/3] /path/to/worktree ⠹ 运行中 1m23s
|
|
99
|
+
* 完成/失败后末尾追加结果预览文本
|
|
40
100
|
* @param {TaskProgress} task - 任务进度
|
|
41
101
|
* @param {number} total - 总任务数
|
|
42
|
-
* @param {number}
|
|
102
|
+
* @param {number} maxPathWidth - 路径最大宽度(用于对齐)
|
|
43
103
|
* @param {string} spinnerChar - 当前 spinner 帧字符
|
|
44
104
|
* @returns {string} 渲染后的单行字符串(含 chalk 颜色)
|
|
45
105
|
*/
|
|
46
|
-
export function renderTaskLine(task: TaskProgress, total: number,
|
|
106
|
+
export function renderTaskLine(task: TaskProgress, total: number, maxPathWidth: number, spinnerChar: string): string {
|
|
47
107
|
const indexStr = `[${task.index}/${total}]`;
|
|
48
|
-
const
|
|
108
|
+
const pathStr = task.path.padEnd(maxPathWidth);
|
|
49
109
|
|
|
50
110
|
switch (task.status) {
|
|
51
111
|
case 'pending': {
|
|
52
|
-
return `${indexStr} ${
|
|
112
|
+
return `${indexStr} ${pathStr} ${chalk.gray(TASK_STATUS_ICONS.PENDING)} ${chalk.gray(TASK_STATUS_LABELS.PENDING)}`;
|
|
53
113
|
}
|
|
54
114
|
case 'running': {
|
|
55
115
|
const elapsed = formatDuration(Date.now() - task.startedAt);
|
|
56
|
-
|
|
116
|
+
// 仅显示活动信息,不显示路径(路径已在第二列显示)
|
|
117
|
+
const detail = task.activity ? ` ${chalk.dim(task.activity)}` : '';
|
|
118
|
+
return `${indexStr} ${pathStr} ${chalk.cyan(spinnerChar)} ${chalk.cyan(TASK_STATUS_LABELS.RUNNING)} ${chalk.gray(elapsed)}${detail}`;
|
|
57
119
|
}
|
|
58
120
|
case 'done': {
|
|
59
121
|
const duration = task.durationMs != null ? formatDuration(task.durationMs) : 'N/A';
|
|
60
122
|
const cost = task.costUsd != null ? `$${task.costUsd.toFixed(2)}` : '';
|
|
61
|
-
|
|
123
|
+
const preview = task.resultPreview ? ` ${chalk.dim(task.resultPreview)}` : '';
|
|
124
|
+
return `${indexStr} ${pathStr} ${chalk.green(TASK_STATUS_ICONS.DONE)} ${chalk.green(TASK_STATUS_LABELS.DONE)} ${chalk.gray(duration)} ${chalk.yellow(cost)}${preview}`;
|
|
62
125
|
}
|
|
63
126
|
case 'failed': {
|
|
64
127
|
const duration = task.durationMs != null ? formatDuration(task.durationMs) : 'N/A';
|
|
65
|
-
|
|
128
|
+
const preview = task.resultPreview ? ` ${chalk.dim(task.resultPreview)}` : '';
|
|
129
|
+
return `${indexStr} ${pathStr} ${chalk.red(TASK_STATUS_ICONS.FAILED)} ${chalk.red(TASK_STATUS_LABELS.FAILED)} ${chalk.gray(duration)}${preview}`;
|
|
66
130
|
}
|
|
67
131
|
}
|
|
68
132
|
}
|
package/src/utils/progress.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SPINNER_FRAMES,
|
|
3
3
|
SPINNER_INTERVAL_MS,
|
|
4
|
-
CURSOR_UP,
|
|
5
|
-
CLEAR_LINE,
|
|
6
4
|
CURSOR_HIDE,
|
|
7
5
|
CURSOR_SHOW,
|
|
6
|
+
DEFAULT_TERMINAL_COLUMNS,
|
|
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,
|
|
8
15
|
MESSAGES,
|
|
9
16
|
} from '../constants/index.js';
|
|
10
17
|
import { formatDuration } from './formatter.js';
|
|
11
18
|
import type { TaskProgress } from './progress-render.js';
|
|
12
|
-
import {
|
|
19
|
+
import { getMaxPathWidth, renderTaskLine, renderSummaryLine, truncateToTerminalWidth } from './progress-render.js';
|
|
13
20
|
|
|
14
21
|
/**
|
|
15
22
|
* 任务进度面板渲染器
|
|
@@ -29,10 +36,12 @@ export class ProgressRenderer {
|
|
|
29
36
|
private timer: ReturnType<typeof setInterval> | null;
|
|
30
37
|
/** 是否为 TTY 环境 */
|
|
31
38
|
private isTTY: boolean;
|
|
32
|
-
/**
|
|
33
|
-
private
|
|
39
|
+
/** resize 事件处理器引用(用于 stop 时移除监听) */
|
|
40
|
+
private resizeHandler: (() => void) | null;
|
|
34
41
|
/** 是否已停止 */
|
|
35
42
|
private stopped: boolean;
|
|
43
|
+
/** exit 兜底处理器(确保异常退出时终端状态被恢复) */
|
|
44
|
+
private exitHandler: (() => void) | null;
|
|
36
45
|
/** 是否存在排队任务(启用汇总行渲染) */
|
|
37
46
|
private hasPendingTasks: boolean;
|
|
38
47
|
|
|
@@ -48,8 +57,9 @@ export class ProgressRenderer {
|
|
|
48
57
|
this.frameIndex = 0;
|
|
49
58
|
this.timer = null;
|
|
50
59
|
this.isTTY = !!process.stdout.isTTY;
|
|
51
|
-
this.
|
|
60
|
+
this.resizeHandler = null;
|
|
52
61
|
this.stopped = false;
|
|
62
|
+
this.exitHandler = null;
|
|
53
63
|
this.hasPendingTasks = !allRunning;
|
|
54
64
|
|
|
55
65
|
this.tasks = branches.map((branch, i) => ({
|
|
@@ -62,6 +72,8 @@ export class ProgressRenderer {
|
|
|
62
72
|
lastActiveAt: allRunning ? now : 0,
|
|
63
73
|
durationMs: null,
|
|
64
74
|
costUsd: null,
|
|
75
|
+
activity: null,
|
|
76
|
+
resultPreview: null,
|
|
65
77
|
}));
|
|
66
78
|
}
|
|
67
79
|
|
|
@@ -83,8 +95,10 @@ export class ProgressRenderer {
|
|
|
83
95
|
return;
|
|
84
96
|
}
|
|
85
97
|
|
|
86
|
-
// TTY
|
|
98
|
+
// TTY 模式:进入备选屏幕缓冲区,隐藏光标,禁用终端自动行换行
|
|
99
|
+
process.stdout.write(ALT_SCREEN_ENTER);
|
|
87
100
|
process.stdout.write(CURSOR_HIDE);
|
|
101
|
+
process.stdout.write(LINE_WRAP_DISABLE);
|
|
88
102
|
this.render();
|
|
89
103
|
this.timer = setInterval(() => {
|
|
90
104
|
this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
|
|
@@ -95,6 +109,22 @@ export class ProgressRenderer {
|
|
|
95
109
|
if (this.timer.unref) {
|
|
96
110
|
this.timer.unref();
|
|
97
111
|
}
|
|
112
|
+
|
|
113
|
+
// 监听终端宽度变化,立即触发重绘
|
|
114
|
+
this.resizeHandler = () => {
|
|
115
|
+
if (!this.stopped) {
|
|
116
|
+
this.render();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
process.stdout.on('resize', this.resizeHandler);
|
|
120
|
+
|
|
121
|
+
// 注册 exit 兜底,确保异常退出时终端状态被恢复
|
|
122
|
+
this.exitHandler = () => {
|
|
123
|
+
process.stdout.write(LINE_WRAP_ENABLE);
|
|
124
|
+
process.stdout.write(CURSOR_SHOW);
|
|
125
|
+
process.stdout.write(ALT_SCREEN_LEAVE);
|
|
126
|
+
};
|
|
127
|
+
process.on('exit', this.exitHandler);
|
|
98
128
|
}
|
|
99
129
|
|
|
100
130
|
/**
|
|
@@ -106,6 +136,18 @@ export class ProgressRenderer {
|
|
|
106
136
|
this.tasks[index].lastActiveAt = Date.now();
|
|
107
137
|
}
|
|
108
138
|
|
|
139
|
+
/**
|
|
140
|
+
* 更新指定任务的活动描述文本
|
|
141
|
+
* 同时更新 lastActiveAt 时间戳
|
|
142
|
+
* @param {number} index - 任务索引(从 0 开始)
|
|
143
|
+
* @param {string} text - 活动描述文本
|
|
144
|
+
*/
|
|
145
|
+
updateActivityText(index: number, text: string): void {
|
|
146
|
+
const task = this.tasks[index];
|
|
147
|
+
task.activity = text;
|
|
148
|
+
task.lastActiveAt = Date.now();
|
|
149
|
+
}
|
|
150
|
+
|
|
109
151
|
/**
|
|
110
152
|
* 标记指定任务为运行中状态
|
|
111
153
|
* 将 pending 任务标记为 running 并设置启动时间戳
|
|
@@ -129,19 +171,21 @@ export class ProgressRenderer {
|
|
|
129
171
|
* @param {number} index - 任务索引(从 0 开始)
|
|
130
172
|
* @param {number} durationMs - 耗时(毫秒)
|
|
131
173
|
* @param {number} costUsd - 费用(美元)
|
|
174
|
+
* @param {string} [resultPreview] - 结果预览文本(可选)
|
|
132
175
|
*/
|
|
133
|
-
markDone(index: number, durationMs: number, costUsd: number): void {
|
|
176
|
+
markDone(index: number, durationMs: number, costUsd: number, resultPreview?: string): void {
|
|
134
177
|
const task = this.tasks[index];
|
|
135
178
|
task.status = 'done';
|
|
136
179
|
task.finishedAt = Date.now();
|
|
137
180
|
task.durationMs = durationMs;
|
|
138
181
|
task.costUsd = costUsd;
|
|
182
|
+
task.resultPreview = resultPreview ?? null;
|
|
139
183
|
|
|
140
184
|
if (!this.isTTY) {
|
|
141
185
|
// 非 TTY 降级:直接输出完成信息
|
|
142
186
|
const duration = formatDuration(durationMs);
|
|
143
187
|
const cost = `$${costUsd.toFixed(2)}`;
|
|
144
|
-
console.log(MESSAGES.PROGRESS_TASK_DONE(task.index, this.total, task.branch, duration, cost, task.path));
|
|
188
|
+
console.log(MESSAGES.PROGRESS_TASK_DONE(task.index, this.total, task.branch, duration, cost, task.resultPreview ?? task.path));
|
|
145
189
|
}
|
|
146
190
|
}
|
|
147
191
|
|
|
@@ -149,17 +193,19 @@ export class ProgressRenderer {
|
|
|
149
193
|
* 标记指定任务为失败状态
|
|
150
194
|
* @param {number} index - 任务索引(从 0 开始)
|
|
151
195
|
* @param {number} durationMs - 耗时(毫秒)
|
|
196
|
+
* @param {string} [resultPreview] - 结果预览文本(可选)
|
|
152
197
|
*/
|
|
153
|
-
markFailed(index: number, durationMs: number): void {
|
|
198
|
+
markFailed(index: number, durationMs: number, resultPreview?: string): void {
|
|
154
199
|
const task = this.tasks[index];
|
|
155
200
|
task.status = 'failed';
|
|
156
201
|
task.finishedAt = Date.now();
|
|
157
202
|
task.durationMs = durationMs;
|
|
203
|
+
task.resultPreview = resultPreview ?? null;
|
|
158
204
|
|
|
159
205
|
if (!this.isTTY) {
|
|
160
206
|
// 非 TTY 降级:直接输出失败信息
|
|
161
207
|
const duration = formatDuration(durationMs);
|
|
162
|
-
console.log(MESSAGES.PROGRESS_TASK_FAILED(task.index, this.total, task.branch, duration, task.path));
|
|
208
|
+
console.log(MESSAGES.PROGRESS_TASK_FAILED(task.index, this.total, task.branch, duration, task.resultPreview ?? task.path));
|
|
163
209
|
}
|
|
164
210
|
}
|
|
165
211
|
|
|
@@ -176,38 +222,78 @@ export class ProgressRenderer {
|
|
|
176
222
|
this.timer = null;
|
|
177
223
|
}
|
|
178
224
|
|
|
225
|
+
// 移除终端 resize 监听
|
|
226
|
+
if (this.resizeHandler) {
|
|
227
|
+
process.stdout.removeListener('resize', this.resizeHandler);
|
|
228
|
+
this.resizeHandler = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
179
231
|
if (this.isTTY) {
|
|
180
|
-
//
|
|
232
|
+
// 最后在备选屏幕渲染一次,确保最终状态显示正确
|
|
181
233
|
this.render();
|
|
234
|
+
// 恢复终端自动行换行
|
|
235
|
+
process.stdout.write(LINE_WRAP_ENABLE);
|
|
182
236
|
// 恢复光标显示
|
|
183
237
|
process.stdout.write(CURSOR_SHOW);
|
|
238
|
+
// 退出备选屏幕缓冲区,恢复主屏幕内容
|
|
239
|
+
process.stdout.write(ALT_SCREEN_LEAVE);
|
|
240
|
+
|
|
241
|
+
// 在主屏幕上输出最终面板状态(备选屏幕内容不保留,需要重新输出)
|
|
242
|
+
const cols = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
|
|
243
|
+
const finalLines = this.buildLines(cols);
|
|
244
|
+
for (const line of finalLines) {
|
|
245
|
+
process.stdout.write(`${line}\n`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 移除 exit 兜底
|
|
250
|
+
if (this.exitHandler) {
|
|
251
|
+
process.removeListener('exit', this.exitHandler);
|
|
252
|
+
this.exitHandler = null;
|
|
184
253
|
}
|
|
185
254
|
}
|
|
186
255
|
|
|
187
256
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
257
|
+
* 构建当前帧的面板行内容
|
|
258
|
+
* @param {number} cols - 终端列数
|
|
259
|
+
* @returns {string[]} 截断后的面板行数组
|
|
190
260
|
*/
|
|
191
|
-
private
|
|
192
|
-
const
|
|
261
|
+
private buildLines(cols: number): string[] {
|
|
262
|
+
const maxPathWidth = getMaxPathWidth(this.tasks);
|
|
193
263
|
const spinnerChar = SPINNER_FRAMES[this.frameIndex];
|
|
194
|
-
const lines = this.tasks.map((task) => renderTaskLine(task, this.total,
|
|
264
|
+
const lines = this.tasks.map((task) => renderTaskLine(task, this.total, maxPathWidth, spinnerChar));
|
|
195
265
|
|
|
196
266
|
// 存在排队任务时追加汇总行
|
|
197
267
|
if (this.hasPendingTasks) {
|
|
198
268
|
lines.push(renderSummaryLine(this.tasks, this.total));
|
|
199
269
|
}
|
|
200
270
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
271
|
+
return lines.map((line) => truncateToTerminalWidth(line, cols));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 执行一次完整的面板渲染
|
|
276
|
+
* 在备选屏幕缓冲区中,每次清屏+归位后完全重绘
|
|
277
|
+
* 无需计算 CURSOR_UP 回退量,不受终端 reflow 影响
|
|
278
|
+
* 使用 Synchronized Output 防止多行写入时的闪烁
|
|
279
|
+
*/
|
|
280
|
+
private render(): void {
|
|
281
|
+
const cols = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
|
|
282
|
+
const lines = this.buildLines(cols);
|
|
283
|
+
|
|
284
|
+
// 开启同步输出(终端缓冲输出,关闭时一次性刷新,防止闪烁)
|
|
285
|
+
process.stdout.write(SYNC_OUTPUT_START);
|
|
286
|
+
|
|
287
|
+
// 备选屏幕无滚动缓冲区,直接清屏+归位即可,无需计算回退量
|
|
288
|
+
process.stdout.write(CLEAR_SCREEN);
|
|
289
|
+
process.stdout.write(CURSOR_HOME);
|
|
205
290
|
|
|
206
|
-
//
|
|
291
|
+
// 逐行输出,按终端宽度截断
|
|
207
292
|
for (const line of lines) {
|
|
208
|
-
process.stdout.write(`${line}
|
|
293
|
+
process.stdout.write(`${line}\n`);
|
|
209
294
|
}
|
|
210
295
|
|
|
211
|
-
|
|
296
|
+
// 关闭同步输出
|
|
297
|
+
process.stdout.write(SYNC_OUTPUT_END);
|
|
212
298
|
}
|
|
213
299
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { ClaudeCodeResult } from '../types/index.js';
|
|
3
|
+
import {
|
|
4
|
+
ACTIVITY_TEXT_MAX_LENGTH,
|
|
5
|
+
TEXT_ACTIVITY_PREFIX,
|
|
6
|
+
} from '../constants/index.js';
|
|
7
|
+
|
|
8
|
+
// ============================================================
|
|
9
|
+
// 类型定义
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
/** stream-json content 数组中的单个块 */
|
|
13
|
+
interface StreamContentBlock {
|
|
14
|
+
/** 内容类型:tool_use / text / tool_result */
|
|
15
|
+
type: string;
|
|
16
|
+
/** 工具名称(tool_use 时) */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** 工具调用参数(tool_use 时) */
|
|
19
|
+
input?: Record<string, unknown>;
|
|
20
|
+
/** 文本内容(text 时) */
|
|
21
|
+
text?: string;
|
|
22
|
+
/** 工具执行结果内容(tool_result 时) */
|
|
23
|
+
content?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** stream-json 单行事件中 assistant/user 消息的 message 结构 */
|
|
27
|
+
interface StreamMessage {
|
|
28
|
+
content?: StreamContentBlock[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** stream-json 单行事件的统一结构 */
|
|
32
|
+
export interface StreamEvent {
|
|
33
|
+
/** 事件类型:system / assistant / user / result */
|
|
34
|
+
type: string;
|
|
35
|
+
/** 事件子类型 */
|
|
36
|
+
subtype?: string;
|
|
37
|
+
/** assistant/user 消息体 */
|
|
38
|
+
message?: StreamMessage;
|
|
39
|
+
/** user 消息的 content(tool_result 场景) */
|
|
40
|
+
content?: StreamContentBlock[];
|
|
41
|
+
// --- result 类型特有字段 ---
|
|
42
|
+
/** 是否为错误结果 */
|
|
43
|
+
is_error?: boolean;
|
|
44
|
+
/** 总耗时(毫秒) */
|
|
45
|
+
duration_ms?: number;
|
|
46
|
+
/** API 调用耗时(毫秒) */
|
|
47
|
+
duration_api_ms?: number;
|
|
48
|
+
/** 交互轮次数 */
|
|
49
|
+
num_turns?: number;
|
|
50
|
+
/** 会话结果文本 */
|
|
51
|
+
result?: string;
|
|
52
|
+
/** 停止原因 */
|
|
53
|
+
stop_reason?: string;
|
|
54
|
+
/** 会话 ID */
|
|
55
|
+
session_id?: string;
|
|
56
|
+
/** 总费用(美元) */
|
|
57
|
+
total_cost_usd?: number;
|
|
58
|
+
/** 使用量统计 */
|
|
59
|
+
usage?: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 解析出的活动信息 */
|
|
63
|
+
export interface ParsedActivity {
|
|
64
|
+
/** 活动类型 */
|
|
65
|
+
kind: 'tool_use' | 'text' | 'result';
|
|
66
|
+
/** 格式化后的活动描述文本(已截断到 ACTIVITY_TEXT_MAX_LENGTH) */
|
|
67
|
+
activityText: string;
|
|
68
|
+
/** 最终结果对象(仅 kind=result 时有值) */
|
|
69
|
+
result?: ClaudeCodeResult;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 行缓冲器,处理 data 事件中的不完整行 */
|
|
73
|
+
export interface LineBuffer {
|
|
74
|
+
/** 追加数据并返回完整的行 */
|
|
75
|
+
push(chunk: string): string[];
|
|
76
|
+
/** 获取缓冲区中剩余的不完整数据(流结束时调用) */
|
|
77
|
+
flush(): string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================
|
|
81
|
+
// 核心函数
|
|
82
|
+
// ============================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 创建行缓冲器,用于处理流式数据中的不完整行
|
|
86
|
+
* stdout 的 data 事件可能在行中间切割,需要缓冲直到遇到换行符
|
|
87
|
+
* @returns {LineBuffer} 行缓冲器实例
|
|
88
|
+
*/
|
|
89
|
+
export function createLineBuffer(): LineBuffer {
|
|
90
|
+
let buffer = '';
|
|
91
|
+
return {
|
|
92
|
+
push(chunk: string): string[] {
|
|
93
|
+
buffer += chunk;
|
|
94
|
+
const lines = buffer.split('\n');
|
|
95
|
+
// 最后一个元素可能是不完整的行,保留在缓冲区
|
|
96
|
+
buffer = lines.pop() ?? '';
|
|
97
|
+
return lines;
|
|
98
|
+
},
|
|
99
|
+
flush(): string | null {
|
|
100
|
+
const remaining = buffer;
|
|
101
|
+
buffer = '';
|
|
102
|
+
return remaining || null;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 解析单行 stream-json 输出为事件对象
|
|
109
|
+
* 忽略空行和非 JSON 行
|
|
110
|
+
* @param {string} line - 单行文本
|
|
111
|
+
* @returns {StreamEvent | null} 解析成功返回事件对象,失败返回 null
|
|
112
|
+
*/
|
|
113
|
+
export function parseStreamLine(line: string): StreamEvent | null {
|
|
114
|
+
const trimmed = line.trim();
|
|
115
|
+
if (!trimmed) return null;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(trimmed) as StreamEvent;
|
|
119
|
+
} catch {
|
|
120
|
+
// 非 JSON 行(verbose 模式可能有额外日志),静默忽略
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 将文本截断到指定最大长度,超长部分用省略号替代
|
|
127
|
+
* @param {string} text - 原始文本
|
|
128
|
+
* @param {number} maxLength - 最大字符数
|
|
129
|
+
* @returns {string} 截断后的文本
|
|
130
|
+
*/
|
|
131
|
+
export function truncateText(text: string, maxLength: number): string {
|
|
132
|
+
if (text.length <= maxLength) return text;
|
|
133
|
+
return text.substring(0, maxLength - 1) + '…';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 根据活动类型和参数生成活动描述文本
|
|
138
|
+
* tool_use 类型直接使用英文工具名,有 file_path 参数时追加文件名
|
|
139
|
+
* 输出最大长度为 ACTIVITY_TEXT_MAX_LENGTH(30 字符),超出截断并追加省略号
|
|
140
|
+
* @param {'tool_use' | 'text'} kind - 活动类型
|
|
141
|
+
* @param {string} [toolName] - 工具名(tool_use 时)
|
|
142
|
+
* @param {Record<string, unknown>} [input] - 工具参数(tool_use 时)
|
|
143
|
+
* @param {string} [text] - 文本内容(text 时)
|
|
144
|
+
* @returns {string} 格式化后的活动描述文本
|
|
145
|
+
*/
|
|
146
|
+
export function formatActivityText(
|
|
147
|
+
kind: 'tool_use' | 'text',
|
|
148
|
+
toolName?: string,
|
|
149
|
+
input?: Record<string, unknown>,
|
|
150
|
+
text?: string,
|
|
151
|
+
): string {
|
|
152
|
+
let raw = '';
|
|
153
|
+
|
|
154
|
+
if (kind === 'tool_use' && toolName) {
|
|
155
|
+
// 对有文件路径参数的工具,追加文件名
|
|
156
|
+
const filePath = input?.file_path as string | undefined;
|
|
157
|
+
// 对有 command 参数的工具(如 Bash),追加命令内容
|
|
158
|
+
const command = input?.command as string | undefined;
|
|
159
|
+
if (filePath) {
|
|
160
|
+
const basename = path.basename(filePath);
|
|
161
|
+
raw = `${toolName} ${basename}`;
|
|
162
|
+
} else if (command) {
|
|
163
|
+
// 清理命令中的换行和多余空白
|
|
164
|
+
const cleaned = command.replace(/[\n\r\t]+/g, ' ').trim();
|
|
165
|
+
raw = `${toolName} ${cleaned}`;
|
|
166
|
+
} else {
|
|
167
|
+
raw = toolName;
|
|
168
|
+
}
|
|
169
|
+
} else if (kind === 'text' && text) {
|
|
170
|
+
// 清理控制字符和换行
|
|
171
|
+
const cleaned = text.replace(/[\n\r\t]/g, ' ').trim();
|
|
172
|
+
if (!cleaned) return '';
|
|
173
|
+
raw = `${TEXT_ACTIVITY_PREFIX}: ${cleaned}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!raw) return '';
|
|
177
|
+
|
|
178
|
+
return truncateText(raw, ACTIVITY_TEXT_MAX_LENGTH);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 从流式事件中提取活动信息
|
|
183
|
+
* 仅处理以下有意义的事件:
|
|
184
|
+
* 1. type=assistant + content 含 tool_use → 提取工具名和参数
|
|
185
|
+
* 2. type=assistant + content 含 text → 提取文本片段
|
|
186
|
+
* 3. type=result → 最终结果,构造 ClaudeCodeResult
|
|
187
|
+
* @param {StreamEvent} event - 流式事件对象
|
|
188
|
+
* @returns {ParsedActivity | null} 提取的活动信息,无关事件返回 null
|
|
189
|
+
*/
|
|
190
|
+
export function parseStreamEvent(event: StreamEvent): ParsedActivity | null {
|
|
191
|
+
// 最终结果事件
|
|
192
|
+
if (event.type === 'result') {
|
|
193
|
+
const result: ClaudeCodeResult = {
|
|
194
|
+
type: event.type,
|
|
195
|
+
subtype: event.subtype ?? '',
|
|
196
|
+
is_error: event.is_error ?? false,
|
|
197
|
+
duration_ms: event.duration_ms ?? 0,
|
|
198
|
+
duration_api_ms: event.duration_api_ms ?? 0,
|
|
199
|
+
num_turns: event.num_turns ?? 0,
|
|
200
|
+
result: event.result ?? '',
|
|
201
|
+
stop_reason: event.stop_reason ?? '',
|
|
202
|
+
session_id: event.session_id ?? '',
|
|
203
|
+
total_cost_usd: event.total_cost_usd ?? 0,
|
|
204
|
+
usage: event.usage ?? {},
|
|
205
|
+
};
|
|
206
|
+
return { kind: 'result', activityText: '', result };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// assistant 消息:从 message.content 中提取
|
|
210
|
+
if (event.type === 'assistant') {
|
|
211
|
+
const content = event.message?.content;
|
|
212
|
+
if (!content || content.length === 0) return null;
|
|
213
|
+
|
|
214
|
+
// 优先查找 tool_use(取最后一个,代表最新操作)
|
|
215
|
+
let lastToolUse: StreamContentBlock | null = null;
|
|
216
|
+
let lastText: StreamContentBlock | null = null;
|
|
217
|
+
|
|
218
|
+
for (const block of content) {
|
|
219
|
+
if (block.type === 'tool_use') {
|
|
220
|
+
lastToolUse = block;
|
|
221
|
+
} else if (block.type === 'text') {
|
|
222
|
+
lastText = block;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// tool_use 优先级高于 text
|
|
227
|
+
if (lastToolUse) {
|
|
228
|
+
const activityText = formatActivityText(
|
|
229
|
+
'tool_use',
|
|
230
|
+
lastToolUse.name,
|
|
231
|
+
lastToolUse.input,
|
|
232
|
+
);
|
|
233
|
+
if (activityText) {
|
|
234
|
+
return { kind: 'tool_use', activityText };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (lastText?.text) {
|
|
239
|
+
const activityText = formatActivityText('text', undefined, undefined, lastText.text);
|
|
240
|
+
if (activityText) {
|
|
241
|
+
return { kind: 'text', activityText };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// user 消息中的 tool_result 不更新活动文本(紧接着会有下一个 assistant 消息)
|
|
249
|
+
// 其他类型(system 等)忽略
|
|
250
|
+
return null;
|
|
251
|
+
}
|