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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.224",
3
+ "version": "0.2.225",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
  });
@@ -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
- console.log(`${C.dim}bye${C.reset}`);
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
- console.log(`${C.dim}session cleared${C.reset}`);
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
- console.log(`${C.dim}${session.turnCount} conversation turns${C.reset}`);
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
- console.log("");
474
+ process.stdout.write("\n");
433
475
  }
434
- console.log(`\n${C.yellow}[error] ${(err as Error).message}${C.reset}`);
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
- console.log("");
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.log(`\n${C.dim}bye${C.reset}`);
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.log(`\n${C.yellow}[interrupting...]${C.reset}`);
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.log(`\n${C.dim}Press Ctrl+C again to interrupt current response${C.reset}`);
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.log(`\n${C.dim}Press Ctrl+C again to exit, or type exit${C.reset}`);
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
- console.log("");
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
- ? `${DIM}…${RESET}`
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
- return ` ${emoji} ${tool.name} ${mark} ${DIM}${info}${RESET}`;
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.length > width ? s.slice(0, Math.max(0, width - 1)) : 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
- this.out.write(`\x1b[${this.blockLines}A`);
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');