chatccc 0.2.224 → 0.2.225
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
CHANGED
|
@@ -339,7 +339,7 @@ describe("ChatSession effort passthrough", () => {
|
|
|
339
339
|
streamTextMock.mockReturnValueOnce({ textStream: textStream("done") });
|
|
340
340
|
|
|
341
341
|
const session = new ChatSession(
|
|
342
|
-
{ apiKey: "sk-test" },
|
|
342
|
+
{ apiKey: "sk-test", effort: "" },
|
|
343
343
|
{ cwd: dir, sessionId: "effort-none" },
|
|
344
344
|
);
|
|
345
345
|
await collect(session.chat("hi"));
|
|
@@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
buildBlockLines,
|
|
5
|
+
charWidth,
|
|
6
|
+
clipToWidth,
|
|
5
7
|
TerminalProgressRenderer,
|
|
6
8
|
} from "../progress/terminal-renderer.ts";
|
|
7
9
|
import { progressView } from "../progress/view.ts";
|
|
@@ -41,6 +43,7 @@ describe("buildBlockLines", () => {
|
|
|
41
43
|
it("renders done / stopped / error status lines", () => {
|
|
42
44
|
const done = buildBlockLines(progressView({ status: "done", text: "ok" }), 100);
|
|
43
45
|
expect(done[0]).toContain("✅ 完成");
|
|
46
|
+
expect(done[0]).toContain("\x1b[1m"); // 完成状态行加粗,不用浅色
|
|
44
47
|
const stopped = buildBlockLines(progressView({ status: "stopped", text: "x" }), 100);
|
|
45
48
|
expect(stopped[0]).toContain("⏹ 已停止");
|
|
46
49
|
const error = buildBlockLines(progressView({ status: "error", text: "x" }), 100);
|
|
@@ -66,6 +69,10 @@ describe("buildBlockLines", () => {
|
|
|
66
69
|
expect(lines[2]).toContain("npm test passed");
|
|
67
70
|
expect(lines[3]).toContain("✗");
|
|
68
71
|
expect(lines[3]).toContain("regex error");
|
|
72
|
+
// 工具行摘要不用浅色字体(回复内容可读性优先)
|
|
73
|
+
for (const line of [lines[1], lines[2], lines[3]]) {
|
|
74
|
+
expect(line).not.toContain("\x1b[2m");
|
|
75
|
+
}
|
|
69
76
|
});
|
|
70
77
|
|
|
71
78
|
it("truncates long body to maxBodyLines", () => {
|
|
@@ -84,6 +91,46 @@ describe("buildBlockLines", () => {
|
|
|
84
91
|
expect(line.length).toBeLessThanOrEqual(30);
|
|
85
92
|
}
|
|
86
93
|
});
|
|
94
|
+
|
|
95
|
+
it("charWidth counts CJK/emoji as 2 columns and control/ZWJ as 0", () => {
|
|
96
|
+
expect(charWidth("a")).toBe(1);
|
|
97
|
+
expect(charWidth("中")).toBe(2);
|
|
98
|
+
expect(charWidth("📂")).toBe(2);
|
|
99
|
+
expect(charWidth("\u200d")).toBe(0); // ZWJ
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("clipToWidth truncates by display width, not string length", () => {
|
|
103
|
+
expect(clipToWidth("中文abc", 4)).toBe("中文");
|
|
104
|
+
expect(clipToWidth("中文abc", 5)).toBe("中文a");
|
|
105
|
+
expect(clipToWidth("📂 x", 2)).toBe("📂");
|
|
106
|
+
expect(clipToWidth("📂 x", 3)).toBe("📂 ");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("clipToWidth keeps ANSI tokens whole and closes colors at the cut", () => {
|
|
110
|
+
const ansi = "\x1b[32m📂\x1b[0m abc";
|
|
111
|
+
expect(clipToWidth(ansi, 6)).toBe("\x1b[32m📂\x1b[0m abc");
|
|
112
|
+
expect(clipToWidth(ansi, 5)).toBe("\x1b[32m📂\x1b[0m ab");
|
|
113
|
+
// 截断发生在未闭合颜色内时自动补 reset,避免颜色泄漏
|
|
114
|
+
expect(clipToWidth("\x1b[1m\x1b[32m✅ 完成\x1b[0m", 6)).toBe("\x1b[1m\x1b[32m✅ 完\x1b[0m");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("emoji tool lines are clipped by display width so the block never wraps", () => {
|
|
118
|
+
// 模拟用户示例:emoji + 超长 JSON 工具行,宽 40 的终端必须整行不折行
|
|
119
|
+
const lines = buildBlockLines(
|
|
120
|
+
progressView({
|
|
121
|
+
text: "",
|
|
122
|
+
tools: [
|
|
123
|
+
{ id: "t1", name: "list_dir", status: "ok", summary: `{"path":"C:\\Users\\weizhangjian\\.chatccc","entries":[{"name":"builtin","path":"C:\\Users${'x'.repeat(300)}"}]}` },
|
|
124
|
+
],
|
|
125
|
+
}),
|
|
126
|
+
40,
|
|
127
|
+
);
|
|
128
|
+
const toolLine = lines.find((l) => l.includes("list_dir"))!;
|
|
129
|
+
// 显示宽度(剥离 ANSI 后按 charWidth 计算)不超过终端列宽,永不折行
|
|
130
|
+
const visible = [...toolLine.replace(/\x1b\[[0-9;]*m/g, "")].reduce((w, ch) => w + charWidth(ch), 0);
|
|
131
|
+
expect(visible).toBeLessThanOrEqual(40);
|
|
132
|
+
expect(toolLine).toContain("\x1b[0m"); // ANSI 完整闭合
|
|
133
|
+
});
|
|
87
134
|
});
|
|
88
135
|
|
|
89
136
|
describe("TerminalProgressRenderer", () => {
|
|
@@ -93,6 +140,7 @@ describe("TerminalProgressRenderer", () => {
|
|
|
93
140
|
r.begin(progressView({ text: "hello" }));
|
|
94
141
|
expect(out.output.startsWith("\x1b[?25l")).toBe(true);
|
|
95
142
|
expect(out.output).toContain("hello");
|
|
143
|
+
r.dispose();
|
|
96
144
|
});
|
|
97
145
|
|
|
98
146
|
it("end finalizes the block and restores cursor, keeping block on screen", () => {
|
|
@@ -140,4 +188,60 @@ describe("TerminalProgressRenderer", () => {
|
|
|
140
188
|
expect(out.output).toContain("\x1b[2K\n");
|
|
141
189
|
expect(out.output).toContain("\x1b[J");
|
|
142
190
|
});
|
|
191
|
+
|
|
192
|
+
it("heartbeat animates generating dots so the block never freezes", () => {
|
|
193
|
+
vi.useFakeTimers();
|
|
194
|
+
const out = new FakeOut();
|
|
195
|
+
const r = new TerminalProgressRenderer({ out: asOut(out), animMs: 100 });
|
|
196
|
+
r.begin(progressView({ headerTitle: "生成中...", text: "" }));
|
|
197
|
+
const frame0 = out.output;
|
|
198
|
+
expect(frame0).toContain("·"); // 首帧即带动画点
|
|
199
|
+
vi.advanceTimersByTime(100);
|
|
200
|
+
const frame1 = out.output;
|
|
201
|
+
expect(frame1).not.toBe(frame0); // 无新事件也持续重绘
|
|
202
|
+
expect(frame1).toContain("··"); // 第二帧点号增长
|
|
203
|
+
r.dispose();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("stops the heartbeat after end so the final block stays static", () => {
|
|
207
|
+
vi.useFakeTimers();
|
|
208
|
+
const out = new FakeOut();
|
|
209
|
+
const r = new TerminalProgressRenderer({ out: asOut(out), animMs: 100 });
|
|
210
|
+
r.begin(progressView({ text: "" }));
|
|
211
|
+
r.end(progressView({ status: "done", text: "final" }));
|
|
212
|
+
const afterEnd = out.output.length;
|
|
213
|
+
vi.advanceTimersByTime(500);
|
|
214
|
+
expect(out.output.length).toBe(afterEnd);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("redraw moves up exactly blockLines-1 rows, never eating history above", () => {
|
|
218
|
+
// 回归测试:光标在区块最后一行时,回到第一行只需上移 N-1 行。
|
|
219
|
+
// 上移 N 行会每帧多上移 1 行,把上方历史内容逐行吃掉。
|
|
220
|
+
const out = new FakeOut();
|
|
221
|
+
const r = new TerminalProgressRenderer({ out: asOut(out), animMs: 0 });
|
|
222
|
+
// 状态行 1 行 + 正文 4 行 = 5 行区块
|
|
223
|
+
r.begin(progressView({ text: "a\nb\nc\nd" }));
|
|
224
|
+
const afterBegin = out.output;
|
|
225
|
+
r.flush(); // 第二帧强制重绘
|
|
226
|
+
const delta = out.output.slice(afterBegin.length);
|
|
227
|
+
expect(delta).toContain("\x1b[4A"); // 上移 5-1=4 行
|
|
228
|
+
expect(delta).not.toContain("\x1b[5A"); // 绝不出现上移 N 行
|
|
229
|
+
r.dispose();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("multi-frame redraws keep moving up N-1 rows each time (no drift)", () => {
|
|
233
|
+
const out = new FakeOut();
|
|
234
|
+
const r = new TerminalProgressRenderer({ out: asOut(out), animMs: 0 });
|
|
235
|
+
r.begin(progressView({ text: "a\nb\nc\nd" }));
|
|
236
|
+
for (let i = 0; i < 3; i++) {
|
|
237
|
+
r.flush();
|
|
238
|
+
}
|
|
239
|
+
// 连续多帧重绘,每帧上移都是 4 行(5 行区块),不是 5/6/7... 逐帧递增
|
|
240
|
+
const moves = out.output.match(/\x1b\[(\d+)A/g) ?? [];
|
|
241
|
+
expect(moves.length).toBe(3);
|
|
242
|
+
for (const move of moves) {
|
|
243
|
+
expect(move).toBe("\x1b[4A");
|
|
244
|
+
}
|
|
245
|
+
r.dispose();
|
|
246
|
+
});
|
|
143
247
|
});
|
|
@@ -95,6 +95,7 @@ describe("unflattenConfig", () => {
|
|
|
95
95
|
CHATCCC_CCC_BASE_URL: "https://api.deepseek.com/v1",
|
|
96
96
|
CHATCCC_CCC_MODEL: "deepseek-v4-flash",
|
|
97
97
|
CHATCCC_CCC_ALTERNATIVE_MODEL: "deepseek-v4-pro",
|
|
98
|
+
CHATCCC_CCC_EFFORT: "max",
|
|
98
99
|
}),
|
|
99
100
|
).toEqual({
|
|
100
101
|
ccc: {
|
|
@@ -104,6 +105,7 @@ describe("unflattenConfig", () => {
|
|
|
104
105
|
DEEPSEEK_BASE_URL: "https://api.deepseek.com/v1",
|
|
105
106
|
model: "deepseek-v4-flash",
|
|
106
107
|
alternativeModel: "deepseek-v4-pro",
|
|
108
|
+
effort: "max",
|
|
107
109
|
},
|
|
108
110
|
});
|
|
109
111
|
});
|
|
@@ -251,6 +253,7 @@ describe("dashboard edit modal", () => {
|
|
|
251
253
|
expect(PAGE_HTML).toContain('id="agent-default-ccc"');
|
|
252
254
|
expect(PAGE_HTML).toContain('id="field-CHATCCC_CCC_MODEL"');
|
|
253
255
|
expect(PAGE_HTML).toContain('id="field-CHATCCC_CCC_ALTERNATIVE_MODEL"');
|
|
256
|
+
expect(PAGE_HTML).toContain('id="field-CHATCCC_CCC_EFFORT"');
|
|
254
257
|
expect(PAGE_HTML).toContain('id="dash-ccc"');
|
|
255
258
|
expect(PAGE_HTML).toContain("editSection('ccc')");
|
|
256
259
|
});
|
package/src/builtin/cli.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import * as readline from "node:readline";
|
|
15
15
|
import * as process from "node:process";
|
|
16
|
+
import { appendFileSync } from "node:fs";
|
|
16
17
|
import { resolve as resolvePath } from "node:path";
|
|
17
18
|
import { fileURLToPath } from "node:url";
|
|
18
19
|
|
|
@@ -32,11 +33,14 @@ interface ParsedArgs {
|
|
|
32
33
|
help: boolean;
|
|
33
34
|
streamJson: boolean;
|
|
34
35
|
prompt: string | null;
|
|
36
|
+
/** 强制纯文本流式输出(不用过程区块渲染器),渲染异常时的兜底通道 */
|
|
37
|
+
plain: boolean;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
interface RuntimeDeps {
|
|
38
41
|
ChatSession: typeof import("./index.js").ChatSession;
|
|
39
42
|
appConfig: typeof import("../config.ts").config;
|
|
43
|
+
fileLog: typeof import("../config.ts").fileLog;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
interface JsonLine {
|
|
@@ -60,6 +64,7 @@ function parseArgs(argv = process.argv.slice(2)): ParsedArgs {
|
|
|
60
64
|
let help = false;
|
|
61
65
|
let streamJson = false;
|
|
62
66
|
let prompt: string | null = null;
|
|
67
|
+
let plain = false;
|
|
63
68
|
|
|
64
69
|
for (let i = 0; i < argv.length; i++) {
|
|
65
70
|
const arg = argv[i];
|
|
@@ -96,20 +101,22 @@ function parseArgs(argv = process.argv.slice(2)): ParsedArgs {
|
|
|
96
101
|
} else if (arg === "--prompt" && next !== undefined) {
|
|
97
102
|
prompt = next;
|
|
98
103
|
i++;
|
|
104
|
+
} else if (arg === "--plain") {
|
|
105
|
+
plain = true;
|
|
99
106
|
} else if (arg === "--help" || arg === "-h") {
|
|
100
107
|
help = true;
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
104
|
-
return { config, options, listSessions, resume, help, streamJson, prompt };
|
|
111
|
+
return { config, options, listSessions, resume, help, streamJson, prompt, plain };
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
async function loadRuntime(): Promise<RuntimeDeps> {
|
|
108
|
-
const [{ ChatSession }, { config: appConfig }] = await Promise.all([
|
|
115
|
+
const [{ ChatSession }, { config: appConfig, fileLog }] = await Promise.all([
|
|
109
116
|
import("./index.js"),
|
|
110
117
|
import("../config.ts"),
|
|
111
118
|
]);
|
|
112
|
-
return { ChatSession, appConfig };
|
|
119
|
+
return { ChatSession, appConfig, fileLog };
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
function printHelp(appConfig: RuntimeDeps["appConfig"]): void {
|
|
@@ -129,6 +136,7 @@ function printHelp(appConfig: RuntimeDeps["appConfig"]): void {
|
|
|
129
136
|
" --list-sessions List saved ccc sessions and exit",
|
|
130
137
|
" --stream-json One-shot mode: write JSONL events to stdout",
|
|
131
138
|
" --prompt <text> Prompt text for --stream-json",
|
|
139
|
+
" --plain Force plain streaming output (no progress block renderer)",
|
|
132
140
|
" --help, -h Show help",
|
|
133
141
|
"",
|
|
134
142
|
"Default config source:",
|
|
@@ -311,8 +319,32 @@ const C = {
|
|
|
311
319
|
yellow: "\x1b[33m",
|
|
312
320
|
};
|
|
313
321
|
|
|
322
|
+
/**
|
|
323
|
+
* 交互模式下渲染器独占终端 stdout:普通日志只写日志文件、不回显到终端,
|
|
324
|
+
* 避免生成过程中任何 console 输出混入过程区块、破坏行数计数(导致重绘
|
|
325
|
+
* 上移不足、把上方历史内容"吃掉")。错误提示(console.error)保留回显。
|
|
326
|
+
*/
|
|
327
|
+
function muteConsoleLogToFile(logPath: string): void {
|
|
328
|
+
const writeFile = (level: string, args: unknown[]): void => {
|
|
329
|
+
try {
|
|
330
|
+
const text = args
|
|
331
|
+
.map((a) =>
|
|
332
|
+
typeof a === "string" ? a
|
|
333
|
+
: a instanceof Error ? (a.stack ?? a.message)
|
|
334
|
+
: JSON.stringify(a))
|
|
335
|
+
.join(" ");
|
|
336
|
+
appendFileSync(logPath, `[${new Date().toISOString()}] [${level}] ${text}\n`, "utf8");
|
|
337
|
+
} catch {
|
|
338
|
+
// 日志系统自身失败不影响主流程
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
console.log = (...args: unknown[]) => writeFile("LOG", args);
|
|
342
|
+
console.info = (...args: unknown[]) => writeFile("INFO", args);
|
|
343
|
+
console.warn = (...args: unknown[]) => writeFile("WARN", args);
|
|
344
|
+
}
|
|
345
|
+
|
|
314
346
|
async function runRepl(args: ParsedArgs): Promise<void> {
|
|
315
|
-
const { ChatSession, appConfig } = await loadRuntime();
|
|
347
|
+
const { ChatSession, appConfig, fileLog } = await loadRuntime();
|
|
316
348
|
|
|
317
349
|
const cwd = resolvePath(args.options.cwd ?? process.cwd());
|
|
318
350
|
let resolvedSession;
|
|
@@ -330,6 +362,12 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
330
362
|
console.log(`${C.dim}Type a message to chat. Double Ctrl+C interrupts generation or exits. Type exit to quit.${C.reset}`);
|
|
331
363
|
console.log("");
|
|
332
364
|
|
|
365
|
+
// 交互渲染模式下 console 输出只写日志文件、不回显到终端(渲染器独占 stdout,
|
|
366
|
+
// 避免生成中日志混入区块破坏行数)。--plain 无渲染器,不需要静音。
|
|
367
|
+
if (process.stdout.isTTY === true && !args.plain) {
|
|
368
|
+
muteConsoleLogToFile(fileLog.logPath);
|
|
369
|
+
}
|
|
370
|
+
|
|
333
371
|
let session: InstanceType<typeof ChatSession>;
|
|
334
372
|
try {
|
|
335
373
|
session = new ChatSession(args.config, {
|
|
@@ -363,20 +401,20 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
363
401
|
}
|
|
364
402
|
|
|
365
403
|
if (input === "exit") {
|
|
366
|
-
|
|
404
|
+
process.stdout.write(`${C.dim}bye${C.reset}\n`);
|
|
367
405
|
rl.close();
|
|
368
406
|
return;
|
|
369
407
|
}
|
|
370
408
|
|
|
371
409
|
if (input === "/clear") {
|
|
372
410
|
session.reset();
|
|
373
|
-
|
|
411
|
+
process.stdout.write(`${C.dim}session cleared${C.reset}\n`);
|
|
374
412
|
rl.prompt();
|
|
375
413
|
return;
|
|
376
414
|
}
|
|
377
415
|
|
|
378
416
|
if (input === "/history") {
|
|
379
|
-
|
|
417
|
+
process.stdout.write(`${C.dim}${session.turnCount} conversation turns${C.reset}\n`);
|
|
380
418
|
rl.prompt();
|
|
381
419
|
return;
|
|
382
420
|
}
|
|
@@ -385,12 +423,16 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
385
423
|
const signal = currentAbort.signal;
|
|
386
424
|
|
|
387
425
|
// TTY 下用"过程区块"(飞书过程卡片的终端形态):固定区域原地更新、
|
|
388
|
-
// 工具调用折叠为单行;非 TTY(管道/CI
|
|
389
|
-
const useTerminalBlock = process.stdout.isTTY === true;
|
|
426
|
+
// 工具调用折叠为单行;非 TTY(管道/CI)或 --plain 回退为纯文本流式输出。
|
|
427
|
+
const useTerminalBlock = process.stdout.isTTY === true && !args.plain;
|
|
390
428
|
const renderer = useTerminalBlock ? new TerminalProgressRenderer() : null;
|
|
391
429
|
let view: ProgressView | null = null;
|
|
392
430
|
if (renderer) {
|
|
393
431
|
view = progressView({ headerTitle: "生成中..." });
|
|
432
|
+
// 先回行首换行再 begin:让过程区块从输入行下方开始,避免首帧 \r\x1b[2K
|
|
433
|
+
// 清掉用户刚输入的问题行(历史文本不被刷掉)。\r\n 兼容 readline
|
|
434
|
+
// 行提交后光标仍停在输入行行尾的情况。
|
|
435
|
+
process.stdout.write("\r\n");
|
|
394
436
|
renderer.begin(view);
|
|
395
437
|
}
|
|
396
438
|
let rendererEnded = false;
|
|
@@ -429,14 +471,14 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
429
471
|
view = progressView({ ...view, status: aborted ? "stopped" : "error", showStop: false });
|
|
430
472
|
renderer.end(view);
|
|
431
473
|
rendererEnded = true;
|
|
432
|
-
|
|
474
|
+
process.stdout.write("\n");
|
|
433
475
|
}
|
|
434
|
-
console.
|
|
476
|
+
console.error(`\n${C.yellow}[error] ${(err as Error).message}${C.reset}`);
|
|
435
477
|
} finally {
|
|
436
478
|
if (renderer && view && !rendererEnded) {
|
|
437
479
|
// 定型终态区块(完成/已停止/异常结束)留在屏幕上,恢复光标
|
|
438
480
|
renderer.end(view);
|
|
439
|
-
|
|
481
|
+
process.stdout.write("\n");
|
|
440
482
|
}
|
|
441
483
|
currentAbort = null;
|
|
442
484
|
ctrlCState.reset();
|
|
@@ -449,31 +491,31 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
449
491
|
const action = ctrlCState.press(currentAbort !== null);
|
|
450
492
|
|
|
451
493
|
if (action === "exit") {
|
|
452
|
-
console.
|
|
494
|
+
console.error(`\n${C.dim}bye${C.reset}`);
|
|
453
495
|
rl.close();
|
|
454
496
|
return;
|
|
455
497
|
}
|
|
456
498
|
|
|
457
499
|
if (action === "interrupt") {
|
|
458
|
-
console.
|
|
500
|
+
console.error(`\n${C.yellow}[interrupting...]${C.reset}`);
|
|
459
501
|
currentAbort?.abort();
|
|
460
502
|
currentAbort = null;
|
|
461
503
|
return;
|
|
462
504
|
}
|
|
463
505
|
|
|
464
506
|
if (action === "arm-interrupt") {
|
|
465
|
-
console.
|
|
507
|
+
console.error(`\n${C.dim}Press Ctrl+C again to interrupt current response${C.reset}`);
|
|
466
508
|
return;
|
|
467
509
|
}
|
|
468
510
|
|
|
469
511
|
if (action === "arm-exit") {
|
|
470
|
-
console.
|
|
512
|
+
console.error(`\n${C.dim}Press Ctrl+C again to exit, or type exit${C.reset}`);
|
|
471
513
|
rl.prompt();
|
|
472
514
|
}
|
|
473
515
|
});
|
|
474
516
|
|
|
475
517
|
rl.on("close", () => {
|
|
476
|
-
|
|
518
|
+
process.stdout.write("\n");
|
|
477
519
|
process.exit(0);
|
|
478
520
|
});
|
|
479
521
|
}
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* 实现要点:
|
|
10
10
|
* - 隐藏光标(\x1b[?25l)→ 每帧整块重绘(\x1b[{n}A 上移 + \x1b[2K 清行 + \x1b[J 清下方)
|
|
11
11
|
* - 帧节流合并重绘,避免高频文本增量导致闪烁
|
|
12
|
+
* - 每行按显示宽度截断(CJK/emoji 算 2 列),保证永不折行:一旦折行,
|
|
13
|
+
* 实际占用的屏幕行数会超过 blockLines 计数,下次重绘上移行数不足,
|
|
14
|
+
* 会清掉区块上方的历史内容(用户输入的问题被"吃掉")。
|
|
12
15
|
* - end() 时把终态区块定型留在屏幕上(与飞书完成卡片留在消息流语义一致),恢复光标
|
|
13
16
|
*/
|
|
14
17
|
|
|
@@ -17,6 +20,7 @@ import { progressView, type ProgressToolCall, type ProgressView } from "./view.t
|
|
|
17
20
|
|
|
18
21
|
const RESET = "\x1b[0m";
|
|
19
22
|
const DIM = "\x1b[2m";
|
|
23
|
+
const BOLD = "\x1b[1m";
|
|
20
24
|
const GREEN = "\x1b[32m";
|
|
21
25
|
const YELLOW = "\x1b[33m";
|
|
22
26
|
const RED = "\x1b[31m";
|
|
@@ -24,11 +28,72 @@ const RED = "\x1b[31m";
|
|
|
24
28
|
const HIDE_CURSOR = "\x1b[?25l";
|
|
25
29
|
const SHOW_CURSOR = "\x1b[?25h";
|
|
26
30
|
|
|
31
|
+
/** 生成中标题后的心跳点号动画帧(1 → 2 → 3 → 2 循环,避免跳变感) */
|
|
32
|
+
const ANIM_FRAMES = ["·", "··", "···", "··"];
|
|
33
|
+
|
|
34
|
+
const ANSI_TOKEN = /\x1b\[[0-9;]*m/;
|
|
35
|
+
|
|
36
|
+
/** 单个字符在终端里的显示列宽:CJK/emoji 占 2 列,控制符/ZWJ/变体选择符占 0 列 */
|
|
37
|
+
export function charWidth(ch: string): number {
|
|
38
|
+
const code = ch.codePointAt(0)!;
|
|
39
|
+
if (code < 32 || (code >= 0x7f && code <= 0xa0)) return 0; // 控制字符
|
|
40
|
+
if (code === 0x200d || code === 0xfe0e || code === 0xfe0f) return 0; // ZWJ / VS15 / VS16
|
|
41
|
+
if (
|
|
42
|
+
(code >= 0x1100 && code <= 0x115f) || // 谚文 Jamo
|
|
43
|
+
(code >= 0x2e80 && code <= 0x303f) || // CJK 部首 / 标点
|
|
44
|
+
(code >= 0x3040 && code <= 0xa4cf) || // 假名 / 汉字 / 谚文 / 彝文
|
|
45
|
+
(code >= 0xac00 && code <= 0xd7a3) || // 谚文音节
|
|
46
|
+
(code >= 0xf900 && code <= 0xfaff) || // CJK 兼容汉字
|
|
47
|
+
(code >= 0xfe30 && code <= 0xfe6f) || // CJK 兼容形式
|
|
48
|
+
(code >= 0xff00 && code <= 0xff60) || // 全角形式
|
|
49
|
+
(code >= 0xffe0 && code <= 0xffe6) || // 全角符号
|
|
50
|
+
(code >= 0x2600 && code <= 0x27bf) || // 杂项符号 + Dingbats(emoji 呈现 2 列)
|
|
51
|
+
(code >= 0x1f000 && code <= 0x1faff) // emoji 补充区
|
|
52
|
+
) {
|
|
53
|
+
return 2;
|
|
54
|
+
}
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 按终端显示宽度截断一行(CJK/emoji 计 2 列),ANSI 序列零宽且永不截断。
|
|
60
|
+
* 截断位置若处于未闭合的颜色状态,自动补 \x1b[0m,避免颜色泄漏到后续输出。
|
|
61
|
+
*/
|
|
62
|
+
export function clipToWidth(s: string, maxCols: number): string {
|
|
63
|
+
if (maxCols <= 0) return "";
|
|
64
|
+
let out = "";
|
|
65
|
+
let visible = 0;
|
|
66
|
+
let colorOpen = false;
|
|
67
|
+
let i = 0;
|
|
68
|
+
while (i < s.length) {
|
|
69
|
+
if (s.charCodeAt(i) === 0x1b) {
|
|
70
|
+
const m = ANSI_TOKEN.exec(s.slice(i));
|
|
71
|
+
if (m && m.index === 0) {
|
|
72
|
+
out += m[0];
|
|
73
|
+
colorOpen = m[0] !== "\x1b[0m";
|
|
74
|
+
i += m[0].length;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const cp = s.codePointAt(i)!;
|
|
79
|
+
const ch = String.fromCodePoint(cp);
|
|
80
|
+
const w = charWidth(ch);
|
|
81
|
+
if (visible + w > maxCols) break;
|
|
82
|
+
out += ch;
|
|
83
|
+
visible += w;
|
|
84
|
+
i += ch.length;
|
|
85
|
+
}
|
|
86
|
+
if (colorOpen) out += "\x1b[0m";
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
27
90
|
export interface TerminalRendererOptions {
|
|
28
91
|
/** 输出流,默认 process.stdout */
|
|
29
92
|
out?: NodeJS.WritableStream & { columns?: number };
|
|
30
93
|
/** 帧节流毫秒数,默认 66(约 15fps) */
|
|
31
94
|
frameMs?: number;
|
|
95
|
+
/** 生成中心跳动画毫秒数,默认 300;<=0 禁用动画 */
|
|
96
|
+
animMs?: number;
|
|
32
97
|
/** 正文最大行数(超出截断,保留首行+末段) */
|
|
33
98
|
maxBodyLines?: number;
|
|
34
99
|
/** 正文最大字符数 */
|
|
@@ -38,7 +103,7 @@ export interface TerminalRendererOptions {
|
|
|
38
103
|
function buildStatusLine(view: ProgressView): string {
|
|
39
104
|
switch (view.status) {
|
|
40
105
|
case "done":
|
|
41
|
-
return `${GREEN}✅ 完成${RESET}`;
|
|
106
|
+
return `${BOLD}${GREEN}✅ 完成${RESET}`;
|
|
42
107
|
case "stopped":
|
|
43
108
|
return `${YELLOW}⏹ 已停止${RESET}`;
|
|
44
109
|
case "error":
|
|
@@ -54,12 +119,13 @@ function buildToolLine(tool: ProgressToolCall): string {
|
|
|
54
119
|
const emoji = getToolEmoji(tool.name);
|
|
55
120
|
const mark =
|
|
56
121
|
tool.status === "running"
|
|
57
|
-
?
|
|
122
|
+
? "…"
|
|
58
123
|
: tool.status === "ok"
|
|
59
124
|
? `${GREEN}✓${RESET}`
|
|
60
125
|
: `${RED}✗${RESET}`;
|
|
61
126
|
const info = tool.status === "running" ? (tool.detail ?? "") : (tool.summary ?? "");
|
|
62
|
-
|
|
127
|
+
// 摘要/详情用正常色:最终区块里不出现浅色文字(可读性优先)
|
|
128
|
+
return ` ${emoji} ${tool.name} ${mark} ${info}`;
|
|
63
129
|
}
|
|
64
130
|
|
|
65
131
|
/**
|
|
@@ -72,7 +138,7 @@ export function buildBlockLines(
|
|
|
72
138
|
maxBodyLines = 30,
|
|
73
139
|
maxBodyChars = 12000,
|
|
74
140
|
): string[] {
|
|
75
|
-
const clip = (s: string) => (s
|
|
141
|
+
const clip = (s: string) => clipToWidth(s, width);
|
|
76
142
|
const lines: string[] = [];
|
|
77
143
|
lines.push(clip(buildStatusLine(view)));
|
|
78
144
|
for (const tool of view.tools) {
|
|
@@ -92,6 +158,7 @@ export function buildBlockLines(
|
|
|
92
158
|
export class TerminalProgressRenderer {
|
|
93
159
|
private readonly out: NodeJS.WritableStream & { columns?: number };
|
|
94
160
|
private readonly frameMs: number;
|
|
161
|
+
private readonly animMs: number;
|
|
95
162
|
private readonly maxBodyLines: number;
|
|
96
163
|
private readonly maxBodyChars: number;
|
|
97
164
|
private view: ProgressView;
|
|
@@ -99,20 +166,24 @@ export class TerminalProgressRenderer {
|
|
|
99
166
|
private dirty = false;
|
|
100
167
|
private timer: NodeJS.Timeout | null = null;
|
|
101
168
|
private ended = false;
|
|
169
|
+
private animFrame = 0;
|
|
170
|
+
private animTimer: NodeJS.Timeout | null = null;
|
|
102
171
|
|
|
103
172
|
constructor(opts: TerminalRendererOptions = {}) {
|
|
104
173
|
this.out = opts.out ?? process.stdout;
|
|
105
174
|
this.frameMs = opts.frameMs ?? 66;
|
|
175
|
+
this.animMs = opts.animMs ?? 300;
|
|
106
176
|
this.maxBodyLines = opts.maxBodyLines ?? 30;
|
|
107
177
|
this.maxBodyChars = opts.maxBodyChars ?? 12000;
|
|
108
178
|
this.view = progressView();
|
|
109
179
|
}
|
|
110
180
|
|
|
111
|
-
/**
|
|
181
|
+
/** 开始一轮区块:隐藏光标并渲染首帧,随后启动生成中心跳动画 */
|
|
112
182
|
begin(view: ProgressView): void {
|
|
113
183
|
this.view = view;
|
|
114
184
|
this.out.write(HIDE_CURSOR);
|
|
115
185
|
this.renderNow(view);
|
|
186
|
+
this.startAnimation();
|
|
116
187
|
}
|
|
117
188
|
|
|
118
189
|
/** 标记视图已更新,按帧节流合并重绘(高频增量不闪烁) */
|
|
@@ -141,12 +212,13 @@ export class TerminalProgressRenderer {
|
|
|
141
212
|
this.renderNow(this.view);
|
|
142
213
|
}
|
|
143
214
|
|
|
144
|
-
/**
|
|
215
|
+
/** 结束一轮:定型终态区块(留在屏幕上),恢复光标,停止动画 */
|
|
145
216
|
end(view: ProgressView): void {
|
|
146
217
|
if (this.timer) {
|
|
147
218
|
clearTimeout(this.timer);
|
|
148
219
|
this.timer = null;
|
|
149
220
|
}
|
|
221
|
+
this.stopAnimation();
|
|
150
222
|
this.ended = true;
|
|
151
223
|
this.renderNow(view);
|
|
152
224
|
this.out.write(SHOW_CURSOR);
|
|
@@ -158,18 +230,42 @@ export class TerminalProgressRenderer {
|
|
|
158
230
|
clearTimeout(this.timer);
|
|
159
231
|
this.timer = null;
|
|
160
232
|
}
|
|
233
|
+
this.stopAnimation();
|
|
161
234
|
if (!this.ended) {
|
|
162
235
|
this.out.write(SHOW_CURSOR);
|
|
163
236
|
}
|
|
164
237
|
}
|
|
165
238
|
|
|
239
|
+
private startAnimation(): void {
|
|
240
|
+
if (this.animMs <= 0 || this.animTimer) return;
|
|
241
|
+
this.animTimer = setInterval(() => {
|
|
242
|
+
this.animFrame++;
|
|
243
|
+
// 终态(done/stopped/error)由 end() 停止;此处兜底跳过,避免结束后还在重绘
|
|
244
|
+
if (this.ended || this.view.status !== "generating") return;
|
|
245
|
+
// 无新事件也持续重绘,点号动画让静止期(思考/工具运行中)也有动态感
|
|
246
|
+
this.renderNow(this.view);
|
|
247
|
+
}, this.animMs);
|
|
248
|
+
this.animTimer.unref?.();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private stopAnimation(): void {
|
|
252
|
+
if (this.animTimer) {
|
|
253
|
+
clearInterval(this.animTimer);
|
|
254
|
+
this.animTimer = null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
166
258
|
private renderNow(view: ProgressView): void {
|
|
167
259
|
const width = this.out.columns && this.out.columns > 0 ? this.out.columns : 80;
|
|
168
|
-
const lines = buildBlockLines(view, width, this.maxBodyLines, this.maxBodyChars);
|
|
260
|
+
const lines = buildBlockLines(this.applyAnimation(view), width, this.maxBodyLines, this.maxBodyChars);
|
|
169
261
|
const newLines = lines.length;
|
|
170
262
|
|
|
171
263
|
if (this.blockLines > 0) {
|
|
172
|
-
|
|
264
|
+
// 关键:光标此刻停在区块【最后一行】行尾,回到区块第一行只需上移
|
|
265
|
+
// blockLines - 1 行。上移 blockLines 行会每帧多上移 1 行,把区块上方
|
|
266
|
+
// 的历史内容逐行"吃掉"(用户看到的"一行一行往上吃")。
|
|
267
|
+
this.out.write(`\x1b[${this.blockLines - 1}A`);
|
|
268
|
+
this.out.write("\r");
|
|
173
269
|
}
|
|
174
270
|
for (let i = 0; i < lines.length; i++) {
|
|
175
271
|
this.out.write(`\r\x1b[2K${lines[i]}`);
|
|
@@ -187,4 +283,12 @@ export class TerminalProgressRenderer {
|
|
|
187
283
|
this.out.write("\x1b[J");
|
|
188
284
|
this.blockLines = newLines;
|
|
189
285
|
}
|
|
286
|
+
|
|
287
|
+
/** 生成中阶段在标题后叠加心跳点号动画;终态原样返回(buildBlockLines 保持纯函数可测) */
|
|
288
|
+
private applyAnimation(view: ProgressView): ProgressView {
|
|
289
|
+
if (view.status !== "generating") return view;
|
|
290
|
+
const base = view.headerTitle.replace(/\.\.\.\s*$/, "").trim();
|
|
291
|
+
const dots = ANIM_FRAMES[this.animFrame % ANIM_FRAMES.length];
|
|
292
|
+
return { ...view, headerTitle: `${base} ${dots}` };
|
|
293
|
+
}
|
|
190
294
|
}
|
package/src/web-ui.ts
CHANGED
|
@@ -1004,6 +1004,19 @@ header .badge{font-size:13px;padding:4px 12px;border-radius:12px;font-weight:500
|
|
|
1004
1004
|
<label>备选模型(选填)</label>
|
|
1005
1005
|
<input type="text" id="field-CHATCCC_CCC_ALTERNATIVE_MODEL" placeholder="加入 /model 列表,便于会话内切换">
|
|
1006
1006
|
</div>
|
|
1007
|
+
<div class="form-group">
|
|
1008
|
+
<label>Effort(推理强度,选填)</label>
|
|
1009
|
+
<select id="field-CHATCCC_CCC_EFFORT">
|
|
1010
|
+
<option value="">(留空/默认,服务端 medium)</option>
|
|
1011
|
+
<option value="none">none - 直接作答,最省 token</option>
|
|
1012
|
+
<option value="minimal">minimal</option>
|
|
1013
|
+
<option value="low">low</option>
|
|
1014
|
+
<option value="medium">medium</option>
|
|
1015
|
+
<option value="high">high</option>
|
|
1016
|
+
<option value="xhigh">xhigh</option>
|
|
1017
|
+
<option value="max">max - 最强推理</option>
|
|
1018
|
+
</select>
|
|
1019
|
+
</div>
|
|
1007
1020
|
</fieldset>
|
|
1008
1021
|
</div>
|
|
1009
1022
|
|
|
@@ -1196,7 +1209,7 @@ const AGENT_FIELDS = {
|
|
|
1196
1209
|
claude: ['CHATCCC_ANTHROPIC_MODEL','CHATCCC_ANTHROPIC_SUBAGENT_MODEL','CHATCCC_ANTHROPIC_EFFORT','CHATCCC_ANTHROPIC_API_KEY','CHATCCC_ANTHROPIC_BASE_URL','CHATCCC_ANTHROPIC_MAX_TURN'],
|
|
1197
1210
|
cursor: ['CHATCCC_CURSOR_PATH','CHATCCC_CURSOR_MODEL','CHATCCC_CURSOR_ALTERNATIVE_MODEL','CHATCCC_CURSOR_AVATAR_BATTERY_MODE','CHATCCC_CURSOR_ON_DEMAND_MONTHLY_BUDGET'],
|
|
1198
1211
|
codex: ['CHATCCC_CODEX_PATH','CHATCCC_CODEX_MODEL','CHATCCC_CODEX_ALTERNATIVE_MODEL','CHATCCC_CODEX_EFFORT','CHATCCC_CODEX_FAST_MODE'],
|
|
1199
|
-
ccc: ['CHATCCC_CCC_API_KEY','CHATCCC_CCC_BASE_URL','CHATCCC_CCC_MODEL','CHATCCC_CCC_ALTERNATIVE_MODEL']
|
|
1212
|
+
ccc: ['CHATCCC_CCC_API_KEY','CHATCCC_CCC_BASE_URL','CHATCCC_CCC_MODEL','CHATCCC_CCC_ALTERNATIVE_MODEL','CHATCCC_CCC_EFFORT']
|
|
1200
1213
|
};
|
|
1201
1214
|
const FEISHU_FIELDS = ['CHATCCC_APP_ID','CHATCCC_APP_SECRET'];
|
|
1202
1215
|
const WEB_UI_FIELDS = ['CHATCCC_WEB_UI_OPEN_ON_START'];
|
|
@@ -1531,6 +1544,7 @@ function renderStep2() {
|
|
|
1531
1544
|
prefillNested('field-CHATCCC_CCC_BASE_URL', c.ccc.DEEPSEEK_BASE_URL);
|
|
1532
1545
|
prefillNested('field-CHATCCC_CCC_MODEL', c.ccc.model);
|
|
1533
1546
|
prefillNested('field-CHATCCC_CCC_ALTERNATIVE_MODEL', c.ccc.alternativeModel);
|
|
1547
|
+
prefillNested('field-CHATCCC_CCC_EFFORT', c.ccc.effort);
|
|
1534
1548
|
}
|
|
1535
1549
|
|
|
1536
1550
|
// 按已有 config 决定每个 Agent 默认是否开启:优先 enabled 字段,缺省时按"任一字段非空"
|
|
@@ -1706,6 +1720,7 @@ function renderStep3() {
|
|
|
1706
1720
|
lines.push('<div class="config-row"><span class="key">Base URL</span><span class="val">' + (vars.CHATCCC_CCC_BASE_URL || '(留空)') + '</span></div>');
|
|
1707
1721
|
lines.push('<div class="config-row"><span class="key">模型</span><span class="val">' + (vars.CHATCCC_CCC_MODEL || '(留空)') + '</span></div>');
|
|
1708
1722
|
lines.push('<div class="config-row"><span class="key">备选模型</span><span class="val">' + (vars.CHATCCC_CCC_ALTERNATIVE_MODEL || '(留空)') + '</span></div>');
|
|
1723
|
+
lines.push('<div class="config-row"><span class="key">Effort</span><span class="val">' + (vars.CHATCCC_CCC_EFFORT || '(留空)') + '</span></div>');
|
|
1709
1724
|
}
|
|
1710
1725
|
});
|
|
1711
1726
|
document.getElementById('review-content').innerHTML = lines.join('');
|
|
@@ -1999,7 +2014,7 @@ function editSection(section) {
|
|
|
1999
2014
|
'CHATCCC_CODEX_PATH': 'CLI 路径', 'CHATCCC_CODEX_MODEL': '模型', 'CHATCCC_CODEX_ALTERNATIVE_MODEL': '备选模型', 'CHATCCC_CODEX_EFFORT': 'Effort',
|
|
2000
2015
|
'CHATCCC_CODEX_FAST_MODE': 'Fast 模式',
|
|
2001
2016
|
'CHATCCC_CCC_API_KEY': 'API Key', 'CHATCCC_CCC_BASE_URL': 'Base URL',
|
|
2002
|
-
'CHATCCC_CCC_MODEL': '模型', 'CHATCCC_CCC_ALTERNATIVE_MODEL': '备选模型'
|
|
2017
|
+
'CHATCCC_CCC_MODEL': '模型', 'CHATCCC_CCC_ALTERNATIVE_MODEL': '备选模型', 'CHATCCC_CCC_EFFORT': 'Effort'
|
|
2003
2018
|
};
|
|
2004
2019
|
var hintMap = {
|
|
2005
2020
|
'CHATCCC_WEB_UI_OPEN_ON_START': '关闭后可继续手动访问 http://localhost:<端口>/;/restart、/update 和 Web UI 重启无论此项为何值都不会自动打开。',
|
|
@@ -2049,6 +2064,7 @@ function editSection(section) {
|
|
|
2049
2064
|
else if (key === 'CHATCCC_CCC_BASE_URL') val = state.config.ccc.DEEPSEEK_BASE_URL || '';
|
|
2050
2065
|
else if (key === 'CHATCCC_CCC_MODEL') val = state.config.ccc.model || '';
|
|
2051
2066
|
else if (key === 'CHATCCC_CCC_ALTERNATIVE_MODEL') val = state.config.ccc.alternativeModel || '';
|
|
2067
|
+
else if (key === 'CHATCCC_CCC_EFFORT') val = state.config.ccc.effort || '';
|
|
2052
2068
|
}
|
|
2053
2069
|
}
|
|
2054
2070
|
var isSecret = key.includes('SECRET') || key.includes('API_KEY');
|