@huicai/cli 2026.710.97949 → 2026.711.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.
- package/bin/huicai.js +93 -2
- package/package.json +2 -2
- package/src/advisor.js +46 -0
- package/src/agent.js +168 -15
- package/src/api.js +33 -0
- package/src/autofix.js +90 -0
- package/src/checkpoints.js +99 -0
- package/src/clipboard.js +34 -0
- package/src/codeindex.js +229 -0
- package/src/colors.js +28 -3
- package/src/commands.js +963 -8
- package/src/compaction.js +6 -2
- package/src/config.js +21 -1
- package/src/context-report.js +119 -0
- package/src/context.js +8 -0
- package/src/cron.js +64 -0
- package/src/daemon.js +62 -0
- package/src/diffview.js +115 -0
- package/src/doctor.js +72 -0
- package/src/forks.js +59 -0
- package/src/gitflow.js +140 -0
- package/src/hooks.js +207 -0
- package/src/insights.js +102 -0
- package/src/kbsync.js +141 -0
- package/src/memory.js +51 -0
- package/src/permissions.js +40 -2
- package/src/render.js +4 -0
- package/src/repl.js +69 -3
- package/src/scan.js +84 -0
- package/src/schedule.js +48 -0
- package/src/settings.js +0 -0
- package/src/subagent.js +126 -24
- package/src/tools.js +19 -4
- package/src/transcript.js +94 -0
- package/src/tui/app.js +103 -3
- package/src/watch.js +74 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// 会话检查点(对标 Claude Code /rewind):每个用户回合前快照「对话位置 + 该回合将改动文件的原始内容」,
|
|
2
|
+
// 可把对话/文件/两者回滚到某个检查点之前。文件原文由 runTurn 经 fileJournal 在写入前记录(见 agent.js)。
|
|
3
|
+
//
|
|
4
|
+
// 检查点存活于当前运行的会话(内存),不落盘(避免把大段文件内容写进会话 JSON)。
|
|
5
|
+
// 语义:检查点 N 记录「第 N 个用户回合开始前」的状态;回滚到 N 即回到该回合之前。
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const MAX_CHECKPOINTS = 100;
|
|
11
|
+
|
|
12
|
+
function safeClone(o) {
|
|
13
|
+
try {
|
|
14
|
+
return o ? JSON.parse(JSON.stringify(o)) : {};
|
|
15
|
+
} catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function deriveLabel(text) {
|
|
21
|
+
const first = String(text || '').replace(/\s+/g, ' ').trim();
|
|
22
|
+
return first ? (first.length > 60 ? first.slice(0, 60) + '…' : first) : '(空)';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createCheckpoints() {
|
|
26
|
+
const list = []; // {id, time, label, messagesLen, compaction, journal:[{path,before,existed,tooLarge?}]}
|
|
27
|
+
let seq = 0;
|
|
28
|
+
|
|
29
|
+
/** 回合开始前调用:记录对话位置,返回一个 journal 数组交给 runTurn 累积本回合的文件原文。 */
|
|
30
|
+
function snapshot({ session, label }) {
|
|
31
|
+
const cp = {
|
|
32
|
+
id: ++seq,
|
|
33
|
+
time: Date.now(),
|
|
34
|
+
label: deriveLabel(label),
|
|
35
|
+
messagesLen: session.messages.length,
|
|
36
|
+
compaction: safeClone(session.compaction),
|
|
37
|
+
journal: [],
|
|
38
|
+
};
|
|
39
|
+
list.push(cp);
|
|
40
|
+
// 上限保护:超出则丢弃最早的(其对应文件原文一并释放)
|
|
41
|
+
if (list.length > MAX_CHECKPOINTS) list.splice(0, list.length - MAX_CHECKPOINTS);
|
|
42
|
+
return cp.journal;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 从某检查点(含)起、各文件最早一次记录的原始内容(用于回滚到该点之前的文件状态)。 */
|
|
46
|
+
function changedFilesSince(pos) {
|
|
47
|
+
const map = new Map(); // relPath -> journal entry(最早)
|
|
48
|
+
for (let i = pos; i < list.length; i++) {
|
|
49
|
+
for (const e of list[i].journal) {
|
|
50
|
+
if (e && e.path && !map.has(e.path)) map.set(e.path, e);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 回滚到检查点 id。
|
|
58
|
+
* @param {number} id 检查点编号
|
|
59
|
+
* @param {'chat'|'code'|'both'} mode
|
|
60
|
+
* @param {{session:object, cwd:string}} p
|
|
61
|
+
*/
|
|
62
|
+
function restore(id, mode, { session, cwd }) {
|
|
63
|
+
const pos = list.findIndex((c) => c.id === id);
|
|
64
|
+
if (pos < 0) return { ok: false, error: `检查点 ${id} 不存在(/rewind 查看可用检查点)` };
|
|
65
|
+
const cp = list[pos];
|
|
66
|
+
const summary = { ok: true, id, mode, conversation: false, filesRestored: [], filesDeleted: [], filesFailed: [], filesSkipped: [] };
|
|
67
|
+
|
|
68
|
+
if (mode === 'code' || mode === 'both') {
|
|
69
|
+
for (const [relPath, e] of changedFilesSince(pos)) {
|
|
70
|
+
if (e.tooLarge) { summary.filesSkipped.push(relPath); continue; }
|
|
71
|
+
const abs = path.resolve(cwd, relPath);
|
|
72
|
+
try {
|
|
73
|
+
if (!e.existed) {
|
|
74
|
+
fs.rmSync(abs, { force: true });
|
|
75
|
+
summary.filesDeleted.push(relPath);
|
|
76
|
+
} else {
|
|
77
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
78
|
+
fs.writeFileSync(abs, e.before, 'utf8');
|
|
79
|
+
summary.filesRestored.push(relPath);
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
summary.filesFailed.push(`${relPath}:${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (mode === 'chat' || mode === 'both') {
|
|
88
|
+
if (cp.messagesLen <= session.messages.length) session.messages.length = cp.messagesLen;
|
|
89
|
+
session.compaction = safeClone(cp.compaction);
|
|
90
|
+
session.lastPromptTokens = 0;
|
|
91
|
+
summary.conversation = true;
|
|
92
|
+
// 对话被截断 → 该点及之后的检查点失效
|
|
93
|
+
list.length = pos;
|
|
94
|
+
}
|
|
95
|
+
return summary;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { list, snapshot, restore, changedFilesSince };
|
|
99
|
+
}
|
package/src/clipboard.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 跨平台剪贴板写入(/copy):macOS pbcopy、Windows clip、Linux wl-copy/xclip。
|
|
2
|
+
// 零依赖,best-effort:命令缺失或失败时返回 {ok:false},由调用方回退(如直接打印内容)。
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
/** 选取本平台的剪贴板写入命令。 */
|
|
7
|
+
function clipboardCmd() {
|
|
8
|
+
if (process.platform === 'darwin') return { cmd: 'pbcopy', args: [] };
|
|
9
|
+
if (process.platform === 'win32') return { cmd: 'clip', args: [] };
|
|
10
|
+
if (process.env.WAYLAND_DISPLAY) return { cmd: 'wl-copy', args: [] };
|
|
11
|
+
return { cmd: 'xclip', args: ['-selection', 'clipboard'] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 把文本写入系统剪贴板。返回 {ok, tool}。 */
|
|
15
|
+
export function copyToClipboard(text) {
|
|
16
|
+
const { cmd, args } = clipboardCmd();
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let child;
|
|
19
|
+
try {
|
|
20
|
+
child = spawn(cmd, args);
|
|
21
|
+
} catch {
|
|
22
|
+
resolve({ ok: false, tool: cmd });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
child.on('error', () => resolve({ ok: false, tool: cmd }));
|
|
26
|
+
child.on('close', (code) => resolve({ ok: code === 0, tool: cmd }));
|
|
27
|
+
child.stdin.on('error', () => {}); // 命令不存在时 stdin 写入会报错,已由 error/close 处理
|
|
28
|
+
try {
|
|
29
|
+
child.stdin.end(String(text));
|
|
30
|
+
} catch {
|
|
31
|
+
resolve({ ok: false, tool: cmd });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
package/src/codeindex.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// 代码检索引擎(search_code 工具 + 项目地图):把仓库按行窗口分块,建 BM25 排序索引,
|
|
2
|
+
// 按「概念/多词查询」检索最相关的代码块——比 grep(纯词法逐行)更懂「相关性」,且零依赖。
|
|
3
|
+
// 可选:若平台提供 /v1/embeddings,则对候选块做向量重排,逼近真语义(api.embed 失败自动退化为纯 BM25)。
|
|
4
|
+
// 索引按 cwd 缓存于内存,过期或首次使用时惰性重建。
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { embed } from './api.js';
|
|
9
|
+
|
|
10
|
+
export const IGNORE = new Set([
|
|
11
|
+
'.git', 'node_modules', 'dist', 'build', 'target', 'out', '.next', '.nuxt',
|
|
12
|
+
'.venv', 'venv', '__pycache__', '.idea', '.gradle', '.mvn', 'coverage', '.cache',
|
|
13
|
+
]);
|
|
14
|
+
const CODE_EXT = new Set([
|
|
15
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.java', '.kt', '.py', '.go', '.rs',
|
|
16
|
+
'.rb', '.php', '.c', '.h', '.cpp', '.hpp', '.cs', '.swift', '.scala', '.sql',
|
|
17
|
+
'.vue', '.svelte', '.sh', '.css', '.scss', '.html', '.md', '.yaml', '.yml', '.json', '.toml',
|
|
18
|
+
]);
|
|
19
|
+
const WINDOW = 50; // 每块行数
|
|
20
|
+
const STEP = 40; // 步长(10 行重叠,避免边界切断)
|
|
21
|
+
const MAX_CHUNKS = 6000;
|
|
22
|
+
export const MAX_FILE_BYTES = 512 * 1024;
|
|
23
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
const cache = new Map(); // cwd -> { index, builtAt }
|
|
26
|
+
|
|
27
|
+
/** 代码分词:驼峰拆分 + 非字母数字切分 + 小写(拉丁);CJK 出单字(召回)+相邻二元(精度)。
|
|
28
|
+
* 索引与查询共用同一分词,故中文注释/中文查询也能命中(代码常是中文注释 + 英文标识符混排)。 */
|
|
29
|
+
export function tokenize(s) {
|
|
30
|
+
const str = String(s);
|
|
31
|
+
const tokens = str
|
|
32
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.split(/[^a-z0-9_]+/)
|
|
35
|
+
.filter((t) => t.length >= 2 && t.length <= 40);
|
|
36
|
+
const singles = str.match(/[一-鿿]/g);
|
|
37
|
+
if (singles) for (const ch of singles) tokens.push(ch);
|
|
38
|
+
const runs = str.match(/[一-鿿]{2,}/g);
|
|
39
|
+
if (runs) for (const run of runs) for (let i = 0; i < run.length - 1; i++) tokens.push(run.slice(i, i + 2));
|
|
40
|
+
return tokens;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function* walk(dir, depth = 0, maxDepth = 14) {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const e of entries) {
|
|
51
|
+
if (IGNORE.has(e.name) || e.name.startsWith('.')) continue;
|
|
52
|
+
const full = path.join(dir, e.name);
|
|
53
|
+
if (e.isDirectory()) {
|
|
54
|
+
if (depth < maxDepth) yield* walk(full, depth + 1, maxDepth);
|
|
55
|
+
} else if (e.isFile() && CODE_EXT.has(path.extname(e.name).toLowerCase())) {
|
|
56
|
+
yield full;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 构建 BM25 索引(惰性、可缓存)。 */
|
|
62
|
+
export function buildIndex(cwd) {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
const df = new Map();
|
|
65
|
+
let totalLen = 0;
|
|
66
|
+
outer: for (const abs of walk(cwd)) {
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
const st = fs.statSync(abs);
|
|
70
|
+
if (st.size > MAX_FILE_BYTES) continue;
|
|
71
|
+
const buf = fs.readFileSync(abs);
|
|
72
|
+
if (buf.includes(0)) continue; // 二进制
|
|
73
|
+
text = buf.toString('utf8');
|
|
74
|
+
} catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const rel = path.relative(cwd, abs).split(path.sep).join('/');
|
|
78
|
+
const lines = text.split('\n');
|
|
79
|
+
for (let start = 0; start < lines.length; start += STEP) {
|
|
80
|
+
const end = Math.min(lines.length, start + WINDOW);
|
|
81
|
+
const slice = lines.slice(start, end);
|
|
82
|
+
const tokens = tokenize(slice.join('\n'));
|
|
83
|
+
if (!tokens.length) continue;
|
|
84
|
+
const tf = new Map();
|
|
85
|
+
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
86
|
+
for (const t of tf.keys()) df.set(t, (df.get(t) || 0) + 1);
|
|
87
|
+
chunks.push({ file: rel, start: start + 1, end, tf, len: tokens.length, text: slice.join('\n') });
|
|
88
|
+
totalLen += tokens.length;
|
|
89
|
+
if (chunks.length >= MAX_CHUNKS) break outer;
|
|
90
|
+
if (end >= lines.length) break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { chunks, df, N: chunks.length, avgLen: chunks.length ? totalLen / chunks.length : 1 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 取(缓存的)索引。 */
|
|
97
|
+
export function getIndex(cwd) {
|
|
98
|
+
const hit = cache.get(cwd);
|
|
99
|
+
if (hit && Date.now() - hit.builtAt < CACHE_TTL) return hit.index;
|
|
100
|
+
const index = buildIndex(cwd);
|
|
101
|
+
cache.set(cwd, { index, builtAt: Date.now() });
|
|
102
|
+
return index;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 使某目录索引失效(写文件后可调用;此处保守,仅供扩展)。 */
|
|
106
|
+
export function invalidate(cwd) {
|
|
107
|
+
cache.delete(cwd);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** BM25 检索,返回前 k 个相关代码块。 */
|
|
111
|
+
export function search(index, query, k = 8) {
|
|
112
|
+
const qtokens = [...new Set(tokenize(query))];
|
|
113
|
+
if (!qtokens.length || !index.N) return [];
|
|
114
|
+
const { chunks, df, N, avgLen } = index;
|
|
115
|
+
const k1 = 1.5;
|
|
116
|
+
const b = 0.75;
|
|
117
|
+
const scored = [];
|
|
118
|
+
for (const c of chunks) {
|
|
119
|
+
let score = 0;
|
|
120
|
+
for (const qt of qtokens) {
|
|
121
|
+
const f = c.tf.get(qt);
|
|
122
|
+
if (!f) continue;
|
|
123
|
+
const n = df.get(qt) || 1;
|
|
124
|
+
const idf = Math.log(1 + (N - n + 0.5) / (n + 0.5));
|
|
125
|
+
score += idf * ((f * (k1 + 1)) / (f + k1 * (1 - b + b * (c.len / avgLen))));
|
|
126
|
+
}
|
|
127
|
+
if (score > 0) scored.push({ file: c.file, start: c.start, end: c.end, text: c.text, score });
|
|
128
|
+
}
|
|
129
|
+
scored.sort((a, b2) => b2.score - a.score);
|
|
130
|
+
return scored.slice(0, k);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 余弦相似度。 */
|
|
134
|
+
export function cosine(a, b) {
|
|
135
|
+
if (!a || !b) return 0;
|
|
136
|
+
let dot = 0;
|
|
137
|
+
let na = 0;
|
|
138
|
+
let nb = 0;
|
|
139
|
+
const n = Math.min(a.length, b.length);
|
|
140
|
+
for (let i = 0; i < n; i++) {
|
|
141
|
+
dot += a[i] * b[i];
|
|
142
|
+
na += a[i] * a[i];
|
|
143
|
+
nb += b[i] * b[i];
|
|
144
|
+
}
|
|
145
|
+
return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* search_code 工具执行器:BM25 召回 + (配置了嵌入模型时)向量语义重排,格式化为「文件:行范围 + 代码」。
|
|
150
|
+
* 配了 config.embeddingModel 才走 /v1/embeddings 做重排(真语义);未配或向量化失败则纯 BM25(离线可用)。
|
|
151
|
+
*/
|
|
152
|
+
export async function searchCode(cwd, args, config) {
|
|
153
|
+
const query = String(args?.query || '').trim();
|
|
154
|
+
if (!query) return '缺少 query(要检索的概念/功能描述)';
|
|
155
|
+
const k = Number.isFinite(args?.k) && args.k > 0 ? Math.min(args.k, 20) : 8;
|
|
156
|
+
const index = getIndex(cwd);
|
|
157
|
+
if (!index.N) return '(当前目录未索引到代码文件)';
|
|
158
|
+
const rerank = !!(config && config.embeddingModel && config.baseUrl && config.apiKey);
|
|
159
|
+
const recall = rerank ? Math.min(30, Math.max(k * 3, 15)) : k; // 重排时多召回候选
|
|
160
|
+
let hits = search(index, query, recall);
|
|
161
|
+
if (!hits.length) return `(未找到与「${query}」明显相关的代码;可换关键词,或用 grep 精确匹配)`;
|
|
162
|
+
let reranked = false;
|
|
163
|
+
if (rerank && hits.length > 1) {
|
|
164
|
+
try {
|
|
165
|
+
const texts = hits.map((h) => h.text.slice(0, 2000));
|
|
166
|
+
const vecs = await embed(config.baseUrl, config.apiKey, config.embeddingModel, [query, ...texts]);
|
|
167
|
+
if (vecs && vecs.length === texts.length + 1 && vecs[0]) {
|
|
168
|
+
const qv = vecs[0];
|
|
169
|
+
hits = hits.map((h, i) => ({ ...h, score: cosine(qv, vecs[i + 1]) })).sort((a, b) => b.score - a.score);
|
|
170
|
+
reranked = true;
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
/* 向量化失败 → 退化为 BM25 排序,不影响可用性 */
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const head = reranked ? '(向量语义重排)\n' : '';
|
|
177
|
+
return head + hits.slice(0, k).map((h) => {
|
|
178
|
+
const body = h.text.length > 1200 ? h.text.slice(0, 1200) + '\n…(本块过长已截断)' : h.text;
|
|
179
|
+
return `${h.file}:${h.start}-${h.end}\n${body}`;
|
|
180
|
+
}).join('\n\n─────\n\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 提取一段代码里的关键符号名(函数/类/def/导出/顶层箭头常量),供项目地图用。 */
|
|
184
|
+
function extractSymbols(text) {
|
|
185
|
+
const syms = new Set();
|
|
186
|
+
const res = [
|
|
187
|
+
/(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_]\w*)/g,
|
|
188
|
+
/\bclass\s+([A-Za-z_]\w*)/g,
|
|
189
|
+
/\bdef\s+([A-Za-z_]\w*)/g,
|
|
190
|
+
/\b(?:export\s+)?const\s+([A-Za-z_]\w*)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>)/g,
|
|
191
|
+
/@(?:Get|Post|Put|Delete|Request)Mapping\(\s*"([^"]+)"/g,
|
|
192
|
+
/\bpublic\s+[\w<>[\]]+\s+([A-Za-z_]\w*)\s*\(/g,
|
|
193
|
+
];
|
|
194
|
+
for (const re of res) {
|
|
195
|
+
let m;
|
|
196
|
+
while ((m = re.exec(text)) && syms.size < 12) syms.add(m[1]);
|
|
197
|
+
}
|
|
198
|
+
return [...syms];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 项目地图:关键源文件 + 其主要符号的紧凑概览(注入系统提示词,帮智能体快速建立全局观)。
|
|
203
|
+
* 按文件大小近似「重要性」取 top N,控制总字符数。
|
|
204
|
+
*/
|
|
205
|
+
export function buildRepoMap(cwd, { maxFiles = 40, maxChars = 2500 } = {}) {
|
|
206
|
+
const files = [];
|
|
207
|
+
for (const abs of walk(cwd)) {
|
|
208
|
+
try {
|
|
209
|
+
const st = fs.statSync(abs);
|
|
210
|
+
if (st.size > MAX_FILE_BYTES) continue;
|
|
211
|
+
files.push({ abs, size: st.size, rel: path.relative(cwd, abs).split(path.sep).join('/') });
|
|
212
|
+
} catch { /* skip */ }
|
|
213
|
+
}
|
|
214
|
+
if (!files.length) return '';
|
|
215
|
+
files.sort((a, b) => b.size - a.size);
|
|
216
|
+
const lines = [];
|
|
217
|
+
let used = 0;
|
|
218
|
+
for (const f of files.slice(0, maxFiles)) {
|
|
219
|
+
let syms = [];
|
|
220
|
+
try {
|
|
221
|
+
syms = extractSymbols(fs.readFileSync(f.abs, 'utf8').slice(0, 40000));
|
|
222
|
+
} catch { /* skip */ }
|
|
223
|
+
const line = `- ${f.rel}${syms.length ? ':' + syms.slice(0, 8).join(', ') : ''}`;
|
|
224
|
+
if (used + line.length > maxChars) break;
|
|
225
|
+
lines.push(line);
|
|
226
|
+
used += line.length + 1;
|
|
227
|
+
}
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
package/src/colors.js
CHANGED
|
@@ -1,8 +1,30 @@
|
|
|
1
1
|
// 极简 ANSI 颜色工具(零依赖)。尊重 NO_COLOR 与非 TTY(管道/重定向时自动关闭颜色)。
|
|
2
|
+
// 运行时可切换:setColorMode('mono') 强制无色(/theme);setAccent(name) 设强调色(/color)。
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
+
const baseEnabled = process.env.NO_COLOR == null && process.env.TERM !== 'dumb' && process.stdout.isTTY;
|
|
5
|
+
let mono = false; // /theme mono:强制关闭颜色
|
|
6
|
+
let accentName = 'cyan'; // /color:强调色(提示符/高亮)
|
|
4
7
|
|
|
5
|
-
const
|
|
8
|
+
const on = () => baseEnabled && !mono;
|
|
9
|
+
const wrap = (code) => (s) => (on() ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
10
|
+
|
|
11
|
+
export const ACCENTS = ['cyan', 'green', 'magenta', 'blue', 'yellow', 'red'];
|
|
12
|
+
|
|
13
|
+
/** 切换主题:'mono' 关闭颜色,其它值恢复(按 env/TTY 判定)。 */
|
|
14
|
+
export function setColorMode(mode) {
|
|
15
|
+
mono = mode === 'mono';
|
|
16
|
+
}
|
|
17
|
+
/** 设置强调色(cyan/green/magenta/blue/yellow/red);非法值忽略。返回是否生效。 */
|
|
18
|
+
export function setAccent(name) {
|
|
19
|
+
if (ACCENTS.includes(name)) {
|
|
20
|
+
accentName = name;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
export function getAccent() {
|
|
26
|
+
return accentName;
|
|
27
|
+
}
|
|
6
28
|
|
|
7
29
|
export const c = {
|
|
8
30
|
reset: '\x1b[0m',
|
|
@@ -17,6 +39,9 @@ export const c = {
|
|
|
17
39
|
magenta: wrap('35'),
|
|
18
40
|
cyan: wrap('36'),
|
|
19
41
|
gray: wrap('90'),
|
|
42
|
+
/** 强调色(随 /color 变化)。 */
|
|
43
|
+
accent: (s) => (c[accentName] ? c[accentName](s) : String(s)),
|
|
20
44
|
};
|
|
21
45
|
|
|
22
|
-
|
|
46
|
+
// 兼容旧引用:静态基线(不随运行时主题变化;如需动态请用 c.* / on())
|
|
47
|
+
export const colorEnabled = baseEnabled;
|