chatccc 0.2.223 → 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.
@@ -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
  });
@@ -41,6 +41,7 @@ export function createCccAdapter(options: CccAdapterOptions = {}): ToolAdapter {
41
41
  ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
42
42
  ...(options.baseURL !== undefined ? { baseURL: options.baseURL } : {}),
43
43
  ...(options.model !== undefined ? { model: options.model } : {}),
44
+ ...(options.effort !== undefined ? { effort: options.effort } : {}),
44
45
  };
45
46
 
46
47
  return {
@@ -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];
@@ -67,6 +72,9 @@ function parseArgs(argv = process.argv.slice(2)): ParsedArgs {
67
72
  if (arg === "--model" && next !== undefined) {
68
73
  config.model = next;
69
74
  i++;
75
+ } else if (arg === "--effort" && next !== undefined) {
76
+ config.effort = next;
77
+ i++;
70
78
  } else if (arg === "--base-url" && next !== undefined) {
71
79
  config.baseURL = next;
72
80
  i++;
@@ -93,20 +101,22 @@ function parseArgs(argv = process.argv.slice(2)): ParsedArgs {
93
101
  } else if (arg === "--prompt" && next !== undefined) {
94
102
  prompt = next;
95
103
  i++;
104
+ } else if (arg === "--plain") {
105
+ plain = true;
96
106
  } else if (arg === "--help" || arg === "-h") {
97
107
  help = true;
98
108
  }
99
109
  }
100
110
 
101
- return { config, options, listSessions, resume, help, streamJson, prompt };
111
+ return { config, options, listSessions, resume, help, streamJson, prompt, plain };
102
112
  }
103
113
 
104
114
  async function loadRuntime(): Promise<RuntimeDeps> {
105
- const [{ ChatSession }, { config: appConfig }] = await Promise.all([
115
+ const [{ ChatSession }, { config: appConfig, fileLog }] = await Promise.all([
106
116
  import("./index.js"),
107
117
  import("../config.ts"),
108
118
  ]);
109
- return { ChatSession, appConfig };
119
+ return { ChatSession, appConfig, fileLog };
110
120
  }
111
121
 
112
122
  function printHelp(appConfig: RuntimeDeps["appConfig"]): void {
@@ -117,6 +127,7 @@ function printHelp(appConfig: RuntimeDeps["appConfig"]): void {
117
127
  "",
118
128
  "Options:",
119
129
  ` --model <name> Model name (overrides config.ccc.model, current default ${appConfig.ccc.model})`,
130
+ ` --effort <level> Reasoning effort: none/minimal/low/medium/high/xhigh/max (overrides config.ccc.effort)`,
120
131
  ` --base-url <url> API base URL (current default ${appConfig.ccc.DEEPSEEK_BASE_URL})`,
121
132
  " --api-key <key> API key (overrides config.ccc.DEEPSEEK_API_KEY)",
122
133
  " --cwd <path> Working directory",
@@ -125,6 +136,7 @@ function printHelp(appConfig: RuntimeDeps["appConfig"]): void {
125
136
  " --list-sessions List saved ccc sessions and exit",
126
137
  " --stream-json One-shot mode: write JSONL events to stdout",
127
138
  " --prompt <text> Prompt text for --stream-json",
139
+ " --plain Force plain streaming output (no progress block renderer)",
128
140
  " --help, -h Show help",
129
141
  "",
130
142
  "Default config source:",
@@ -307,8 +319,32 @@ const C = {
307
319
  yellow: "\x1b[33m",
308
320
  };
309
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
+
310
346
  async function runRepl(args: ParsedArgs): Promise<void> {
311
- const { ChatSession, appConfig } = await loadRuntime();
347
+ const { ChatSession, appConfig, fileLog } = await loadRuntime();
312
348
 
313
349
  const cwd = resolvePath(args.options.cwd ?? process.cwd());
314
350
  let resolvedSession;
@@ -326,6 +362,12 @@ async function runRepl(args: ParsedArgs): Promise<void> {
326
362
  console.log(`${C.dim}Type a message to chat. Double Ctrl+C interrupts generation or exits. Type exit to quit.${C.reset}`);
327
363
  console.log("");
328
364
 
365
+ // 交互渲染模式下 console 输出只写日志文件、不回显到终端(渲染器独占 stdout,
366
+ // 避免生成中日志混入区块破坏行数)。--plain 无渲染器,不需要静音。
367
+ if (process.stdout.isTTY === true && !args.plain) {
368
+ muteConsoleLogToFile(fileLog.logPath);
369
+ }
370
+
329
371
  let session: InstanceType<typeof ChatSession>;
330
372
  try {
331
373
  session = new ChatSession(args.config, {
@@ -359,20 +401,20 @@ async function runRepl(args: ParsedArgs): Promise<void> {
359
401
  }
360
402
 
361
403
  if (input === "exit") {
362
- console.log(`${C.dim}bye${C.reset}`);
404
+ process.stdout.write(`${C.dim}bye${C.reset}\n`);
363
405
  rl.close();
364
406
  return;
365
407
  }
366
408
 
367
409
  if (input === "/clear") {
368
410
  session.reset();
369
- console.log(`${C.dim}session cleared${C.reset}`);
411
+ process.stdout.write(`${C.dim}session cleared${C.reset}\n`);
370
412
  rl.prompt();
371
413
  return;
372
414
  }
373
415
 
374
416
  if (input === "/history") {
375
- console.log(`${C.dim}${session.turnCount} conversation turns${C.reset}`);
417
+ process.stdout.write(`${C.dim}${session.turnCount} conversation turns${C.reset}\n`);
376
418
  rl.prompt();
377
419
  return;
378
420
  }
@@ -381,12 +423,16 @@ async function runRepl(args: ParsedArgs): Promise<void> {
381
423
  const signal = currentAbort.signal;
382
424
 
383
425
  // TTY 下用"过程区块"(飞书过程卡片的终端形态):固定区域原地更新、
384
- // 工具调用折叠为单行;非 TTY(管道/CI)回退为纯文本流式输出。
385
- const useTerminalBlock = process.stdout.isTTY === true;
426
+ // 工具调用折叠为单行;非 TTY(管道/CI)或 --plain 回退为纯文本流式输出。
427
+ const useTerminalBlock = process.stdout.isTTY === true && !args.plain;
386
428
  const renderer = useTerminalBlock ? new TerminalProgressRenderer() : null;
387
429
  let view: ProgressView | null = null;
388
430
  if (renderer) {
389
431
  view = progressView({ headerTitle: "生成中..." });
432
+ // 先回行首换行再 begin:让过程区块从输入行下方开始,避免首帧 \r\x1b[2K
433
+ // 清掉用户刚输入的问题行(历史文本不被刷掉)。\r\n 兼容 readline
434
+ // 行提交后光标仍停在输入行行尾的情况。
435
+ process.stdout.write("\r\n");
390
436
  renderer.begin(view);
391
437
  }
392
438
  let rendererEnded = false;
@@ -425,14 +471,14 @@ async function runRepl(args: ParsedArgs): Promise<void> {
425
471
  view = progressView({ ...view, status: aborted ? "stopped" : "error", showStop: false });
426
472
  renderer.end(view);
427
473
  rendererEnded = true;
428
- console.log("");
474
+ process.stdout.write("\n");
429
475
  }
430
- 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}`);
431
477
  } finally {
432
478
  if (renderer && view && !rendererEnded) {
433
479
  // 定型终态区块(完成/已停止/异常结束)留在屏幕上,恢复光标
434
480
  renderer.end(view);
435
- console.log("");
481
+ process.stdout.write("\n");
436
482
  }
437
483
  currentAbort = null;
438
484
  ctrlCState.reset();
@@ -445,31 +491,31 @@ async function runRepl(args: ParsedArgs): Promise<void> {
445
491
  const action = ctrlCState.press(currentAbort !== null);
446
492
 
447
493
  if (action === "exit") {
448
- console.log(`\n${C.dim}bye${C.reset}`);
494
+ console.error(`\n${C.dim}bye${C.reset}`);
449
495
  rl.close();
450
496
  return;
451
497
  }
452
498
 
453
499
  if (action === "interrupt") {
454
- console.log(`\n${C.yellow}[interrupting...]${C.reset}`);
500
+ console.error(`\n${C.yellow}[interrupting...]${C.reset}`);
455
501
  currentAbort?.abort();
456
502
  currentAbort = null;
457
503
  return;
458
504
  }
459
505
 
460
506
  if (action === "arm-interrupt") {
461
- 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}`);
462
508
  return;
463
509
  }
464
510
 
465
511
  if (action === "arm-exit") {
466
- 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}`);
467
513
  rl.prompt();
468
514
  }
469
515
  });
470
516
 
471
517
  rl.on("close", () => {
472
- console.log("");
518
+ process.stdout.write("\n");
473
519
  process.exit(0);
474
520
  });
475
521
  }
@@ -100,6 +100,11 @@ export interface ChatSessionConfig {
100
100
  apiKey?: string;
101
101
  /** 模型名称;传入时覆盖 config.ccc.model */
102
102
  model?: string;
103
+ /**
104
+ * Reasoning effort(none/minimal/low/medium/high/xhigh/max);
105
+ * 传入时覆盖 config.ccc.effort,留空不传 reasoning_effort 请求字段。
106
+ */
107
+ effort?: string;
103
108
  }
104
109
 
105
110
  export interface ChatSessionOptions {
@@ -156,6 +161,7 @@ export class ChatSession {
156
161
  private cwd: string;
157
162
  private context: BuiltinContextManager;
158
163
  private maxSteps?: number;
164
+ private effort: string;
159
165
 
160
166
  constructor(
161
167
  overrides: ChatSessionConfig = {},
@@ -170,6 +176,7 @@ export class ChatSession {
170
176
 
171
177
  const baseURL = overrides.baseURL ?? appConfig.ccc.DEEPSEEK_BASE_URL;
172
178
  const modelId = overrides.model ?? appConfig.ccc.model;
179
+ this.effort = (overrides.effort ?? appConfig.ccc.effort ?? "").trim();
173
180
 
174
181
  const provider = createOpenAICompatible({
175
182
  name: "deepseek",
@@ -260,6 +267,9 @@ export class ChatSession {
260
267
  tools: createBuiltinFileTools(this.cwd),
261
268
  stopWhen: maxSteps !== undefined ? stepCountIs(maxSteps) : isLoopFinished(),
262
269
  abortSignal: signal,
270
+ // DeepSeek OpenAI 兼容接口:providerOptions.deepseek.reasoningEffort
271
+ // 由 @ai-sdk/openai-compatible 自动映射为请求体 reasoning_effort 字段
272
+ ...(this.effort ? { providerOptions: { deepseek: { reasoningEffort: this.effort } } } : {}),
263
273
  });
264
274
 
265
275
  const stream = result.fullStream ?? textStreamToFullStream(result.textStream);