chatccc 0.2.222 → 0.2.223
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/package.json +1 -1
- package/src/__tests__/card-plain-text.test.ts +7 -6
- package/src/__tests__/cards.test.ts +179 -178
- package/src/__tests__/progress-reducer.test.ts +110 -0
- package/src/__tests__/terminal-renderer.test.ts +143 -0
- package/src/builtin/cli.ts +38 -1
- package/src/cards.ts +280 -275
- package/src/progress/reducer.ts +108 -0
- package/src/progress/terminal-renderer.ts +190 -0
- package/src/progress/view.ts +77 -0
- package/src/session.ts +935 -937
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildBlockLines,
|
|
5
|
+
TerminalProgressRenderer,
|
|
6
|
+
} from "../progress/terminal-renderer.ts";
|
|
7
|
+
import { progressView } from "../progress/view.ts";
|
|
8
|
+
|
|
9
|
+
class FakeOut {
|
|
10
|
+
chunks: string[] = [];
|
|
11
|
+
columns = 100;
|
|
12
|
+
write(s: string): boolean {
|
|
13
|
+
this.chunks.push(s);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
get output(): string {
|
|
17
|
+
return this.chunks.join("");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 测试桩:仅实现 write 的假输出流,类型上按 WritableStream 对待 */
|
|
22
|
+
function asOut(out: FakeOut): NodeJS.WritableStream & { columns?: number } {
|
|
23
|
+
return out as unknown as NodeJS.WritableStream & { columns?: number };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.useRealTimers();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("buildBlockLines", () => {
|
|
31
|
+
it("renders generating status line with header title and stop hint", () => {
|
|
32
|
+
const lines = buildBlockLines(
|
|
33
|
+
progressView({ headerTitle: "正在启动 Agent · 0秒", text: "" }),
|
|
34
|
+
100,
|
|
35
|
+
);
|
|
36
|
+
expect(lines[0]).toContain("⏳ 正在启动 Agent · 0秒");
|
|
37
|
+
expect(lines[0]).toContain("Ctrl+C 停止");
|
|
38
|
+
expect(lines[1]).toContain("等待 Agent 输出...");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders done / stopped / error status lines", () => {
|
|
42
|
+
const done = buildBlockLines(progressView({ status: "done", text: "ok" }), 100);
|
|
43
|
+
expect(done[0]).toContain("✅ 完成");
|
|
44
|
+
const stopped = buildBlockLines(progressView({ status: "stopped", text: "x" }), 100);
|
|
45
|
+
expect(stopped[0]).toContain("⏹ 已停止");
|
|
46
|
+
const error = buildBlockLines(progressView({ status: "error", text: "x" }), 100);
|
|
47
|
+
expect(error[0]).toContain("❌ 异常结束");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders tool lines with emoji and status mark", () => {
|
|
51
|
+
const lines = buildBlockLines(
|
|
52
|
+
progressView({
|
|
53
|
+
text: "",
|
|
54
|
+
tools: [
|
|
55
|
+
{ id: "t1", name: "edit_file", status: "running", detail: "edit a.ts" },
|
|
56
|
+
{ id: "t2", name: "run_command", status: "ok", summary: "npm test passed" },
|
|
57
|
+
{ id: "t3", name: "search_code", status: "error", summary: "regex error" },
|
|
58
|
+
],
|
|
59
|
+
}),
|
|
60
|
+
100,
|
|
61
|
+
);
|
|
62
|
+
expect(lines[1]).toContain("edit_file");
|
|
63
|
+
expect(lines[1]).toContain("…");
|
|
64
|
+
expect(lines[1]).toContain("edit a.ts");
|
|
65
|
+
expect(lines[2]).toContain("✓");
|
|
66
|
+
expect(lines[2]).toContain("npm test passed");
|
|
67
|
+
expect(lines[3]).toContain("✗");
|
|
68
|
+
expect(lines[3]).toContain("regex error");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("truncates long body to maxBodyLines", () => {
|
|
72
|
+
const text = Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n");
|
|
73
|
+
const lines = buildBlockLines(progressView({ text }), 100, 5);
|
|
74
|
+
expect(lines.filter((l) => l.startsWith("line"))).toHaveLength(5);
|
|
75
|
+
expect(lines.join("\n")).toContain("...");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("clips lines wider than terminal width to avoid wrapping", () => {
|
|
79
|
+
const lines = buildBlockLines(
|
|
80
|
+
progressView({ text: "x".repeat(200) }),
|
|
81
|
+
30,
|
|
82
|
+
);
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
expect(line.length).toBeLessThanOrEqual(30);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("TerminalProgressRenderer", () => {
|
|
90
|
+
it("begin writes hide-cursor and the first frame", () => {
|
|
91
|
+
const out = new FakeOut();
|
|
92
|
+
const r = new TerminalProgressRenderer({ out: asOut(out) });
|
|
93
|
+
r.begin(progressView({ text: "hello" }));
|
|
94
|
+
expect(out.output.startsWith("\x1b[?25l")).toBe(true);
|
|
95
|
+
expect(out.output).toContain("hello");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("end finalizes the block and restores cursor, keeping block on screen", () => {
|
|
99
|
+
const out = new FakeOut();
|
|
100
|
+
const r = new TerminalProgressRenderer({ out: asOut(out) });
|
|
101
|
+
r.begin(progressView({ text: "a" }));
|
|
102
|
+
const before = out.chunks.length;
|
|
103
|
+
r.end(progressView({ status: "done", text: "final" }));
|
|
104
|
+
const delta = out.output.slice(out.chunks.slice(0, before).join("").length);
|
|
105
|
+
expect(delta).toContain("✅ 完成");
|
|
106
|
+
expect(delta.endsWith("\x1b[?25h")).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("render is frame-throttled and flush forces immediate redraw", () => {
|
|
110
|
+
vi.useFakeTimers();
|
|
111
|
+
const out = new FakeOut();
|
|
112
|
+
const r = new TerminalProgressRenderer({ out: asOut(out), frameMs: 100 });
|
|
113
|
+
|
|
114
|
+
r.begin(progressView({ text: "" }));
|
|
115
|
+
const outputAfterBegin = out.output;
|
|
116
|
+
|
|
117
|
+
r.render(progressView({ text: "one" }));
|
|
118
|
+
r.render(progressView({ text: "two" }));
|
|
119
|
+
r.render(progressView({ text: "three" }));
|
|
120
|
+
// 节流窗口内多次 render 不应产生任何输出
|
|
121
|
+
expect(out.output).toBe(outputAfterBegin);
|
|
122
|
+
|
|
123
|
+
vi.advanceTimersByTime(100);
|
|
124
|
+
// 合并后只重绘一次,且展示的是最新视图
|
|
125
|
+
expect(out.output).toContain("three");
|
|
126
|
+
expect(out.output).not.toContain("two");
|
|
127
|
+
|
|
128
|
+
r.flush();
|
|
129
|
+
// flush 立即重绘当前视图(输出继续增长且仍是三帧内容)
|
|
130
|
+
expect(out.output.length).toBeGreaterThan(outputAfterBegin.length);
|
|
131
|
+
expect(out.output).toContain("three");
|
|
132
|
+
r.dispose();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("clears leftover lines when the block shrinks", () => {
|
|
136
|
+
const out = new FakeOut();
|
|
137
|
+
const r = new TerminalProgressRenderer({ out: asOut(out) });
|
|
138
|
+
r.begin(progressView({ text: "a\nb\nc\nd" }));
|
|
139
|
+
r.end(progressView({ status: "done", text: "short" }));
|
|
140
|
+
expect(out.output).toContain("\x1b[2K\n");
|
|
141
|
+
expect(out.output).toContain("\x1b[J");
|
|
142
|
+
});
|
|
143
|
+
});
|
package/src/builtin/cli.ts
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* npx tsx src/builtin/cli.ts
|
|
6
6
|
* npx tsx src/builtin/cli.ts --model deepseek-v4-pro
|
|
7
7
|
* npx tsx src/builtin/cli.ts --stream-json --prompt "hello"
|
|
8
|
+
*
|
|
9
|
+
* 交互模式(TTY)下,单轮回复渲染为固定"过程区块"(飞书过程卡片的终端形态):
|
|
10
|
+
* 状态行 + 折叠工具行 + 原地更新正文,不再滚屏刷 JSON;完成/停止/异常后定型
|
|
11
|
+
* 留在屏幕上。非 TTY(管道/CI)回退为纯文本流式输出;--stream-json 机器接口不变。
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
14
|
import * as readline from "node:readline";
|
|
@@ -15,6 +19,9 @@ import { fileURLToPath } from "node:url";
|
|
|
15
19
|
import { listBuiltinContextSessions } from "./context.js";
|
|
16
20
|
import { resolveBuiltinSession, type BuiltinResumeRequest } from "./session-select.js";
|
|
17
21
|
import { createCtrlCState } from "./sigint.js";
|
|
22
|
+
import { reduceProgress } from "../progress/reducer.ts";
|
|
23
|
+
import { TerminalProgressRenderer } from "../progress/terminal-renderer.ts";
|
|
24
|
+
import { progressView, type ProgressView } from "../progress/view.ts";
|
|
18
25
|
import type { ChatEvent, ChatSessionConfig, ChatSessionOptions } from "./index.js";
|
|
19
26
|
|
|
20
27
|
interface ParsedArgs {
|
|
@@ -373,10 +380,28 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
373
380
|
currentAbort = new AbortController();
|
|
374
381
|
const signal = currentAbort.signal;
|
|
375
382
|
|
|
383
|
+
// TTY 下用"过程区块"(飞书过程卡片的终端形态):固定区域原地更新、
|
|
384
|
+
// 工具调用折叠为单行;非 TTY(管道/CI)回退为纯文本流式输出。
|
|
385
|
+
const useTerminalBlock = process.stdout.isTTY === true;
|
|
386
|
+
const renderer = useTerminalBlock ? new TerminalProgressRenderer() : null;
|
|
387
|
+
let view: ProgressView | null = null;
|
|
388
|
+
if (renderer) {
|
|
389
|
+
view = progressView({ headerTitle: "生成中..." });
|
|
390
|
+
renderer.begin(view);
|
|
391
|
+
}
|
|
392
|
+
let rendererEnded = false;
|
|
393
|
+
|
|
376
394
|
try {
|
|
377
395
|
let lastAccumulated = "";
|
|
378
396
|
for await (const event of session.chat(input, signal)) {
|
|
379
|
-
if (
|
|
397
|
+
if (renderer && view) {
|
|
398
|
+
view = reduceProgress(view, event);
|
|
399
|
+
if (event.type === "text" || event.type === "compact") {
|
|
400
|
+
renderer.render(view);
|
|
401
|
+
} else {
|
|
402
|
+
renderer.flush();
|
|
403
|
+
}
|
|
404
|
+
} else if (event.type === "text") {
|
|
380
405
|
const newText = event.accumulated.slice(lastAccumulated.length);
|
|
381
406
|
process.stdout.write(newText);
|
|
382
407
|
lastAccumulated = event.accumulated;
|
|
@@ -395,8 +420,20 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
395
420
|
}
|
|
396
421
|
}
|
|
397
422
|
} catch (err) {
|
|
423
|
+
if (renderer && view) {
|
|
424
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
425
|
+
view = progressView({ ...view, status: aborted ? "stopped" : "error", showStop: false });
|
|
426
|
+
renderer.end(view);
|
|
427
|
+
rendererEnded = true;
|
|
428
|
+
console.log("");
|
|
429
|
+
}
|
|
398
430
|
console.log(`\n${C.yellow}[error] ${(err as Error).message}${C.reset}`);
|
|
399
431
|
} finally {
|
|
432
|
+
if (renderer && view && !rendererEnded) {
|
|
433
|
+
// 定型终态区块(完成/已停止/异常结束)留在屏幕上,恢复光标
|
|
434
|
+
renderer.end(view);
|
|
435
|
+
console.log("");
|
|
436
|
+
}
|
|
400
437
|
currentAbort = null;
|
|
401
438
|
ctrlCState.reset();
|
|
402
439
|
}
|