chatccc 0.2.221 → 0.2.223
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 +1 -1
- package/src/__tests__/builtin-chat-session.test.ts +40 -0
- package/src/__tests__/builtin-skills.test.ts +141 -0
- package/src/__tests__/card-plain-text.test.ts +7 -6
- package/src/__tests__/cards.test.ts +179 -178
- package/src/__tests__/progress-reducer.test.ts +110 -0
- package/src/__tests__/terminal-renderer.test.ts +143 -0
- package/src/builtin/cli.ts +38 -1
- package/src/builtin/index.ts +12 -0
- package/src/builtin/skills.ts +108 -0
- package/src/cards.ts +280 -275
- package/src/progress/reducer.ts +108 -0
- package/src/progress/terminal-renderer.ts +190 -0
- package/src/progress/view.ts +77 -0
- package/src/session.ts +935 -937
|
@@ -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
|
+
});
|
package/src/builtin/cli.ts
CHANGED
|
@@ -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 {
|
|
@@ -373,10 +380,28 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
373
380
|
currentAbort = new AbortController();
|
|
374
381
|
const signal = currentAbort.signal;
|
|
375
382
|
|
|
383
|
+
// TTY 下用"过程区块"(飞书过程卡片的终端形态):固定区域原地更新、
|
|
384
|
+
// 工具调用折叠为单行;非 TTY(管道/CI)回退为纯文本流式输出。
|
|
385
|
+
const useTerminalBlock = process.stdout.isTTY === true;
|
|
386
|
+
const renderer = useTerminalBlock ? new TerminalProgressRenderer() : null;
|
|
387
|
+
let view: ProgressView | null = null;
|
|
388
|
+
if (renderer) {
|
|
389
|
+
view = progressView({ headerTitle: "生成中..." });
|
|
390
|
+
renderer.begin(view);
|
|
391
|
+
}
|
|
392
|
+
let rendererEnded = false;
|
|
393
|
+
|
|
376
394
|
try {
|
|
377
395
|
let lastAccumulated = "";
|
|
378
396
|
for await (const event of session.chat(input, signal)) {
|
|
379
|
-
if (
|
|
397
|
+
if (renderer && view) {
|
|
398
|
+
view = reduceProgress(view, event);
|
|
399
|
+
if (event.type === "text" || event.type === "compact") {
|
|
400
|
+
renderer.render(view);
|
|
401
|
+
} else {
|
|
402
|
+
renderer.flush();
|
|
403
|
+
}
|
|
404
|
+
} else if (event.type === "text") {
|
|
380
405
|
const newText = event.accumulated.slice(lastAccumulated.length);
|
|
381
406
|
process.stdout.write(newText);
|
|
382
407
|
lastAccumulated = event.accumulated;
|
|
@@ -395,8 +420,20 @@ async function runRepl(args: ParsedArgs): Promise<void> {
|
|
|
395
420
|
}
|
|
396
421
|
}
|
|
397
422
|
} catch (err) {
|
|
423
|
+
if (renderer && view) {
|
|
424
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
425
|
+
view = progressView({ ...view, status: aborted ? "stopped" : "error", showStop: false });
|
|
426
|
+
renderer.end(view);
|
|
427
|
+
rendererEnded = true;
|
|
428
|
+
console.log("");
|
|
429
|
+
}
|
|
398
430
|
console.log(`\n${C.yellow}[error] ${(err as Error).message}${C.reset}`);
|
|
399
431
|
} finally {
|
|
432
|
+
if (renderer && view && !rendererEnded) {
|
|
433
|
+
// 定型终态区块(完成/已停止/异常结束)留在屏幕上,恢复光标
|
|
434
|
+
renderer.end(view);
|
|
435
|
+
console.log("");
|
|
436
|
+
}
|
|
400
437
|
currentAbort = null;
|
|
401
438
|
ctrlCState.reset();
|
|
402
439
|
}
|
package/src/builtin/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
defaultBuiltinSessionId,
|
|
21
21
|
} from "./context.ts";
|
|
22
22
|
import { createBuiltinFileTools } from "./file-tools.ts";
|
|
23
|
+
import { buildDefaultSkillDirs, buildSkillsIndexPrompt, scanSkillsDirs } from "./skills.ts";
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// 系统提示词 — 编译期冻结常量
|
|
@@ -118,6 +119,11 @@ export interface ChatSessionOptions {
|
|
|
118
119
|
keepRecentMessages?: number;
|
|
119
120
|
/** Optional tool-step limit. Leave unset for no step limit. */
|
|
120
121
|
maxSteps?: number;
|
|
122
|
+
/**
|
|
123
|
+
* Codex-style skill 扫描目录(<dir>/<name>/SKILL.md)。
|
|
124
|
+
* 缺省扫描 ~/.codex/skills、~/.agents/skills、<cwd>/.codex/skills。
|
|
125
|
+
*/
|
|
126
|
+
skillsDirs?: string[];
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
/**
|
|
@@ -180,6 +186,12 @@ export class ChatSession {
|
|
|
180
186
|
if (projectInstructions) {
|
|
181
187
|
systemContent.push("", projectInstructions);
|
|
182
188
|
}
|
|
189
|
+
// Codex-style skills 索引注入(name + description + 路径,模型按需 read_file 全文)
|
|
190
|
+
const skills = scanSkillsDirs(options.skillsDirs ?? buildDefaultSkillDirs(this.cwd));
|
|
191
|
+
const skillsPrompt = buildSkillsIndexPrompt(skills);
|
|
192
|
+
if (skillsPrompt) {
|
|
193
|
+
systemContent.push("", skillsPrompt);
|
|
194
|
+
}
|
|
183
195
|
if (options.systemPrompt) {
|
|
184
196
|
systemContent.push("", options.systemPrompt);
|
|
185
197
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* builtin/skills.ts — Codex-style skills 支持
|
|
3
|
+
*
|
|
4
|
+
* 从用户级(~/.codex/skills、~/.agents/skills)和项目级(<cwd>/.codex/skills)
|
|
5
|
+
* 扫描 Codex 目录式 skill(<name>/SKILL.md),解析 frontmatter 中的
|
|
6
|
+
* name + description,生成索引注入 system prompt。模型按需用 read_file
|
|
7
|
+
* 读取 SKILL.md 全文并执行(索引注入省 token,触发靠 description + 指令)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export interface BuiltinSkill {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
/** SKILL.md 的绝对路径 */
|
|
18
|
+
skillPath: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 解析 SKILL.md frontmatter(兼容 CRLF),返回 name + description;无 frontmatter 返回 null */
|
|
22
|
+
export function parseSkillFrontmatter(
|
|
23
|
+
content: string,
|
|
24
|
+
): { name: string; description: string } | null {
|
|
25
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/.exec(content);
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
const fm = match[1];
|
|
28
|
+
const nameMatch = /^name:\s*(.+?)\s*$/m.exec(fm);
|
|
29
|
+
if (!nameMatch) return null;
|
|
30
|
+
const descMatch = /^description:\s*(.+?)\s*$/m.exec(fm);
|
|
31
|
+
return {
|
|
32
|
+
name: nameMatch[1].trim(),
|
|
33
|
+
description: descMatch?.[1].trim() ?? "",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 扫描多个 skill 目录,返回去重后的 skill 列表。
|
|
39
|
+
* 同名 skill 后面的目录覆盖前面的(调用方应把项目级目录放最后)。
|
|
40
|
+
* 隐藏目录(.system 等)和无 SKILL.md 的目录会被跳过。
|
|
41
|
+
*/
|
|
42
|
+
export function scanSkillsDirs(dirs: string[]): BuiltinSkill[] {
|
|
43
|
+
const byName = new Map<string, BuiltinSkill>();
|
|
44
|
+
|
|
45
|
+
for (const dir of dirs) {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
continue; // 目录不存在或不可读:跳过
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!entry.isDirectory()) continue;
|
|
55
|
+
if (entry.name.startsWith(".")) continue; // 排除 .system 等隐藏/内置目录
|
|
56
|
+
|
|
57
|
+
const skillPath = join(dir, entry.name, "SKILL.md");
|
|
58
|
+
let content: string;
|
|
59
|
+
try {
|
|
60
|
+
content = readFileSync(skillPath, "utf-8");
|
|
61
|
+
} catch {
|
|
62
|
+
continue; // 没有 SKILL.md 的目录不是 skill
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const parsed = parseSkillFrontmatter(content);
|
|
66
|
+
if (!parsed) continue;
|
|
67
|
+
|
|
68
|
+
byName.set(parsed.name, {
|
|
69
|
+
name: parsed.name,
|
|
70
|
+
description: parsed.description,
|
|
71
|
+
skillPath,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [...byName.values()];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 默认 skill 扫描目录:
|
|
81
|
+
* 1. ~/.codex/skills(Codex CLI 旧路径,用户实际在用的地方)
|
|
82
|
+
* 2. ~/.agents/skills(Codex 标准全局目录)
|
|
83
|
+
* 3. <cwd>/.codex/skills(项目级,优先级最高,放最后)
|
|
84
|
+
*/
|
|
85
|
+
export function buildDefaultSkillDirs(cwd: string): string[] {
|
|
86
|
+
return [
|
|
87
|
+
join(homedir(), ".codex", "skills"),
|
|
88
|
+
join(homedir(), ".agents", "skills"),
|
|
89
|
+
join(cwd, ".codex", "skills"),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 生成 skill 索引提示词(注入 system prompt)。
|
|
95
|
+
* 索引只含 name + description + 路径,并指示模型在任务匹配时
|
|
96
|
+
* 先用 read_file 读取 SKILL.md 全文再执行——这是触发率的关键。
|
|
97
|
+
*/
|
|
98
|
+
export function buildSkillsIndexPrompt(skills: BuiltinSkill[]): string {
|
|
99
|
+
if (skills.length === 0) return "";
|
|
100
|
+
|
|
101
|
+
const lines = [
|
|
102
|
+
"## Available Skills (Codex-style)",
|
|
103
|
+
"The following Codex-style skills are available on this machine. When a user request matches a skill's description, first read its full SKILL.md with read_file, then follow the instructions in it exactly.",
|
|
104
|
+
"",
|
|
105
|
+
...skills.map((s) => `- **${s.name}** (\`${s.skillPath}\`): ${s.description || "(no description)"}`),
|
|
106
|
+
];
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|