clawt 2.16.4 → 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/dist/index.js +281 -57
- package/dist/postinstall.js +2 -2
- package/docs/spec.md +49 -4
- package/package.json +2 -1
- package/src/constants/index.ts +12 -2
- package/src/constants/messages/run.ts +4 -4
- 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
package/docs/spec.md
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
| CLI 框架 | Commander.js |
|
|
47
47
|
| 日志库 | winston (按日期滚动文件) |
|
|
48
48
|
| 交互式 | enquirer (选项选择/确认对话) |
|
|
49
|
+
| 终端宽度 | string-width (ANSI 安全的字符宽度计算) |
|
|
49
50
|
| 测试 | Vitest + @vitest/coverage-v8 |
|
|
50
51
|
| 构建 | tsup / tsc |
|
|
51
52
|
| 分发 | pnpm 全局安装 (`pnpm add -g clawt`) |
|
|
@@ -338,8 +339,9 @@ clawt run -b <branchName>
|
|
|
338
339
|
4. 通过公共函数 `executeBatchTasks`(`src/utils/task-executor.ts`)启动批量任务执行,该函数负责进度面板渲染、SIGINT 中断处理、并发控制和汇总输出。对每个 worktree 并行启动 Claude Code CLI:
|
|
339
340
|
```bash
|
|
340
341
|
cd ~/.clawt/worktrees/<project>/<branchName>-<i>
|
|
341
|
-
claude -p "<tasks[i]>" --output-format json --permission-mode bypassPermissions
|
|
342
|
+
claude -p "<tasks[i]>" --output-format stream-json --verbose --permission-mode bypassPermissions
|
|
342
343
|
```
|
|
344
|
+
使用 `stream-json` 格式可实时获取 Claude Code 的流式事件(工具调用、文本输出、最终结果),用于在进度面板中显示每个任务的实时活动描述和结果预览。流式事件解析由 `src/utils/stream-parser.ts` 负责。
|
|
343
345
|
5. 进入**事件监听通知**阶段(见 [5.3](#53-任务完成通知机制))
|
|
344
346
|
6. **中断处理(Ctrl+C / SIGINT)**
|
|
345
347
|
- 监听 `SIGINT` 信号,用户按下 Ctrl+C 时触发
|
|
@@ -405,7 +407,7 @@ clawt run -b <branchName>
|
|
|
405
407
|
|
|
406
408
|
**机制说明:**
|
|
407
409
|
|
|
408
|
-
Claude Code CLI 以 `--output-format json`
|
|
410
|
+
Claude Code CLI 以 `--output-format stream-json --verbose` 运行时,stdout 会持续输出 JSON 行(每行一个事件),包括 `system`、`assistant`(含 `tool_use` 和 `text`)、`user`(含 `tool_result`)等类型。任务结束时输出 `type: "result"` 事件:
|
|
409
411
|
|
|
410
412
|
```json
|
|
411
413
|
{
|
|
@@ -423,12 +425,22 @@ Claude Code CLI 以 `--output-format json` 运行时,退出后会在 stdout
|
|
|
423
425
|
}
|
|
424
426
|
```
|
|
425
427
|
|
|
428
|
+
**流式事件解析(`src/utils/stream-parser.ts`):**
|
|
429
|
+
|
|
430
|
+
由于 stdout 的 `data` 事件可能在行中间切割,使用 `createLineBuffer()` 行缓冲器拼接完整行后,通过 `parseStreamLine()` 解析为 `StreamEvent` 对象,再由 `parseStreamEvent()` 提取活动信息(`ParsedActivity`):
|
|
431
|
+
|
|
432
|
+
- **`tool_use` 类型**:提取工具名和文件路径/命令参数,格式如 `Read index.ts`、`Bash ls -la`
|
|
433
|
+
- **`text` 类型**:提取文本片段,格式如 `思考中: 让我分析一下`
|
|
434
|
+
- **`result` 类型**:构造 `ClaudeCodeResult` 对象,提取耗时、费用、结果文本等
|
|
435
|
+
|
|
436
|
+
活动描述文本最大长度为 `ACTIVITY_TEXT_MAX_LENGTH`(30 字符),超出后截断并追加省略号。结果预览文本最大长度为 `RESULT_PREVIEW_MAX_LENGTH`(40 字符)。
|
|
437
|
+
|
|
426
438
|
**事件监听与通知流程:**
|
|
427
439
|
|
|
428
440
|
1. 为每个 Claude Code 子进程维护状态(运行中 / 已完成 / 已失败)
|
|
429
441
|
2. 监听每个子进程的 `close` 事件(基于 Node.js `ChildProcess` 的事件驱动机制)
|
|
430
|
-
3. 当某个子进程触发 `close`
|
|
431
|
-
4. 在主 worktree 的 clawt
|
|
442
|
+
3. 当某个子进程触发 `close` 事件时,解析流式输出中最后的 `result` 事件
|
|
443
|
+
4. 在主 worktree 的 clawt 终端实时输出通知。TTY 环境下使用进度面板,进度面板每个任务行第二列显示 worktree 路径(终端可点击跳转),运行中显示实时活动描述,完成/失败后显示结果预览:
|
|
432
444
|
|
|
433
445
|
```
|
|
434
446
|
✓ [完成] worktree: ~/.clawt/worktrees/main-project/feature-scheme-1
|
|
@@ -452,6 +464,39 @@ Claude Code CLI 以 `--output-format json` 运行时,退出后会在 stdout
|
|
|
452
464
|
════════════════════════════════════════
|
|
453
465
|
```
|
|
454
466
|
|
|
467
|
+
#### 进度面板渲染机制
|
|
468
|
+
|
|
469
|
+
进度面板由 `ProgressRenderer`(`src/utils/progress.ts`)负责渲染,渲染函数拆分到 `src/utils/progress-render.ts`。
|
|
470
|
+
|
|
471
|
+
**TTY 模式渲染策略(备选屏幕缓冲区):**
|
|
472
|
+
|
|
473
|
+
- **进入备选屏幕**:`start()` 时通过 `ALT_SCREEN_ENTER`(`\x1B[?1049h`)进入终端备选屏幕缓冲区,隔离进度面板与主屏幕内容
|
|
474
|
+
- **禁用行换行**:通过 `LINE_WRAP_DISABLE`(`\x1B[?7l`)防止超长行自动折行,配合按终端宽度截断保证每行只占一行
|
|
475
|
+
- **每帧渲染**:使用 `CLEAR_SCREEN` + `CURSOR_HOME` 清屏后完全重绘,无需计算 `CURSOR_UP` 回退量,不受终端 reflow 影响
|
|
476
|
+
- **防闪烁**:每帧渲染使用 Synchronized Output(`SYNC_OUTPUT_START` / `SYNC_OUTPUT_END`),终端缓冲全部输出后一次性刷新
|
|
477
|
+
- **行宽截断**:通过 `truncateToTerminalWidth()`(`src/utils/progress-render.ts`)将含 ANSI 转义码的字符串截断到终端可见列数,使用 `string-width` 库正确计算中文/emoji 宽度
|
|
478
|
+
- **终端 resize 响应**:监听 `process.stdout` 的 `resize` 事件,窗口宽度变化时立即触发重绘
|
|
479
|
+
- **退出时恢复**:`stop()` 时恢复行换行、显示光标、退出备选屏幕,然后在主屏幕上重新输出最终面板状态(备选屏幕内容不保留)
|
|
480
|
+
- **异常退出兜底**:注册 `process.on('exit')` 处理器,确保即使异常退出也能恢复终端状态
|
|
481
|
+
|
|
482
|
+
**任务行格式:**
|
|
483
|
+
|
|
484
|
+
```
|
|
485
|
+
[1/3] /path/to/worktree ⠹ 运行中 1m23s Read index.ts
|
|
486
|
+
[2/3] /path/to/worktree ✓ 完成 2m05s $0.08 任务已成功完成
|
|
487
|
+
[3/3] /path/to/worktree ● 排队中
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
- 第二列为 worktree 路径(`path.padEnd(maxPathWidth)` 对齐)
|
|
491
|
+
- 运行中状态:末尾显示实时活动描述文本(如工具名+文件名、思考中+文本片段)
|
|
492
|
+
- 完成/失败状态:末尾显示结果预览文本(从 `ClaudeCodeResult.result` 提取,最大 40 字符)
|
|
493
|
+
|
|
494
|
+
**非 TTY 降级模式:**
|
|
495
|
+
|
|
496
|
+
- 启动时输出 `[1/3] branch 启动 path`
|
|
497
|
+
- 完成时输出 `[1/3] branch ✓ 完成 duration cost detail`(`detail` 优先使用结果预览,无则回退到路径)
|
|
498
|
+
- 失败时输出 `[1/3] branch ✗ 失败 duration detail`
|
|
499
|
+
|
|
455
500
|
---
|
|
456
501
|
|
|
457
502
|
### 5.4 在主 Worktree 验证其他分支
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawt",
|
|
3
|
-
"version": "2.16.
|
|
3
|
+
"version": "2.16.5",
|
|
4
4
|
"description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"chalk": "^5.4.1",
|
|
20
20
|
"commander": "^13.1.0",
|
|
21
21
|
"enquirer": "^2.4.1",
|
|
22
|
+
"string-width": "^8.2.0",
|
|
22
23
|
"winston": "^3.17.0",
|
|
23
24
|
"winston-daily-rotate-file": "^5.0.0"
|
|
24
25
|
},
|
package/src/constants/index.ts
CHANGED
|
@@ -10,11 +10,21 @@ export { DEBUG_LOG_PREFIX, DEBUG_TIMESTAMP_FORMAT } from './logger.js';
|
|
|
10
10
|
export {
|
|
11
11
|
SPINNER_FRAMES,
|
|
12
12
|
SPINNER_INTERVAL_MS,
|
|
13
|
-
CURSOR_UP,
|
|
14
|
-
CLEAR_LINE,
|
|
15
13
|
CURSOR_HIDE,
|
|
16
14
|
CURSOR_SHOW,
|
|
17
15
|
TASK_STATUS_ICONS,
|
|
18
16
|
TASK_STATUS_LABELS,
|
|
17
|
+
ACTIVITY_TEXT_MAX_LENGTH,
|
|
18
|
+
TEXT_ACTIVITY_PREFIX,
|
|
19
|
+
RESULT_PREVIEW_MAX_LENGTH,
|
|
20
|
+
DEFAULT_TERMINAL_COLUMNS,
|
|
21
|
+
LINE_WRAP_DISABLE,
|
|
22
|
+
LINE_WRAP_ENABLE,
|
|
23
|
+
SYNC_OUTPUT_START,
|
|
24
|
+
SYNC_OUTPUT_END,
|
|
25
|
+
ALT_SCREEN_ENTER,
|
|
26
|
+
ALT_SCREEN_LEAVE,
|
|
27
|
+
CLEAR_SCREEN,
|
|
28
|
+
CURSOR_HOME,
|
|
19
29
|
} from './progress.js';
|
|
20
30
|
export { SELECT_ALL_NAME, SELECT_ALL_LABEL } from './prompt.js';
|
|
@@ -17,11 +17,11 @@ export const RUN_MESSAGES = {
|
|
|
17
17
|
PROGRESS_TASK_STARTED: (index: number, total: number, branch: string, path: string) =>
|
|
18
18
|
`[${index}/${total}] ${branch} 启动 ${path}`,
|
|
19
19
|
/** 非 TTY 环境降级输出:任务完成 */
|
|
20
|
-
PROGRESS_TASK_DONE: (index: number, total: number, branch: string, duration: string, cost: string,
|
|
21
|
-
`[${index}/${total}] ${branch} ✓ 完成 ${duration} ${cost} ${
|
|
20
|
+
PROGRESS_TASK_DONE: (index: number, total: number, branch: string, duration: string, cost: string, detail: string) =>
|
|
21
|
+
`[${index}/${total}] ${branch} ✓ 完成 ${duration} ${cost} ${detail}`,
|
|
22
22
|
/** 非 TTY 环境降级输出:任务失败 */
|
|
23
|
-
PROGRESS_TASK_FAILED: (index: number, total: number, branch: string, duration: string,
|
|
24
|
-
`[${index}/${total}] ${branch} ✗ 失败 ${duration} ${
|
|
23
|
+
PROGRESS_TASK_FAILED: (index: number, total: number, branch: string, duration: string, detail: string) =>
|
|
24
|
+
`[${index}/${total}] ${branch} ✗ 失败 ${duration} ${detail}`,
|
|
25
25
|
/** 并发限制提示 */
|
|
26
26
|
CONCURRENCY_INFO: (concurrency: number, total: number) =>
|
|
27
27
|
`并发限制: ${concurrency},共 ${total} 个任务`,
|
|
@@ -4,12 +4,6 @@ export const SPINNER_FRAMES: readonly string[] = ['⠋', '⠙', '⠹', '⠸', '
|
|
|
4
4
|
/** spinner 刷新间隔(毫秒) */
|
|
5
5
|
export const SPINNER_INTERVAL_MS = 100;
|
|
6
6
|
|
|
7
|
-
/** ANSI 转义:光标上移 n 行 */
|
|
8
|
-
export const CURSOR_UP = (n: number): string => `\x1B[${n}A`;
|
|
9
|
-
|
|
10
|
-
/** ANSI 转义:清除从光标到行尾 */
|
|
11
|
-
export const CLEAR_LINE = '\x1B[0K';
|
|
12
|
-
|
|
13
7
|
/** ANSI 转义:隐藏光标 */
|
|
14
8
|
export const CURSOR_HIDE = '\x1B[?25l';
|
|
15
9
|
|
|
@@ -37,3 +31,39 @@ export const TASK_STATUS_LABELS = {
|
|
|
37
31
|
/** 失败 */
|
|
38
32
|
FAILED: '失败',
|
|
39
33
|
} as const;
|
|
34
|
+
|
|
35
|
+
/** 活动描述文本的最大字符数 */
|
|
36
|
+
export const ACTIVITY_TEXT_MAX_LENGTH = 30;
|
|
37
|
+
|
|
38
|
+
/** 文本类型活动的前缀 */
|
|
39
|
+
export const TEXT_ACTIVITY_PREFIX = '思考中';
|
|
40
|
+
|
|
41
|
+
/** 结果预览文本的最大字符数 */
|
|
42
|
+
export const RESULT_PREVIEW_MAX_LENGTH = 40;
|
|
43
|
+
|
|
44
|
+
/** 终端宽度的默认值(无法获取时的回退值) */
|
|
45
|
+
export const DEFAULT_TERMINAL_COLUMNS = 80;
|
|
46
|
+
|
|
47
|
+
/** ANSI 转义:禁用终端自动行换行(超出宽度的内容被截断而非折行) */
|
|
48
|
+
export const LINE_WRAP_DISABLE = '\x1B[?7l';
|
|
49
|
+
|
|
50
|
+
/** ANSI 转义:恢复终端自动行换行 */
|
|
51
|
+
export const LINE_WRAP_ENABLE = '\x1B[?7h';
|
|
52
|
+
|
|
53
|
+
/** ANSI 转义:开启同步输出模式(终端缓冲所有输出直到关闭,防止渲染闪烁) */
|
|
54
|
+
export const SYNC_OUTPUT_START = '\x1B[?2026h';
|
|
55
|
+
|
|
56
|
+
/** ANSI 转义:关闭同步输出模式(终端一次性刷新缓冲区内容) */
|
|
57
|
+
export const SYNC_OUTPUT_END = '\x1B[?2026l';
|
|
58
|
+
|
|
59
|
+
/** ANSI 转义:进入备选屏幕缓冲区(同时保存光标位置) */
|
|
60
|
+
export const ALT_SCREEN_ENTER = '\x1B[?1049h';
|
|
61
|
+
|
|
62
|
+
/** ANSI 转义:离开备选屏幕缓冲区(同时恢复光标位置) */
|
|
63
|
+
export const ALT_SCREEN_LEAVE = '\x1B[?1049l';
|
|
64
|
+
|
|
65
|
+
/** ANSI 转义:清除整个屏幕 */
|
|
66
|
+
export const CLEAR_SCREEN = '\x1B[2J';
|
|
67
|
+
|
|
68
|
+
/** ANSI 转义:光标移到屏幕左上角 (1,1) */
|
|
69
|
+
export const CURSOR_HOME = '\x1B[H';
|
package/src/utils/index.ts
CHANGED
|
@@ -61,6 +61,8 @@ export type { WorktreeResolveMessages, WorktreeMultiResolveMessages } from './wo
|
|
|
61
61
|
export { ProgressRenderer } from './progress.js';
|
|
62
62
|
export { parseTaskFile, loadTaskFile, parseTasksFromOptions } from './task-file.js';
|
|
63
63
|
export { executeBatchTasks } from './task-executor.js';
|
|
64
|
+
export { createLineBuffer, parseStreamLine, parseStreamEvent, formatActivityText, truncateText } from './stream-parser.js';
|
|
65
|
+
export type { ParsedActivity, StreamEvent, LineBuffer } from './stream-parser.js';
|
|
64
66
|
export { detectTerminalApp, openCommandInNewTerminalTab } from './terminal.js';
|
|
65
67
|
export { truncateTaskDesc, printDryRunPreview } from './dry-run.js';
|
|
66
68
|
export { applyAliases } from './alias.js';
|
|
@@ -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
|
}
|