chatccc 0.2.222 → 0.2.224

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.
@@ -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
+ });
@@ -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 {
@@ -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 {
@@ -60,6 +67,9 @@ function parseArgs(argv = process.argv.slice(2)): ParsedArgs {
60
67
  if (arg === "--model" && next !== undefined) {
61
68
  config.model = next;
62
69
  i++;
70
+ } else if (arg === "--effort" && next !== undefined) {
71
+ config.effort = next;
72
+ i++;
63
73
  } else if (arg === "--base-url" && next !== undefined) {
64
74
  config.baseURL = next;
65
75
  i++;
@@ -110,6 +120,7 @@ function printHelp(appConfig: RuntimeDeps["appConfig"]): void {
110
120
  "",
111
121
  "Options:",
112
122
  ` --model <name> Model name (overrides config.ccc.model, current default ${appConfig.ccc.model})`,
123
+ ` --effort <level> Reasoning effort: none/minimal/low/medium/high/xhigh/max (overrides config.ccc.effort)`,
113
124
  ` --base-url <url> API base URL (current default ${appConfig.ccc.DEEPSEEK_BASE_URL})`,
114
125
  " --api-key <key> API key (overrides config.ccc.DEEPSEEK_API_KEY)",
115
126
  " --cwd <path> Working directory",
@@ -373,10 +384,28 @@ async function runRepl(args: ParsedArgs): Promise<void> {
373
384
  currentAbort = new AbortController();
374
385
  const signal = currentAbort.signal;
375
386
 
387
+ // TTY 下用"过程区块"(飞书过程卡片的终端形态):固定区域原地更新、
388
+ // 工具调用折叠为单行;非 TTY(管道/CI)回退为纯文本流式输出。
389
+ const useTerminalBlock = process.stdout.isTTY === true;
390
+ const renderer = useTerminalBlock ? new TerminalProgressRenderer() : null;
391
+ let view: ProgressView | null = null;
392
+ if (renderer) {
393
+ view = progressView({ headerTitle: "生成中..." });
394
+ renderer.begin(view);
395
+ }
396
+ let rendererEnded = false;
397
+
376
398
  try {
377
399
  let lastAccumulated = "";
378
400
  for await (const event of session.chat(input, signal)) {
379
- if (event.type === "text") {
401
+ if (renderer && view) {
402
+ view = reduceProgress(view, event);
403
+ if (event.type === "text" || event.type === "compact") {
404
+ renderer.render(view);
405
+ } else {
406
+ renderer.flush();
407
+ }
408
+ } else if (event.type === "text") {
380
409
  const newText = event.accumulated.slice(lastAccumulated.length);
381
410
  process.stdout.write(newText);
382
411
  lastAccumulated = event.accumulated;
@@ -395,8 +424,20 @@ async function runRepl(args: ParsedArgs): Promise<void> {
395
424
  }
396
425
  }
397
426
  } catch (err) {
427
+ if (renderer && view) {
428
+ const aborted = err instanceof Error && err.name === "AbortError";
429
+ view = progressView({ ...view, status: aborted ? "stopped" : "error", showStop: false });
430
+ renderer.end(view);
431
+ rendererEnded = true;
432
+ console.log("");
433
+ }
398
434
  console.log(`\n${C.yellow}[error] ${(err as Error).message}${C.reset}`);
399
435
  } finally {
436
+ if (renderer && view && !rendererEnded) {
437
+ // 定型终态区块(完成/已停止/异常结束)留在屏幕上,恢复光标
438
+ renderer.end(view);
439
+ console.log("");
440
+ }
400
441
  currentAbort = null;
401
442
  ctrlCState.reset();
402
443
  }
@@ -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);