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.
- package/config.sample.json +40 -39
- package/package.json +1 -1
- package/src/__tests__/builtin-chat-session.test.ts +33 -0
- package/src/__tests__/builtin-config.test.ts +1 -0
- package/src/__tests__/ccc-adapter.test.ts +20 -0
- package/src/__tests__/config-reload.test.ts +52 -52
- package/src/__tests__/config-sample.test.ts +41 -40
- package/src/__tests__/orchestrator.test.ts +836 -803
- package/src/__tests__/session.test.ts +1183 -1173
- package/src/__tests__/terminal-renderer.test.ts +104 -0
- package/src/__tests__/web-ui.test.ts +3 -0
- package/src/adapters/ccc-adapter.ts +1 -0
- package/src/builtin/cli.ts +63 -17
- package/src/builtin/index.ts +10 -0
- package/src/config.ts +203 -192
- package/src/progress/terminal-renderer.ts +112 -8
- package/src/session.ts +5 -2
- package/src/web-ui.ts +626 -607
|
@@ -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/session.ts
CHANGED
|
@@ -526,7 +526,7 @@ export function getEffectiveEffortForTool(tool: string, sessionId?: string): str
|
|
|
526
526
|
const override = sessionEffortOverrides.get(sessionId);
|
|
527
527
|
if (override) return override;
|
|
528
528
|
}
|
|
529
|
-
if (tool === "claude" || tool === "codex") {
|
|
529
|
+
if (tool === "claude" || tool === "codex" || tool === "ccc") {
|
|
530
530
|
return getDefaultEffortForTool(tool);
|
|
531
531
|
}
|
|
532
532
|
return "";
|
|
@@ -597,7 +597,10 @@ export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter
|
|
|
597
597
|
fastMode: effectiveFastMode,
|
|
598
598
|
});
|
|
599
599
|
} else if (tool === "ccc") {
|
|
600
|
-
adapter = createCccAdapter({
|
|
600
|
+
adapter = createCccAdapter({
|
|
601
|
+
model: effectiveModel || undefined,
|
|
602
|
+
effort: effectiveEffort || undefined,
|
|
603
|
+
});
|
|
601
604
|
} else {
|
|
602
605
|
adapter = createClaudeAdapter({
|
|
603
606
|
model: effectiveModel,
|