@gird/ccli 1.0.2 → 2.0.0

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.
Files changed (45) hide show
  1. package/dist/args.js +35 -0
  2. package/dist/args.js.map +1 -0
  3. package/dist/config.js +120 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/env.js +39 -0
  6. package/dist/env.js.map +1 -0
  7. package/dist/index.js +36 -226
  8. package/dist/index.js.map +1 -0
  9. package/dist/repl.js +59 -0
  10. package/dist/repl.js.map +1 -0
  11. package/dist/version.js +13 -0
  12. package/dist/version.js.map +1 -0
  13. package/package.json +4 -14
  14. package/.env.example +0 -23
  15. package/CCLI.md +0 -15
  16. package/dist/agent/client-openai.js +0 -166
  17. package/dist/agent/client.js +0 -63
  18. package/dist/agent/factory.js +0 -39
  19. package/dist/agent/loop.js +0 -163
  20. package/dist/agent/pricing.js +0 -28
  21. package/dist/agent/prompt.js +0 -43
  22. package/dist/agent/provider.js +0 -18
  23. package/dist/cli/headless.js +0 -50
  24. package/dist/cli/mentions.js +0 -60
  25. package/dist/cli/session.js +0 -432
  26. package/dist/commands/custom.js +0 -46
  27. package/dist/commands/slash.js +0 -148
  28. package/dist/config/config.js +0 -128
  29. package/dist/config/dotenv.js +0 -59
  30. package/dist/context/project.js +0 -155
  31. package/dist/hooks/hooks.js +0 -82
  32. package/dist/permissions/permissions.js +0 -87
  33. package/dist/session/store.js +0 -81
  34. package/dist/tools/bash.js +0 -97
  35. package/dist/tools/filesystem.js +0 -215
  36. package/dist/tools/index.js +0 -45
  37. package/dist/tools/search.js +0 -0
  38. package/dist/tools/task.js +0 -35
  39. package/dist/tools/todo.js +0 -68
  40. package/dist/tools/types.js +0 -8
  41. package/dist/tools/web.js +0 -108
  42. package/dist/ui/diff.js +0 -105
  43. package/dist/ui/markdown.js +0 -137
  44. package/dist/ui/spinner.js +0 -64
  45. package/dist/ui/theme.js +0 -106
@@ -1,215 +0,0 @@
1
- /**
2
- * 文件系统工具:read_file / write_file / edit_file / list_dir。
3
- * 复刻 Claude Code 通过 fs 模块操作文件的本地能力。
4
- */
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, } from 'node:fs';
6
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
- import { c, brand } from '../ui/theme.js';
8
- import { renderDiff, diffStat } from '../ui/diff.js';
9
- function resolvePath(p, ctx) {
10
- return isAbsolute(p) ? p : resolve(ctx.cwd, p);
11
- }
12
- function rel(p, ctx) {
13
- const r = relative(ctx.cwd, p);
14
- return r.startsWith('..') ? p : r || '.';
15
- }
16
- export const readFileTool = {
17
- name: 'read_file',
18
- description: '读取文件内容。返回带行号的内容。可指定 offset/limit 读取大文件的部分。优先用此工具而非 cat/head。',
19
- inputSchema: {
20
- type: 'object',
21
- properties: {
22
- path: { type: 'string', description: '文件路径(相对或绝对)' },
23
- offset: { type: 'number', description: '起始行号(从 1 开始,可选)' },
24
- limit: { type: 'number', description: '读取行数(可选,默认 2000)' },
25
- },
26
- required: ['path'],
27
- },
28
- preview(input, ctx) {
29
- return `读取 ${brand.accent(rel(resolvePath(input.path, ctx), ctx))}`;
30
- },
31
- async run(input, ctx) {
32
- const full = resolvePath(input.path, ctx);
33
- if (!existsSync(full)) {
34
- return { content: `错误:文件不存在 ${input.path}`, isError: true };
35
- }
36
- const st = statSync(full);
37
- if (st.isDirectory()) {
38
- return { content: `错误:${input.path} 是目录,请用 list_dir`, isError: true };
39
- }
40
- let text;
41
- try {
42
- text = readFileSync(full, 'utf8');
43
- }
44
- catch (e) {
45
- return { content: `读取失败:${e.message}`, isError: true };
46
- }
47
- const lines = text.split('\n');
48
- const offset = Math.max(1, input.offset ?? 1);
49
- const limit = input.limit ?? 2000;
50
- const slice = lines.slice(offset - 1, offset - 1 + limit);
51
- const numbered = slice
52
- .map((l, i) => `${String(offset + i).padStart(6)}\t${l}`)
53
- .join('\n');
54
- const more = lines.length > offset - 1 + limit
55
- ? `\n…(共 ${lines.length} 行,已显示 ${offset}-${offset + slice.length - 1})`
56
- : '';
57
- return {
58
- content: numbered + more || '(空文件)',
59
- display: `${c.dim('读取')} ${rel(full, ctx)} ${c.dim(`(${slice.length} 行)`)}`,
60
- };
61
- },
62
- };
63
- export const writeFileTool = {
64
- name: 'write_file',
65
- description: '写入文件(覆盖已存在的内容,自动创建父目录)。用于创建新文件或完整重写文件。部分修改请用 edit_file。',
66
- inputSchema: {
67
- type: 'object',
68
- properties: {
69
- path: { type: 'string', description: '文件路径' },
70
- content: { type: 'string', description: '完整文件内容' },
71
- },
72
- required: ['path', 'content'],
73
- },
74
- permission(input, ctx) {
75
- const r = rel(resolvePath(input.path, ctx), ctx);
76
- return {
77
- tool: 'write_file',
78
- rule: `Write(${r})`,
79
- description: `写入文件 ${r}`,
80
- dangerous: false,
81
- };
82
- },
83
- preview(input, ctx) {
84
- const r = rel(resolvePath(input.path, ctx), ctx);
85
- const exists = existsSync(resolvePath(input.path, ctx));
86
- const lines = String(input.content ?? '').split('\n').length;
87
- return `${exists ? '覆盖' : '创建'} ${brand.accent(r)} ${c.dim(`(${lines} 行)`)}`;
88
- },
89
- async run(input, ctx) {
90
- const full = resolvePath(input.path, ctx);
91
- const existed = existsSync(full);
92
- const before = existed ? readFileSync(full, 'utf8') : '';
93
- try {
94
- const dir = dirname(full);
95
- if (!existsSync(dir))
96
- mkdirSync(dir, { recursive: true });
97
- writeFileSync(full, input.content, 'utf8');
98
- }
99
- catch (e) {
100
- return { content: `写入失败:${e.message}`, isError: true };
101
- }
102
- const lines = String(input.content).split('\n').length;
103
- let display = `${existed ? c.yellow('覆盖') : brand.ok('创建')} ${rel(full, ctx)} ${c.dim(`(${lines} 行)`)}`;
104
- if (existed && before !== input.content) {
105
- const stat = diffStat(before, input.content);
106
- display += ` ${c.dim(`(+${stat.add} -${stat.del})`)}\n${renderDiff(before, input.content)}`;
107
- }
108
- return {
109
- content: `${existed ? '已覆盖' : '已创建'} ${rel(full, ctx)}(${lines} 行)`,
110
- display,
111
- };
112
- },
113
- };
114
- export const editFileTool = {
115
- name: 'edit_file',
116
- description: '对文件做精确替换。old_text 必须在文件中唯一且精确匹配(含缩进/空白)。用于局部修改。如需替换全部出现,设 replace_all=true。',
117
- inputSchema: {
118
- type: 'object',
119
- properties: {
120
- path: { type: 'string', description: '文件路径' },
121
- old_text: { type: 'string', description: '要替换的原文(精确匹配)' },
122
- new_text: { type: 'string', description: '替换后的新文本' },
123
- replace_all: {
124
- type: 'boolean',
125
- description: '是否替换所有出现(默认 false)',
126
- },
127
- },
128
- required: ['path', 'old_text', 'new_text'],
129
- },
130
- permission(input, ctx) {
131
- const r = rel(resolvePath(input.path, ctx), ctx);
132
- return {
133
- tool: 'edit_file',
134
- rule: `Edit(${r})`,
135
- description: `编辑文件 ${r}`,
136
- dangerous: false,
137
- };
138
- },
139
- preview(input, ctx) {
140
- const r = rel(resolvePath(input.path, ctx), ctx);
141
- return `编辑 ${brand.accent(r)}`;
142
- },
143
- async run(input, ctx) {
144
- const full = resolvePath(input.path, ctx);
145
- if (!existsSync(full)) {
146
- return { content: `错误:文件不存在 ${input.path}`, isError: true };
147
- }
148
- let text = readFileSync(full, 'utf8');
149
- const before = text;
150
- const { old_text, new_text, replace_all } = input;
151
- if (old_text === new_text) {
152
- return { content: '错误:old_text 与 new_text 相同', isError: true };
153
- }
154
- const count = text.split(old_text).length - 1;
155
- if (count === 0) {
156
- return {
157
- content: `错误:在文件中未找到 old_text。请先 read_file 确认精确内容(含缩进)。`,
158
- isError: true,
159
- };
160
- }
161
- if (count > 1 && !replace_all) {
162
- return {
163
- content: `错误:old_text 出现了 ${count} 次,不唯一。请提供更多上下文,或设 replace_all=true。`,
164
- isError: true,
165
- };
166
- }
167
- text = replace_all
168
- ? text.split(old_text).join(new_text)
169
- : text.replace(old_text, new_text);
170
- writeFileSync(full, text, 'utf8');
171
- const stat = diffStat(before, text);
172
- const diff = renderDiff(before, text);
173
- return {
174
- content: `已编辑 ${rel(full, ctx)}(替换 ${replace_all ? count : 1} 处)`,
175
- display: `${brand.ok('编辑')} ${rel(full, ctx)} ${c.dim(`(+${stat.add} -${stat.del})`)}\n${diff}`,
176
- };
177
- },
178
- };
179
- export const listDirTool = {
180
- name: 'list_dir',
181
- description: '列出目录内容(文件与子目录)。优先用此工具而非 ls。',
182
- inputSchema: {
183
- type: 'object',
184
- properties: {
185
- path: { type: 'string', description: '目录路径(默认当前工作目录)' },
186
- },
187
- },
188
- preview(input, ctx) {
189
- return `列出 ${brand.accent(input.path || '.')}`;
190
- },
191
- async run(input, ctx) {
192
- const full = resolvePath(input.path || '.', ctx);
193
- if (!existsSync(full)) {
194
- return { content: `错误:路径不存在 ${input.path}`, isError: true };
195
- }
196
- let entries;
197
- try {
198
- entries = readdirSync(full);
199
- }
200
- catch (e) {
201
- return { content: `列目录失败:${e.message}`, isError: true };
202
- }
203
- const rows = entries
204
- .filter((e) => e !== 'node_modules' && e !== '.git')
205
- .sort()
206
- .map((e) => {
207
- const st = statSync(join(full, e));
208
- return st.isDirectory() ? `${e}/` : e;
209
- });
210
- return {
211
- content: rows.length ? rows.join('\n') : '(空目录)',
212
- display: `${c.dim('列目录')} ${rel(full, ctx)} ${c.dim(`(${rows.length} 项)`)}`,
213
- };
214
- },
215
- };
@@ -1,45 +0,0 @@
1
- import { readFileTool, writeFileTool, editFileTool, listDirTool, } from './filesystem.js';
2
- import { grepTool, globTool } from './search.js';
3
- import { bashTool } from './bash.js';
4
- import { todoWriteTool } from './todo.js';
5
- import { webFetchTool } from './web.js';
6
- export class ToolRegistry {
7
- tools = new Map();
8
- register(tool) {
9
- this.tools.set(tool.name, tool);
10
- }
11
- get(name) {
12
- return this.tools.get(name);
13
- }
14
- list() {
15
- return [...this.tools.values()];
16
- }
17
- }
18
- /** 完整工具集(主 Agent 使用)。 */
19
- export function createDefaultRegistry() {
20
- const reg = new ToolRegistry();
21
- // 感知类
22
- reg.register(readFileTool);
23
- reg.register(listDirTool);
24
- reg.register(grepTool);
25
- reg.register(globTool);
26
- reg.register(webFetchTool);
27
- // 行动类
28
- reg.register(writeFileTool);
29
- reg.register(editFileTool);
30
- reg.register(bashTool);
31
- // 协调类
32
- reg.register(todoWriteTool);
33
- return reg;
34
- }
35
- /** 只读工具集(子代理 / Task 使用,防止破坏与递归)。 */
36
- export function createReadOnlyRegistry() {
37
- const reg = new ToolRegistry();
38
- reg.register(readFileTool);
39
- reg.register(listDirTool);
40
- reg.register(grepTool);
41
- reg.register(globTool);
42
- reg.register(webFetchTool);
43
- return reg;
44
- }
45
- export * from './types.js';
Binary file
@@ -1,35 +0,0 @@
1
- import { brand, c } from '../ui/theme.js';
2
- export const SUBAGENT_SYSTEM = `你是一个被主 Agent 派生的调研子代理。
3
- 你只拥有只读工具(read_file / list_dir / grep / glob / web_fetch)。
4
- 请自主、彻底地完成被交付的调研任务,然后用简洁的中文输出**最终结论**:
5
- - 直接给出发现的事实、相关文件路径(用 路径:行号)与结论。
6
- - 不要返回中间过程的啰嗦叙述,主 Agent 只需要你的结论。`;
7
- export function createTaskTool(runner) {
8
- return {
9
- name: 'task',
10
- description: '派生一个只读子代理来完成聚焦的调研类任务(如「在整个代码库中找出所有使用 X 的位置并总结」)。适合需要多步搜索/阅读但你不想让中间过程占满上下文的场景。子代理只能读,不能修改文件或执行命令。返回子代理的最终结论。',
11
- inputSchema: {
12
- type: 'object',
13
- properties: {
14
- description: { type: 'string', description: '任务简短标题(3-6 字)' },
15
- prompt: {
16
- type: 'string',
17
- description: '交给子代理的完整、自洽的任务说明',
18
- },
19
- },
20
- required: ['description', 'prompt'],
21
- },
22
- preview(input) {
23
- return `${brand.accent('task')}: ${input.description}`;
24
- },
25
- async run(input, ctx) {
26
- const { description, prompt } = input;
27
- const res = await runner(String(description ?? '子任务'), String(prompt ?? ''), ctx.cwd);
28
- return {
29
- content: res.text || '(子代理无输出)',
30
- isError: res.isError,
31
- display: `${brand.ok('✓')} 子代理「${description}」完成 ${c.dim(`(${res.text.length} 字结论)`)}`,
32
- };
33
- },
34
- };
35
- }
@@ -1,68 +0,0 @@
1
- import { c, brand } from '../ui/theme.js';
2
- /** 会话级 todo 状态(整表覆盖式更新)。 */
3
- export class TodoStore {
4
- items = [];
5
- listeners = [];
6
- set(items) {
7
- this.items = items;
8
- for (const l of this.listeners)
9
- l(items);
10
- }
11
- onChange(fn) {
12
- this.listeners.push(fn);
13
- }
14
- }
15
- export function renderTodos(items) {
16
- if (!items.length)
17
- return c.dim('(清单为空)');
18
- const done = items.filter((t) => t.status === 'completed').length;
19
- const lines = items.map((t) => {
20
- if (t.status === 'completed')
21
- return ` ${brand.ok('✔')} ${c.dim(c.strike(t.content))}`;
22
- if (t.status === 'in_progress')
23
- return ` ${brand.accent('▸')} ${c.bold(t.content)}`;
24
- return ` ${c.dim('○')} ${t.content}`;
25
- });
26
- return (`${brand.primary('任务清单')} ${c.dim(`(${done}/${items.length})`)}\n` +
27
- lines.join('\n'));
28
- }
29
- export const todoWriteTool = {
30
- name: 'todo_write',
31
- description: '维护任务清单以追踪多步骤工作的进度。每次传入完整的清单(整表覆盖)。对于包含 3 步以上的复杂任务,应在开始时创建清单,并随进展把每项标记为 in_progress / completed。同一时刻只应有一项 in_progress。',
32
- inputSchema: {
33
- type: 'object',
34
- properties: {
35
- todos: {
36
- type: 'array',
37
- description: '完整的任务清单',
38
- items: {
39
- type: 'object',
40
- properties: {
41
- content: { type: 'string', description: '任务描述(祈使式)' },
42
- status: {
43
- type: 'string',
44
- enum: ['pending', 'in_progress', 'completed'],
45
- },
46
- activeForm: { type: 'string', description: '进行时描述(可选)' },
47
- },
48
- required: ['content', 'status'],
49
- },
50
- },
51
- },
52
- required: ['todos'],
53
- },
54
- async run(input, ctx) {
55
- const todos = Array.isArray(input.todos) ? input.todos : [];
56
- const inProgress = todos.filter((t) => t.status === 'in_progress').length;
57
- ctx.todos?.set(todos);
58
- const warn = inProgress > 1
59
- ? '\n(提醒:同一时刻应只有一项 in_progress)'
60
- : '';
61
- const summary = `已更新任务清单:共 ${todos.length} 项,` +
62
- `完成 ${todos.filter((t) => t.status === 'completed').length} 项。${warn}`;
63
- return {
64
- content: summary,
65
- display: renderTodos(todos),
66
- };
67
- },
68
- };
@@ -1,8 +0,0 @@
1
- /** 转成 Anthropic SDK 的 tool 定义。 */
2
- export function toAnthropicTool(t) {
3
- return {
4
- name: t.name,
5
- description: t.description,
6
- input_schema: t.inputSchema,
7
- };
8
- }
package/dist/tools/web.js DELETED
@@ -1,108 +0,0 @@
1
- import { c, brand } from '../ui/theme.js';
2
- const MAX_CHARS = 12_000;
3
- const TIMEOUT = 15_000;
4
- /** 极简 HTML → 文本:去脚本/样式/标签,解码常见实体,折叠空白。 */
5
- export function htmlToText(html) {
6
- let s = html;
7
- s = s.replace(/<script[\s\S]*?<\/script>/gi, ' ');
8
- s = s.replace(/<style[\s\S]*?<\/style>/gi, ' ');
9
- s = s.replace(/<!--[\s\S]*?-->/g, ' ');
10
- // 块级标签转换行
11
- s = s.replace(/<\/(p|div|section|article|h[1-6]|li|tr|br)>/gi, '\n');
12
- s = s.replace(/<br\s*\/?>/gi, '\n');
13
- // 去掉剩余标签
14
- s = s.replace(/<[^>]+>/g, ' ');
15
- // 实体
16
- const entities = {
17
- '&nbsp;': ' ',
18
- '&amp;': '&',
19
- '&lt;': '<',
20
- '&gt;': '>',
21
- '&quot;': '"',
22
- '&#39;': "'",
23
- '&apos;': "'",
24
- };
25
- s = s.replace(/&[a-z#0-9]+;/gi, (m) => entities[m.toLowerCase()] ?? m);
26
- // 折叠空白
27
- s = s
28
- .split('\n')
29
- .map((l) => l.replace(/[ \t]+/g, ' ').trim())
30
- .filter((l) => l.length > 0)
31
- .join('\n');
32
- return s;
33
- }
34
- export const webFetchTool = {
35
- name: 'web_fetch',
36
- description: '抓取一个 http/https 网址的内容并转为精简文本,用于查阅在线文档或网页。返回正文文本(已截断)。',
37
- inputSchema: {
38
- type: 'object',
39
- properties: {
40
- url: { type: 'string', description: 'http/https 网址' },
41
- prompt: {
42
- type: 'string',
43
- description: '你关注的问题(可选,会附在内容前作为提示)',
44
- },
45
- },
46
- required: ['url'],
47
- },
48
- permission(input) {
49
- return {
50
- tool: 'web_fetch',
51
- rule: `WebFetch(${safeHost(input.url)})`,
52
- description: `抓取网页 ${input.url}`,
53
- dangerous: false,
54
- };
55
- },
56
- preview(input) {
57
- return `抓取 ${brand.accent(input.url)}`;
58
- },
59
- async run(input, _ctx) {
60
- const url = String(input.url || '');
61
- if (!/^https?:\/\//i.test(url)) {
62
- return { content: '错误:仅支持 http/https 协议。', isError: true };
63
- }
64
- const controller = new AbortController();
65
- const timer = setTimeout(() => controller.abort(), TIMEOUT);
66
- try {
67
- const res = await fetch(url, {
68
- signal: controller.signal,
69
- headers: { 'User-Agent': 'CCLI/1.0 (+web_fetch)' },
70
- redirect: 'follow',
71
- });
72
- clearTimeout(timer);
73
- if (!res.ok) {
74
- return { content: `请求失败:HTTP ${res.status}`, isError: true };
75
- }
76
- const ctype = res.headers.get('content-type') || '';
77
- const raw = await res.text();
78
- let text;
79
- if (ctype.includes('html'))
80
- text = htmlToText(raw);
81
- else if (ctype.includes('json'))
82
- text = raw;
83
- else
84
- text = raw;
85
- if (text.length > MAX_CHARS) {
86
- text = text.slice(0, MAX_CHARS) + '\n…(内容已截断)';
87
- }
88
- const header = input.prompt ? `[关注点] ${input.prompt}\n\n` : '';
89
- return {
90
- content: `${header}来源:${url}\n\n${text}`,
91
- display: `${brand.ok('✓')} 抓取 ${url} ${c.dim(`(${text.length} 字)`)}`,
92
- };
93
- }
94
- catch (e) {
95
- clearTimeout(timer);
96
- const msg = e?.name === 'AbortError' ? '请求超时' : e?.message ?? String(e);
97
- return { content: `抓取失败:${msg}`, isError: true };
98
- }
99
- },
100
- };
101
- function safeHost(url) {
102
- try {
103
- return new URL(url).host;
104
- }
105
- catch {
106
- return url;
107
- }
108
- }
package/dist/ui/diff.js DELETED
@@ -1,105 +0,0 @@
1
- /**
2
- * 行级 diff —— 基于最长公共子序列(LCS)。
3
- * 用于 edit_file / write_file 后展示真正的变更。
4
- */
5
- import { c, brand } from './theme.js';
6
- /** 计算两段文本的行级 diff 操作序列。 */
7
- export function diffLines(oldText, newText) {
8
- const a = oldText.split('\n');
9
- const b = newText.split('\n');
10
- const n = a.length;
11
- const m = b.length;
12
- // LCS 动态规划表
13
- const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
14
- for (let i = n - 1; i >= 0; i--) {
15
- for (let j = m - 1; j >= 0; j--) {
16
- dp[i][j] =
17
- a[i] === b[j]
18
- ? dp[i + 1][j + 1] + 1
19
- : Math.max(dp[i + 1][j], dp[i][j + 1]);
20
- }
21
- }
22
- const ops = [];
23
- let i = 0;
24
- let j = 0;
25
- while (i < n && j < m) {
26
- if (a[i] === b[j]) {
27
- ops.push({ type: 'eq', line: a[i] });
28
- i++;
29
- j++;
30
- }
31
- else if (dp[i + 1][j] >= dp[i][j + 1]) {
32
- ops.push({ type: 'del', line: a[i] });
33
- i++;
34
- }
35
- else {
36
- ops.push({ type: 'add', line: b[j] });
37
- j++;
38
- }
39
- }
40
- while (i < n)
41
- ops.push({ type: 'del', line: a[i++] });
42
- while (j < m)
43
- ops.push({ type: 'add', line: b[j++] });
44
- return ops;
45
- }
46
- /**
47
- * 渲染 diff,带行号,折叠大段未变更上下文(只在变更附近保留 context 行)。
48
- */
49
- export function renderDiff(oldText, newText, context = 2) {
50
- const ops = diffLines(oldText, newText);
51
- // 标记需要显示的行(变更行及其上下文)
52
- const show = new Array(ops.length).fill(false);
53
- for (let k = 0; k < ops.length; k++) {
54
- if (ops[k].type !== 'eq') {
55
- for (let d = -context; d <= context; d++) {
56
- const idx = k + d;
57
- if (idx >= 0 && idx < ops.length)
58
- show[idx] = true;
59
- }
60
- }
61
- }
62
- const out = [];
63
- let oldLn = 0;
64
- let newLn = 0;
65
- let gapPrinted = false;
66
- for (let k = 0; k < ops.length; k++) {
67
- const op = ops[k];
68
- if (op.type === 'eq') {
69
- oldLn++;
70
- newLn++;
71
- }
72
- else if (op.type === 'del') {
73
- oldLn++;
74
- }
75
- else {
76
- newLn++;
77
- }
78
- if (!show[k]) {
79
- if (!gapPrinted && out.length > 0) {
80
- out.push(c.dim(' ⋮'));
81
- gapPrinted = true;
82
- }
83
- continue;
84
- }
85
- gapPrinted = false;
86
- if (op.type === 'eq') {
87
- out.push(c.dim(`${String(newLn).padStart(4)} ${op.line}`));
88
- }
89
- else if (op.type === 'del') {
90
- out.push(c.red(`${String(oldLn).padStart(4)} - ${op.line}`));
91
- }
92
- else {
93
- out.push(brand.ok(`${String(newLn).padStart(4)} + ${op.line}`));
94
- }
95
- }
96
- return out.join('\n');
97
- }
98
- /** 统计 +/- 行数。 */
99
- export function diffStat(oldText, newText) {
100
- const ops = diffLines(oldText, newText);
101
- return {
102
- add: ops.filter((o) => o.type === 'add').length,
103
- del: ops.filter((o) => o.type === 'del').length,
104
- };
105
- }