@huicai/cli 2026.710.97950 → 2026.711.24616

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 (50) hide show
  1. package/LICENSE +43 -21
  2. package/bin/huicai.js +93 -2
  3. package/package.json +4 -3
  4. package/src/advisor.js +1 -0
  5. package/src/agent.js +1 -385
  6. package/src/api.js +1 -326
  7. package/src/autofix.js +1 -0
  8. package/src/banner.js +1 -83
  9. package/src/checkpoints.js +1 -0
  10. package/src/clipboard.js +1 -0
  11. package/src/codeindex.js +1 -0
  12. package/src/colors.js +1 -22
  13. package/src/commands.js +1 -339
  14. package/src/compaction.js +1 -140
  15. package/src/config.js +1 -66
  16. package/src/consult.js +1 -65
  17. package/src/context-report.js +1 -0
  18. package/src/context.js +1 -144
  19. package/src/cron.js +1 -0
  20. package/src/daemon.js +1 -0
  21. package/src/device.js +1 -88
  22. package/src/diffview.js +1 -0
  23. package/src/doctor.js +1 -0
  24. package/src/executors.js +1 -391
  25. package/src/forks.js +1 -0
  26. package/src/gitflow.js +1 -0
  27. package/src/hooks.js +1 -0
  28. package/src/ide.js +1 -53
  29. package/src/input.js +1 -182
  30. package/src/insights.js +1 -0
  31. package/src/kbsync.js +1 -0
  32. package/src/markdown.js +0 -0
  33. package/src/markup-filter.js +1 -85
  34. package/src/mcp.js +1 -207
  35. package/src/memory.js +1 -110
  36. package/src/permissions.js +1 -82
  37. package/src/render.js +1 -134
  38. package/src/repl.js +1 -205
  39. package/src/scan.js +1 -0
  40. package/src/schedule.js +1 -0
  41. package/src/session.js +1 -140
  42. package/src/settings.js +0 -0
  43. package/src/skills.js +1 -248
  44. package/src/subagent.js +1 -82
  45. package/src/todo.js +1 -75
  46. package/src/tools.js +1 -132
  47. package/src/transcript.js +1 -0
  48. package/src/tui/app.js +1 -566
  49. package/src/usage.js +1 -43
  50. package/src/watch.js +1 -0
package/src/tui/app.js CHANGED
@@ -1,566 +1 @@
1
- // ink + React 富终端界面(TUI)。用 React.createElement 编写(无 JSX,故仍无需构建步骤)。
2
- // 通过 runTurn 的结构化 events(onDelta/onToolStart/onTodo/…)驱动渲染:Static 承载已定稿的对话记录,
3
- // 下方动态区呈现流式正文、任务清单、思考 spinner、授权提示与可编辑输入行、底部状态栏。
4
- //
5
- // 交互细节:
6
- // - 输入行支持光标移动/行内编辑(←→、Ctrl+A/E/U/K/W)与上下键历史(按项目目录持久化);
7
- // - 模型工作中可继续输入:回车即排队,工具轮间隙自动注入(「边跑边补充指令」);Esc 中断当前回合;
8
- // - 授权提示含高风险命令警示,[A] 可把允许规则写入项目 .huicai/settings.json;
9
- // - 斜杠命令与 REPL 共用 commands.js 注册表;@文件 引用与 !命令 直通同样可用。
10
- //
11
- // 仅在 TTY 且已安装 ink 时启用;未安装时 bin 回退到零依赖 readline REPL。
12
-
13
- import React, { useState, useRef, useReducer, useEffect, useMemo } from 'react';
14
- import { render, Box, Text, Static, useInput, useApp } from 'ink';
15
- import { runTurn } from '../agent.js';
16
- import { fetchUsage } from '../api.js';
17
- import { toolLabel } from '../render.js';
18
- import { renderMarkdown, looksLikeMarkdown } from '../markdown.js';
19
- import { renderWelcome } from '../banner.js';
20
- import { createSession, saveSession } from '../session.js';
21
- import { buildExtraTools } from '../repl.js';
22
- import { closeMcp } from '../mcp.js';
23
- import { runSlash, listCommands } from '../commands.js';
24
- import { usageStatus, fmtReset } from '../usage.js';
25
- import { expandAtMentions, buildUserContent, runBangCommand, loadHistory, appendHistory } from '../input.js';
26
- import { loadAllowRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from '../settings.js';
27
-
28
- const h = React.createElement;
29
-
30
- function shortLine(s, max = 200) {
31
- const first = String(s).split('\n')[0];
32
- return first.length > max ? first.slice(0, max) + '…' : first;
33
- }
34
-
35
- function clipLines(s, maxLines = 12) {
36
- const lines = String(s).split('\n');
37
- if (lines.length <= maxLines) return s;
38
- return lines.slice(0, maxLines).join('\n') + `\n…(共 ${lines.length} 行)`;
39
- }
40
-
41
- function fmtTok(n) {
42
- n = Number(n) || 0;
43
- return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
44
- }
45
-
46
- const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
47
-
48
- export function App({ config, cwd, session: initialSession, extraTools, yolo, initialPrompt, version, onExit }) {
49
- const app = useApp();
50
- const logRef = useRef([]); // 已定稿记录:{id,kind,text}
51
- const idRef = useRef(0);
52
- const liveRef = useRef(''); // 当前流式正文
53
- const sessionRef = useRef(initialSession);
54
- const abortRef = useRef(null);
55
- const pendingRef = useRef(null); // 授权中:{name,args,resolve}
56
- const sessionAllow = useRef(new Set());
57
- const rulesRef = useRef(loadAllowRules(cwd)); // settings.json 允许规则
58
- const yoloRef = useRef(!!yolo);
59
- const startedRef = useRef(false);
60
- const statusRef = useRef('idle');
61
- const queueRef = useRef([]); // 忙时排队的用户消息
62
- const todoStateRef = useRef({ list: [] });
63
- const historyRef = useRef(loadHistory(cwd));
64
- const histIdxRef = useRef(null);
65
- const draftRef = useRef('');
66
-
67
- const [, force] = useReducer((x) => x + 1, 0);
68
- // 定稿记录(logRef)变更计数:ink <Static> 靠 items「引用变化」识别新项,就地 push 同一数组它不会刷新
69
- // (会导致轮末助手回复无后续渲染触发而丢失)。故每次 addLog 自增 logVer,据此派生「全新数组引用」交给 Static。
70
- const [logVer, bumpLog] = useReducer((x) => x + 1, 0);
71
- const [staticKey, setStaticKey] = useState(0); // /clear 时自增以重挂 Static,复位其内部已渲染索引
72
- const [status, setStatus] = useState('idle'); // idle | busy
73
- const [inp, setInp] = useState({ text: '', cursor: 0 });
74
- const [pending, setPending] = useState(null);
75
- const [usage, setUsage] = useState({ prompt: 0, completion: 0 });
76
- const [ctxTok, setCtxTok] = useState(0);
77
- const [todos, setTodos] = useState([]);
78
- const [queueLen, setQueueLen] = useState(0);
79
- const [spin, setSpin] = useState(0);
80
- const [win, setWin] = useState(null); // Token 用量窗口(5小时/7天)最新快照
81
- const winRef = useRef(null); // 同上,供异步路径同步读取(发起回合前的用尽拦截)
82
- const [sugIdx, setSugIdx] = useState(0); // 斜杠命令补全:选中项
83
- const [hideSug, setHideSug] = useState(false); // 斜杠命令补全:Esc 临时收起
84
- const allCommands = useMemo(() => listCommands(cwd), [cwd]); // 命令清单(含自定义),一次性
85
-
86
- const addLog = (kind, text) => {
87
- logRef.current.push({ id: idRef.current++, kind, text });
88
- bumpLog(); // 变更定稿记录 → 触发 Static 用新数组引用刷新(见 logVer 说明)
89
- };
90
- const flushLive = () => {
91
- if (liveRef.current) {
92
- // 定稿时把助手正文渲染为 Markdown(ink <Text> 原样保留内嵌 ANSI);流式期间仍显示原文,完成即定稿为富文本。
93
- // 纯散文(无 Markdown 结构)直接留原文,避免无谓处理与边角改动。
94
- const raw = liveRef.current;
95
- const md = config.markdown !== false && looksLikeMarkdown(raw);
96
- addLog('assistant', md ? renderMarkdown(raw) : raw);
97
- liveRef.current = '';
98
- }
99
- };
100
- const setBusy = (b) => {
101
- statusRef.current = b ? 'busy' : 'idle';
102
- setStatus(statusRef.current);
103
- };
104
-
105
- // 思考中 spinner
106
- useEffect(() => {
107
- if (status !== 'busy') return;
108
- const t = setInterval(() => setSpin((s) => (s + 1) % SPINNER.length), 80);
109
- return () => clearInterval(t);
110
- }, [status]);
111
-
112
- // Token 用量窗口:进入时拉一次,之后每回合结束 + 定时刷新(best-effort,离线/平台不支持则静默)
113
- const refreshUsage = async () => {
114
- if (!config.apiKey) return;
115
- try {
116
- const u = await fetchUsage(config.baseUrl, config.apiKey);
117
- winRef.current = u;
118
- setWin(u);
119
- } catch { /* 忽略 */ }
120
- };
121
- useEffect(() => {
122
- void refreshUsage();
123
- const t = setInterval(() => { void refreshUsage(); }, 180000);
124
- return () => clearInterval(t);
125
- // eslint-disable-next-line react-hooks/exhaustive-deps
126
- }, []);
127
-
128
- const thinkRef = useRef(''); // 思考过程(reasoning)流式缓冲,仅动态区展示不入定稿记录
129
- const events = {
130
- onDelta: (t) => {
131
- if (thinkRef.current) thinkRef.current = ''; // 正文开始,思考收起
132
- liveRef.current += t;
133
- force();
134
- },
135
- onReasoning: (t) => {
136
- thinkRef.current = (thinkRef.current + t).slice(-400);
137
- force();
138
- },
139
- onToolStart: (name, args) => {
140
- thinkRef.current = '';
141
- flushLive();
142
- addLog('tool', toolLabel(name, args));
143
- },
144
- onToolResult: (name, res) => addLog('toolresult', shortLine(res)),
145
- onFileDiff: (diff) => addLog('diff', diff), // 文件更改的行(已带 ANSI,Static 原样呈现)
146
- onSkip: (name, args) => addLog('skip', toolLabel(name, args) + ' — 已跳过'),
147
- onInfo: (t) => {
148
- flushLive();
149
- addLog('info', t);
150
- },
151
- onTodo: (list) => setTodos(list),
152
- onTurnEnd: () => {
153
- thinkRef.current = '';
154
- flushLive();
155
- },
156
- };
157
-
158
- // ink 版权限:只读/yolo/本会话/settings 规则放行;否则弹提示等待按键(y/a/A/n)
159
- const permissions = {
160
- request: (name, args, _cwd, readOnly) => {
161
- if (readOnly || yoloRef.current || sessionAllow.current.has(name)
162
- || matchAllowRules(rulesRef.current, name, args)) {
163
- return Promise.resolve({ allow: true });
164
- }
165
- return new Promise((resolve) => {
166
- pendingRef.current = { name, args, resolve };
167
- setPending({ label: toolLabel(name, args), name, args, risk: riskWarning(name, args) });
168
- });
169
- },
170
- isYolo: () => yoloRef.current,
171
- setYolo: (v) => {
172
- yoloRef.current = !!v;
173
- },
174
- };
175
-
176
- const pullQueued = () => {
177
- // runTurn 同步回调:排队消息按原文注入(@ 文档/图片的异步展开只在主输入路径做,队列消息保留 @ 路径原文,模型可再 read_file)
178
- const q = queueRef.current.shift();
179
- setQueueLen(queueRef.current.length);
180
- return q == null ? null : q;
181
- };
182
-
183
- const runUserTurn = async (text) => {
184
- // 用量窗口已用尽 → 终止:不发起新回合(进行中的回合由平台 429 终态自然结束)
185
- const gate = usageStatus(winRef.current);
186
- if (gate && gate.level === 'exhausted') {
187
- addLog('info', `✗ Token 用量窗口(${gate.label})已用尽,暂无法继续。${fmtReset(gate.resetAtEpochMs)}`);
188
- return;
189
- }
190
- const { text: expanded, images } = await expandAtMentions(text, cwd, config);
191
- sessionRef.current.messages.push({ role: 'user', content: buildUserContent(expanded, images) });
192
- setBusy(true);
193
- abortRef.current = new AbortController();
194
- if (!sessionRef.current.compaction) sessionRef.current.compaction = {};
195
- const r = await runTurn({
196
- config,
197
- messages: sessionRef.current.messages,
198
- permissions,
199
- cwd,
200
- extraTools,
201
- events,
202
- signal: abortRef.current.signal,
203
- todoState: todoStateRef.current,
204
- compaction: sessionRef.current.compaction,
205
- pullQueued,
206
- });
207
- abortRef.current = null;
208
- flushLive();
209
- if (r?.usage) setUsage((u) => ({ prompt: u.prompt + r.usage.prompt, completion: u.completion + r.usage.completion }));
210
- if (r?.lastPromptTokens) {
211
- setCtxTok(r.lastPromptTokens);
212
- sessionRef.current.lastPromptTokens = r.lastPromptTokens;
213
- }
214
- saveSession(sessionRef.current);
215
- if ((r?.aborted || r?.error) && queueRef.current.length) {
216
- queueRef.current = [];
217
- setQueueLen(0);
218
- addLog('info', '已清空排队消息');
219
- }
220
- setBusy(false);
221
- void refreshUsage(); // 回合结束刷新用量窗口(含被 429 用尽的情形 → 更新右下角提示 + 拦截下一回合)
222
- };
223
-
224
- const doExit = () => {
225
- onExit?.();
226
- app.exit();
227
- };
228
-
229
- const handleSlash = async (line) => {
230
- const ctx = {
231
- version,
232
- config,
233
- cwd,
234
- usage,
235
- permissions,
236
- extraTools,
237
- session: sessionRef.current,
238
- print: (t) => addLog('info', String(t)),
239
- };
240
- const r = await runSlash(line, ctx);
241
- if (r.action === 'exit') return doExit();
242
- if (r.action === 'clear') {
243
- sessionRef.current = createSession({ cwd, model: config.model, messages: [sessionRef.current.messages[0]] });
244
- todoStateRef.current.list = [];
245
- setTodos([]);
246
- setCtxTok(0);
247
- logRef.current = [];
248
- liveRef.current = '';
249
- setStaticKey((k) => k + 1); // 重挂 Static:复位其内部已渲染索引,新会话记录从头正常渲染
250
- addLog('info', '已开启新会话(旧会话仍可 --resume 恢复)');
251
- }
252
- if (r.prompt) {
253
- if (statusRef.current === 'busy') {
254
- queueRef.current.push(r.prompt);
255
- setQueueLen(queueRef.current.length);
256
- } else {
257
- addLog('user', r.prompt);
258
- await runUserTurn(r.prompt);
259
- }
260
- }
261
- };
262
-
263
- const handleBang = async (command) => {
264
- if (statusRef.current === 'busy') {
265
- addLog('info', '正在处理上一条,! 命令请稍候再试');
266
- return;
267
- }
268
- addLog('user', '! ' + command);
269
- const { output, record } = await runBangCommand(command, cwd);
270
- addLog('toolresult', clipLines(output));
271
- sessionRef.current.messages.push({ role: 'user', content: record });
272
- saveSession(sessionRef.current);
273
- };
274
-
275
- const submit = async (raw) => {
276
- const t = raw.trim();
277
- setInp({ text: '', cursor: 0 });
278
- histIdxRef.current = null;
279
- if (!t) return;
280
- appendHistory(cwd, t);
281
- if (historyRef.current[historyRef.current.length - 1] !== t) historyRef.current.push(t);
282
- if (t.startsWith('/')) return handleSlash(t);
283
- if (t.startsWith('!') && t.length > 1) return handleBang(t.slice(1).trim());
284
- if (statusRef.current === 'busy') {
285
- // 边跑边补充:排队,工具轮间隙自动注入本回合
286
- addLog('user', t);
287
- queueRef.current.push(t);
288
- setQueueLen(queueRef.current.length);
289
- return;
290
- }
291
- addLog('user', t);
292
- await runUserTurn(t);
293
- };
294
-
295
- // 首个自动指令
296
- useEffect(() => {
297
- if (!startedRef.current && initialPrompt) {
298
- startedRef.current = true;
299
- void submit(initialPrompt);
300
- }
301
- // eslint-disable-next-line react-hooks/exhaustive-deps
302
- }, []);
303
-
304
- // 历史翻阅
305
- const histUp = () => {
306
- const hist = historyRef.current;
307
- if (!hist.length) return;
308
- if (histIdxRef.current == null) {
309
- draftRef.current = inp.text;
310
- histIdxRef.current = hist.length - 1;
311
- } else if (histIdxRef.current > 0) {
312
- histIdxRef.current--;
313
- }
314
- const t = hist[histIdxRef.current] ?? '';
315
- setInp({ text: t, cursor: t.length });
316
- };
317
- const histDown = () => {
318
- if (histIdxRef.current == null) return;
319
- const hist = historyRef.current;
320
- if (histIdxRef.current < hist.length - 1) {
321
- histIdxRef.current++;
322
- const t = hist[histIdxRef.current];
323
- setInp({ text: t, cursor: t.length });
324
- } else {
325
- histIdxRef.current = null;
326
- setInp({ text: draftRef.current, cursor: draftRef.current.length });
327
- }
328
- };
329
-
330
- // 斜杠命令自动补全:仅在「以 / 开头且尚未输入空格(还在打命令名)」时给候选
331
- const slashMatch = /^\/(\S*)$/.exec(inp.text);
332
- const slashQuery = slashMatch ? slashMatch[1] : null;
333
- const suggestions = slashQuery == null
334
- ? []
335
- : allCommands.filter((cmd) => cmd.name.toLowerCase().startsWith(slashQuery.toLowerCase())).slice(0, 8);
336
- const popupOpen = suggestions.length > 0 && !hideSug;
337
- // 候选变化时复位选中项与「Esc 收起」态(继续打字即重新弹出)
338
- useEffect(() => { setSugIdx(0); setHideSug(false); }, [slashQuery]);
339
-
340
- useInput((ch, key) => {
341
- // 授权提示优先(y 一次 / a 本会话 / A 永久规则 / n·Esc·回车 拒绝)
342
- if (pendingRef.current) {
343
- const p = pendingRef.current;
344
- const done = (res) => {
345
- pendingRef.current = null;
346
- setPending(null);
347
- p.resolve(res);
348
- };
349
- if (ch === 'y') return done({ allow: true });
350
- if (ch === 'a') {
351
- sessionAllow.current.add(p.name);
352
- return done({ allow: true });
353
- }
354
- if (ch === 'A') {
355
- sessionAllow.current.add(p.name);
356
- const rule = suggestRule(p.name, p.args);
357
- try {
358
- addProjectAllowRule(cwd, rule);
359
- rulesRef.current = loadAllowRules(cwd);
360
- addLog('info', `已写入允许规则:${rule} → .huicai/settings.json`);
361
- } catch (e) {
362
- addLog('info', `规则写入失败(本会话仍生效):${e.message}`);
363
- }
364
- return done({ allow: true });
365
- }
366
- if (ch === 'n' || key.escape || key.return) return done({ allow: false, reason: '用户拒绝了该操作。' });
367
- return;
368
- }
369
- // 斜杠命令补全弹层打开时,接管 上下选择 / Tab 补全 / 回车执行 / Esc 收起;其它键落到下方输入以继续过滤
370
- if (popupOpen) {
371
- const pick = () => suggestions[Math.min(sugIdx, suggestions.length - 1)];
372
- if (key.upArrow) return setSugIdx((x) => (x - 1 + suggestions.length) % suggestions.length);
373
- if (key.downArrow) return setSugIdx((x) => (x + 1) % suggestions.length);
374
- if (key.tab) {
375
- const name = pick().name;
376
- return setInp({ text: '/' + name + ' ', cursor: name.length + 2 });
377
- }
378
- if (key.return) return void submit('/' + pick().name);
379
- if (key.escape) return setHideSug(true);
380
- }
381
- if (key.escape) {
382
- if (statusRef.current === 'busy' && abortRef.current) {
383
- abortRef.current.abort();
384
- addLog('info', '已请求中断(Esc)');
385
- }
386
- return;
387
- }
388
- if (key.ctrl && ch === 'c') {
389
- if (statusRef.current === 'busy' && abortRef.current) {
390
- abortRef.current.abort();
391
- addLog('info', '已请求中断');
392
- } else {
393
- doExit();
394
- }
395
- return;
396
- }
397
- if (key.return) {
398
- void submit(inp.text);
399
- return;
400
- }
401
- if (key.upArrow) return histUp();
402
- if (key.downArrow) return histDown();
403
- if (key.leftArrow) return setInp((s) => ({ ...s, cursor: Math.max(0, s.cursor - 1) }));
404
- if (key.rightArrow) return setInp((s) => ({ ...s, cursor: Math.min(s.text.length, s.cursor + 1) }));
405
- if (key.ctrl && ch === 'a') return setInp((s) => ({ ...s, cursor: 0 }));
406
- if (key.ctrl && ch === 'e') return setInp((s) => ({ ...s, cursor: s.text.length }));
407
- if (key.ctrl && ch === 'u') return setInp((s) => ({ text: s.text.slice(s.cursor), cursor: 0 }));
408
- if (key.ctrl && ch === 'k') return setInp((s) => ({ text: s.text.slice(0, s.cursor), cursor: s.cursor }));
409
- if (key.ctrl && ch === 'w') {
410
- return setInp((s) => {
411
- const before = s.text.slice(0, s.cursor).replace(/\S+\s*$/, '');
412
- return { text: before + s.text.slice(s.cursor), cursor: before.length };
413
- });
414
- }
415
- if (key.backspace || key.delete) {
416
- histIdxRef.current = null;
417
- return setInp((s) => (s.cursor > 0
418
- ? { text: s.text.slice(0, s.cursor - 1) + s.text.slice(s.cursor), cursor: s.cursor - 1 }
419
- : s));
420
- }
421
- if (ch && !key.ctrl && !key.meta) {
422
- histIdxRef.current = null;
423
- const str = ch.replace(/\r\n?/g, '\n'); // 粘贴多行归一化
424
- setInp((s) => ({ text: s.text.slice(0, s.cursor) + str + s.text.slice(s.cursor), cursor: s.cursor + str.length }));
425
- }
426
- });
427
-
428
- // ---- 渲染 ----
429
- // 每当定稿记录变更(logVer)派生一个全新数组引用,Static 才能识别并渲染新追加项;
430
- // 流式增量只调 force()(logVer 不变)→ 此处返回同一引用,Static 不重复刷历史记录。
431
- const logItems = useMemo(() => logRef.current.slice(), [logVer]);
432
- const cwdName = cwd.split('/').filter(Boolean).pop() || cwd;
433
- const before = inp.text.slice(0, inp.cursor);
434
- const at = inp.text[inp.cursor] ?? ' ';
435
- const after = inp.text.slice(inp.cursor + 1);
436
- const termWidth = Math.max(8, process.stdout.columns || 80); // 输入栏上下蓝线宽度(随终端自适应)
437
- const uStat = usageStatus(win); // 用量告警(null=正常,不显示)
438
- const uWarn = !uStat ? '' : uStat.level === 'exhausted'
439
- ? `⛔ ${uStat.label}用量已用尽${fmtReset(uStat.resetAtEpochMs) ? ' · ' + fmtReset(uStat.resetAtEpochMs) : ''}`
440
- : `⚠ ${uStat.label}用量 ${Math.round(uStat.pct * 100)}%(${fmtTok(uStat.used)}/${fmtTok(uStat.limit)})`;
441
-
442
- return h(
443
- Box,
444
- { flexDirection: 'column' },
445
- // 已定稿记录(Static:只渲染一次,天然滚动)
446
- h(Static, { key: staticKey, items: logItems }, (item) => h(LogLine, { key: item.id, item })),
447
- // 思考过程(reasoning,流式、不入定稿记录)
448
- thinkRef.current
449
- ? h(Box, null, h(Text, { color: 'gray', italic: true }, '✦ 思考 ' + thinkRef.current.replace(/\n/g, ' ').slice(-160)))
450
- : null,
451
- // 流式正文
452
- liveRef.current ? h(Box, { marginTop: 0 }, h(Text, null, liveRef.current)) : null,
453
- // 任务清单(todo_write 实时更新)
454
- todos.length
455
- ? h(
456
- Box,
457
- { flexDirection: 'column', paddingLeft: 2, marginTop: 1 },
458
- ...todos.map((t, i) =>
459
- h(
460
- Text,
461
- {
462
- key: i,
463
- color: t.status === 'completed' ? 'green' : t.status === 'in_progress' ? 'cyan' : 'gray',
464
- dimColor: t.status === 'completed',
465
- bold: t.status === 'in_progress',
466
- },
467
- (t.status === 'completed' ? '☑ ' : t.status === 'in_progress' ? '◐ ' : '☐ ') + t.content,
468
- ),
469
- ),
470
- )
471
- : null,
472
- // 思考 spinner
473
- status === 'busy' && !liveRef.current
474
- ? h(Box, null, h(Text, { color: 'cyan' }, SPINNER[spin] + ' 思考中…'), h(Text, { color: 'gray' }, '(Esc 中断 · 输入回车可排队补充)'))
475
- : null,
476
- // 授权提示
477
- pending
478
- ? h(
479
- Box,
480
- { flexDirection: 'column', borderStyle: 'round', borderColor: 'yellow', paddingX: 1, marginTop: 1 },
481
- h(Text, { color: 'yellow', bold: true }, '● 需要授权:' + pending.label),
482
- pending.risk ? h(Text, { color: 'red', bold: true }, '⚠ ' + pending.risk) : null,
483
- pending.name === 'run_bash' ? h(Text, { color: 'gray' }, '$ ' + (pending.args.command || '')) : null,
484
- pending.name === 'fetch_url' ? h(Text, { color: 'gray' }, 'GET ' + (pending.args.url || '')) : null,
485
- h(Text, null, '[y] 允许一次 · [a] 本会话 · [A] 永久(写入项目设置) · [n/Esc] 拒绝'),
486
- )
487
- : null,
488
- // 斜杠命令补全弹层(输入 / 即弹;↑↓ 选择 · Tab 补全 · ⏎ 执行 · Esc 关闭)
489
- popupOpen
490
- ? h(
491
- Box,
492
- { flexDirection: 'column', marginTop: 1 },
493
- h(Text, { color: 'gray' }, ' 斜杠命令 · ↑↓ 选择 · Tab 补全 · ⏎ 执行 · Esc 关闭'),
494
- ...suggestions.map((s, i) =>
495
- h(
496
- Box,
497
- { key: s.name },
498
- h(Text, { color: 'cyan', bold: i === sugIdx },
499
- (i === sugIdx ? ' ❯ ' : ' ') + '/' + s.name + (s.args ? ' ' + s.args : '')),
500
- h(Text, { color: 'gray' }, ' ' + String(s.desc || '').replace(/\s+/g, ' ').slice(0, 46)),
501
- ),
502
- ),
503
- )
504
- : null,
505
- // 输入栏:上下各一条蓝色横线包住输入行(弹层打开时其自带间距,蓝线贴紧)
506
- h(Box, { marginTop: popupOpen ? 0 : 1 }, h(Text, { color: 'blue' }, '─'.repeat(termWidth))),
507
- // 输入行(带光标;忙时输入会排队)
508
- h(
509
- Box,
510
- null,
511
- h(Text, { color: status === 'busy' ? 'yellow' : 'cyan' }, status === 'busy' ? '⧗ ' : '› '),
512
- h(Text, null, before),
513
- h(Text, { inverse: true }, at),
514
- h(Text, null, after),
515
- ),
516
- h(Text, { color: 'blue' }, '─'.repeat(termWidth)),
517
- // 底栏:左=目录/模型/队列;右下角=用量告警(仅 ≥80% 或用尽时实时出现)
518
- h(
519
- Box,
520
- { justifyContent: 'space-between' },
521
- h(Text, { color: 'gray' },
522
- `${cwdName} · ${config.model}${yoloRef.current ? ' · ⚠ yolo' : ''}`
523
- + (queueLen ? ` · 排队 ${queueLen}` : '')
524
- + ' · /help'),
525
- uWarn ? h(Text, { color: uStat.level === 'exhausted' ? 'red' : 'yellow', bold: true }, uWarn) : h(Text, null, ''),
526
- ),
527
- );
528
- }
529
-
530
- /** 单行记录渲染(按 kind 着色)。 */
531
- function LogLine({ item }) {
532
- const { kind, text } = item;
533
- if (kind === 'user') return h(Box, { marginTop: 1 }, h(Text, { color: 'cyan', bold: true }, '› '), h(Text, { bold: true }, text));
534
- if (kind === 'assistant') return h(Box, null, h(Text, null, text));
535
- if (kind === 'diff') return h(Box, { marginLeft: 2 }, h(Text, null, text)); // 更改的行(已带 ANSI)
536
- if (kind === 'tool') return h(Text, { color: 'cyan' }, ' ⏺ ' + text);
537
- if (kind === 'toolresult') return h(Text, { color: 'gray' }, ' ' + text);
538
- if (kind === 'skip') return h(Text, { color: 'gray' }, ' ⊘ ' + text);
539
- if (kind === 'info') {
540
- // 失败/错误类信息用红色高亮,避免被淹没(如「模型调用失败:令牌校验失败」)
541
- const isErr = /✗|失败|错误|error/i.test(text);
542
- return h(Text, { color: isErr ? 'red' : 'yellow' }, (isErr ? ' ✗ ' : ' · ') + text.replace(/^✗\s*/, ''));
543
- }
544
- return h(Text, null, text);
545
- }
546
-
547
- /**
548
- * 启动 ink TUI。先(在 ink 接管屏幕前)连接 MCP 并打印连接信息,再进入全屏界面;退出后关闭 MCP。
549
- */
550
- export async function runInk({ config, cwd, session, yolo, initialPrompt, version }) {
551
- process.stdout.write(renderWelcome({
552
- model: config.model,
553
- cwd,
554
- version,
555
- platform: /^(agent|workflow|datahuman)-/.test(String(config.model || '')),
556
- yolo,
557
- consult: config.consult ? (config.consultLabel || config.consult) : null,
558
- ink: true,
559
- }) + '\n');
560
- const { extraTools, clients } = await buildExtraTools(config, cwd);
561
- const { waitUntilExit } = render(
562
- h(App, { config, cwd, session, extraTools, yolo, initialPrompt, version, onExit: () => closeMcp(clients) }),
563
- );
564
- await waitUntilExit();
565
- closeMcp(clients);
566
- }
1
+ import r,{useState as e,useRef as t,useReducer as n,useEffect as o,useMemo as s}from"react";import{execSync as c}from"node:child_process";import{render as l,Box as u,Text as i,Static as a,useInput as m,useApp as p}from"ink";import{runTurn as f}from"../agent.js";import{fetchUsage as g}from"../api.js";import{toolLabel as d}from"../render.js";import{renderMarkdown as h,looksLikeMarkdown as x}from"../markdown.js";import{renderWelcome as y}from"../banner.js";import{createSession as w,saveSession as b}from"../session.js";import{createCheckpoints as v}from"../checkpoints.js";import{buildExtraTools as k}from"../repl.js";import{closeMcp as T}from"../mcp.js";import{runSlash as $,listCommands as j}from"../commands.js";import{usageStatus as S,fmtReset as A}from"../usage.js";import{expandAtMentions as I,buildUserContent as P,runBangCommand as E,loadHistory as M,appendHistory as L}from"../input.js";import{loadAllowRules as _,loadDenyRules as C,matchAllowRules as D,suggestRule as R,addProjectAllowRule as U,riskWarning as W}from"../settings.js";import{PLAN_DENY_REASON as F,SANDBOX_DENY_REASON as K}from"../permissions.js";import{runVerifyLoop as Y}from"../autofix.js";const q=r.createElement;function B(r){return(r=Number(r)||0)>=1e3?(r/1e3).toFixed(1)+"k":String(r)}const G=["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"];export function App({config:r,cwd:l,session:y,extraTools:k,yolo:T,initialPrompt:N,version:O,onExit:Q}){const X=p(),z=t([]),H=t(0),V=t(""),Z=t(y),rr=t(null),er=t(null),tr=t(new Set),nr=t(_(l)),or=t(C(l)),sr=t(!1),cr=t(!1),lr=t(v()),ur=t(!!T),ir=t(!1),ar=t("idle"),mr=t([]),pr=t({list:[]}),fr=t(M(l)),gr=t(null),dr=t(""),[,hr]=n(r=>r+1,0),[xr,yr]=n(r=>r+1,0),[wr,br]=e(0),[vr,kr]=e("idle"),[Tr,$r]=e({text:"",cursor:0}),[jr,Sr]=e(null),[Ar,Ir]=e({prompt:0,completion:0}),[Pr,Er]=e(0),[Mr,Lr]=e([]),[_r,Cr]=e(0),[Dr,Rr]=e(!1),[Ur,Wr]=e(""),Fr=t(null),[Kr,Yr]=e(0),[qr,Br]=e(null),Gr=t(null),[Jr,Nr]=e(0),[Or,Qr]=e(!1),Xr=s(()=>j(l),[l]),zr=(r,e)=>{z.current.push({id:H.current++,kind:r,text:e}),yr()},Hr=()=>{if(V.current){const e=V.current,t=!1!==r.markdown&&x(e);zr("assistant",t?h(e):e),V.current=""}},Vr=r=>{ar.current=r?"busy":"idle",kr(ar.current)};o(()=>{if("busy"!==vr)return;const r=setInterval(()=>Yr(r=>(r+1)%G.length),80);return()=>clearInterval(r)},[vr]);const Zr=async()=>{if(r.apiKey)try{const e=await g(r.baseUrl,r.apiKey);Gr.current=e,Br(e)}catch{}};o(()=>{Zr();const r=setInterval(()=>{Zr()},18e4);return()=>clearInterval(r)},[]),o(()=>{if(!r.statusLine)return void Wr("");const e=()=>{try{const e=c(r.statusLine,{cwd:l,timeout:3e3,stdio:["ignore","pipe","ignore"]}).toString().split("\n")[0].slice(0,80);Wr(e)}catch{Wr("")}};e();const t=setInterval(e,5e3);return()=>clearInterval(t)},[r.statusLine]),o(()=>()=>{Fr.current&&clearInterval(Fr.current)},[]);const re=t(""),ee={onDelta:r=>{re.current&&(re.current=""),V.current+=r,hr()},onReasoning:r=>{re.current=(re.current+r).slice(-400),hr()},onToolStart:(r,e)=>{re.current="",Hr(),zr("tool",d(r,e))},onToolResult:(r,e)=>zr("toolresult",function(r,e=200){const t=String(r).split("\n")[0];return t.length>e?t.slice(0,e)+"…":t}(e)),onFileDiff:r=>zr("diff",r),onSkip:(r,e)=>zr("skip",d(r,e)+" — 已跳过"),onInfo:r=>{Hr(),zr("info",r)},onTodo:r=>Lr(r),onTurnEnd:()=>{re.current="",Hr()}},te={request:(r,e,t,n)=>or.current.length&&D(or.current,r,e)?Promise.resolve({allow:!1,reason:"该操作被拒绝规则阻止(/permissions 查看或移除)。"}):sr.current&&!n?Promise.resolve({allow:!1,reason:F}):!cr.current||"run_bash"!==r&&"fetch_url"!==r?n||ur.current||tr.current.has(r)||D(nr.current,r,e)?Promise.resolve({allow:!0}):new Promise(t=>{er.current={name:r,args:e,resolve:t},Sr({label:d(r,e),name:r,args:e,risk:W(r,e)})}):Promise.resolve({allow:!1,reason:K}),isYolo:()=>ur.current,setYolo:r=>{ur.current=!!r},setPlanMode:r=>{sr.current=!!r,Rr(!!r)},isPlanMode:()=>sr.current,setSandbox:r=>{cr.current=!!r},isSandbox:()=>cr.current,reloadRules:()=>{nr.current=_(l),or.current=C(l)}},ne=()=>{const r=mr.current.shift();return Cr(mr.current.length),null==r?null:r},oe=async e=>{const t=S(Gr.current);if(t&&"exhausted"===t.level)return void zr("info",`✗ Token 用量窗口(${t.label})已用尽,暂无法继续。${A(t.resetAtEpochMs)}`);const{text:n,images:o}=await I(e,l,r),s=lr.current.snapshot({session:Z.current,label:e});Z.current.messages.push({role:"user",content:P(n,o)}),Vr(!0),rr.current=new AbortController,Z.current.compaction||(Z.current.compaction={});const c=await f({config:r,messages:Z.current.messages,permissions:te,cwd:l,extraTools:k,events:ee,signal:rr.current.signal,todoState:pr.current,compaction:Z.current.compaction,pullQueued:ne,fileJournal:s});return rr.current=null,Hr(),c?.usage&&Ir(r=>({prompt:r.prompt+c.usage.prompt,completion:r.completion+c.usage.completion})),c?.lastPromptTokens&&(Er(c.lastPromptTokens),Z.current.lastPromptTokens=c.lastPromptTokens),b(Z.current),(c?.aborted||c?.error)&&mr.current.length&&(mr.current=[],Cr(0),zr("info","已清空排队消息")),Vr(!1),Zr(),c},se=()=>{Q?.(),X.exit()},ce=async r=>{if("busy"===ar.current)return void zr("info","正在处理上一条,! 命令请稍候再试");zr("user","! "+r);const{output:e,record:t}=await E(r,l);zr("toolresult",function(r,e=12){const t=String(r).split("\n");return t.length<=e?r:t.slice(0,e).join("\n")+`\n…(共 ${t.length} 行)`}(e)),Z.current.messages.push({role:"user",content:t}),b(Z.current)},le=async e=>{const t=e.trim();if($r({text:"",cursor:0}),gr.current=null,t){if(L(l,t),fr.current[fr.current.length-1]!==t&&fr.current.push(t),t.startsWith("/"))return(async e=>{const t={version:O,config:r,cwd:l,usage:Ar,permissions:te,extraTools:k,session:Z.current,checkpoints:lr.current,print:r=>zr("info",String(r))},n=await $(e,t);if("exit"===n.action)return se();"clear"===n.action&&(Z.current=w({cwd:l,model:r.model,messages:[Z.current.messages[0]]}),pr.current.list=[],lr.current.list.length=0,Lr([]),Er(0),z.current=[],V.current="",br(r=>r+1),zr("info","已开启新会话(旧会话仍可 --resume 恢复)")),"refresh"===n.action&&(pr.current.list=[],Lr([]),Er(0)),"autofix"===n.action&&await Y({command:n.command,maxIters:n.maxIters,cwd:l,runTurnOnce:r=>oe(r),emit:r=>zr("info",r)}),"cd"===n.action&&zr("info","富 TUI 暂不支持 /cd 切换目录;请用 huicai --plain(普通模式)使用,或退出后到目标目录重开。"),"loop"===n.action&&(Fr.current&&(clearInterval(Fr.current),Fr.current=null),n.stop?zr("info","已停止循环。"):(zr("info",`已开始循环:每 ${n.seconds}s 运行「${n.prompt}」(/loop off 停止)`),Fr.current=setInterval(()=>{"busy"!==ar.current&&(zr("user",n.prompt),oe(n.prompt))},1e3*n.seconds))),n.prompt&&("busy"===ar.current?(mr.current.push(n.prompt),Cr(mr.current.length)):(zr("user",n.prompt),await oe(n.prompt)))})(t);if(t.startsWith("!")&&t.length>1)return ce(t.slice(1).trim());if("busy"===ar.current)return zr("user",t),mr.current.push(t),void Cr(mr.current.length);zr("user",t),await oe(t)}};o(()=>{!ir.current&&N&&(ir.current=!0,le(N))},[]);const ue=/^\/(\S*)$/.exec(Tr.text),ie=ue?ue[1]:null,ae=null==ie?[]:Xr.filter(r=>r.name.toLowerCase().startsWith(ie.toLowerCase())).slice(0,8),me=ae.length>0&&!Or;o(()=>{Nr(0),Qr(!1)},[ie]),m((r,e)=>{if(er.current){const t=er.current,n=r=>{er.current=null,Sr(null),t.resolve(r)};if("y"===r)return n({allow:!0});if("a"===r)return tr.current.add(t.name),n({allow:!0});if("A"===r){tr.current.add(t.name);const r=R(t.name,t.args);try{U(l,r),nr.current=_(l),zr("info",`已写入允许规则:${r} → .huicai/settings.json`)}catch(r){zr("info",`规则写入失败(本会话仍生效):${r.message}`)}return n({allow:!0})}return"n"===r||e.escape||e.return?n({allow:!1,reason:"用户拒绝了该操作。"}):void 0}if(me){const r=()=>ae[Math.min(Jr,ae.length-1)];if(e.upArrow)return Nr(r=>(r-1+ae.length)%ae.length);if(e.downArrow)return Nr(r=>(r+1)%ae.length);if(e.tab){const e=r().name;return $r({text:"/"+e+" ",cursor:e.length+2})}if(e.return)return void le("/"+r().name);if(e.escape)return Qr(!0)}if(e.escape)"busy"===ar.current&&rr.current&&(rr.current.abort(),zr("info","已请求中断(Esc)"));else if(e.ctrl&&"c"===r)"busy"===ar.current&&rr.current?(rr.current.abort(),zr("info","已请求中断")):se();else if(e.return)le(Tr.text);else{if(e.upArrow)return(()=>{const r=fr.current;if(!r.length)return;null==gr.current?(dr.current=Tr.text,gr.current=r.length-1):gr.current>0&&gr.current--;const e=r[gr.current]??"";$r({text:e,cursor:e.length})})();if(e.downArrow)return(()=>{if(null==gr.current)return;const r=fr.current;if(gr.current<r.length-1){gr.current++;const e=r[gr.current];$r({text:e,cursor:e.length})}else gr.current=null,$r({text:dr.current,cursor:dr.current.length})})();if(e.leftArrow)return $r(r=>({...r,cursor:Math.max(0,r.cursor-1)}));if(e.rightArrow)return $r(r=>({...r,cursor:Math.min(r.text.length,r.cursor+1)}));if(e.ctrl&&"a"===r)return $r(r=>({...r,cursor:0}));if(e.ctrl&&"e"===r)return $r(r=>({...r,cursor:r.text.length}));if(e.ctrl&&"u"===r)return $r(r=>({text:r.text.slice(r.cursor),cursor:0}));if(e.ctrl&&"k"===r)return $r(r=>({text:r.text.slice(0,r.cursor),cursor:r.cursor}));if(e.ctrl&&"w"===r)return $r(r=>{const e=r.text.slice(0,r.cursor).replace(/\S+\s*$/,"");return{text:e+r.text.slice(r.cursor),cursor:e.length}});if(e.backspace||e.delete)return gr.current=null,$r(r=>r.cursor>0?{text:r.text.slice(0,r.cursor-1)+r.text.slice(r.cursor),cursor:r.cursor-1}:r);if(r&&!e.ctrl&&!e.meta){gr.current=null;const e=r.replace(/\r\n?/g,"\n");$r(r=>({text:r.text.slice(0,r.cursor)+e+r.text.slice(r.cursor),cursor:r.cursor+e.length}))}}});const pe=s(()=>z.current.slice(),[xr]),fe=l.split("/").filter(Boolean).pop()||l,ge=Tr.text.slice(0,Tr.cursor),de=Tr.text[Tr.cursor]??" ",he=Tr.text.slice(Tr.cursor+1),xe=Math.max(8,process.stdout.columns||80),ye=S(qr),we=ye?"exhausted"===ye.level?`⛔ ${ye.label}用量已用尽${A(ye.resetAtEpochMs)?" · "+A(ye.resetAtEpochMs):""}`:`⚠ ${ye.label}用量 ${Math.round(100*ye.pct)}%(${B(ye.used)}/${B(ye.limit)})`:"";return q(u,{flexDirection:"column"},q(a,{key:wr,items:pe},r=>q(J,{key:r.id,item:r})),re.current?q(u,null,q(i,{color:"gray",italic:!0},"✦ 思考 "+re.current.replace(/\n/g," ").slice(-160))):null,V.current?q(u,{marginTop:0},q(i,null,V.current)):null,Mr.length?q(u,{flexDirection:"column",paddingLeft:2,marginTop:1},...Mr.map((r,e)=>q(i,{key:e,color:"completed"===r.status?"green":"in_progress"===r.status?"cyan":"gray",dimColor:"completed"===r.status,bold:"in_progress"===r.status},("completed"===r.status?"☑ ":"in_progress"===r.status?"◐ ":"☐ ")+r.content))):null,"busy"!==vr||V.current?null:q(u,null,q(i,{color:"cyan"},G[Kr]+" 思考中…"),q(i,{color:"gray"},"(Esc 中断 · 输入回车可排队补充)")),jr?q(u,{flexDirection:"column",borderStyle:"round",borderColor:"yellow",paddingX:1,marginTop:1},q(i,{color:"yellow",bold:!0},"● 需要授权:"+jr.label),jr.risk?q(i,{color:"red",bold:!0},"⚠ "+jr.risk):null,"run_bash"===jr.name?q(i,{color:"gray"},"$ "+(jr.args.command||"")):null,"fetch_url"===jr.name?q(i,{color:"gray"},"GET "+(jr.args.url||"")):null,q(i,null,"[y] 允许一次 · [a] 本会话 · [A] 永久(写入项目设置) · [n/Esc] 拒绝")):null,me?q(u,{flexDirection:"column",marginTop:1},q(i,{color:"gray"}," 斜杠命令 · ↑↓ 选择 · Tab 补全 · ⏎ 执行 · Esc 关闭"),...ae.map((r,e)=>q(u,{key:r.name},q(i,{color:"cyan",bold:e===Jr},(e===Jr?" ❯ ":" ")+"/"+r.name+(r.args?" "+r.args:"")),q(i,{color:"gray"}," "+String(r.desc||"").replace(/\s+/g," ").slice(0,46))))):null,q(u,{marginTop:me?0:1},q(i,{color:"blue"},"─".repeat(xe))),q(u,null,q(i,{color:"busy"===vr?"yellow":"cyan"},"busy"===vr?"⧗ ":"› "),q(i,null,ge),q(i,{inverse:!0},de),q(i,null,he)),q(i,{color:"blue"},"─".repeat(xe)),q(u,{justifyContent:"space-between"},q(i,{color:"gray"},(Ur?Ur+" · ":"")+`${fe} · ${r.model}${ur.current?" · ⚠ yolo":""}${Dr?" · 📋 计划":""}`+(_r?` · 排队 ${_r}`:"")+" · /help"),we?q(i,{color:"exhausted"===ye.level?"red":"yellow",bold:!0},we):q(i,null,"")))}function J({item:r}){const{kind:e,text:t}=r;if("user"===e)return q(u,{marginTop:1},q(i,{color:"cyan",bold:!0},"› "),q(i,{bold:!0},t));if("assistant"===e)return q(u,null,q(i,null,t));if("diff"===e)return q(u,{marginLeft:2},q(i,null,t));if("tool"===e)return q(i,{color:"cyan"}," ⏺ "+t);if("toolresult"===e)return q(i,{color:"gray"}," "+t);if("skip"===e)return q(i,{color:"gray"}," ⊘ "+t);if("info"===e){const r=/✗|失败|错误|error/i.test(t);return q(i,{color:r?"red":"yellow"},(r?" ✗ ":" · ")+t.replace(/^✗\s*/,""))}return q(i,null,t)}export async function runInk({config:r,cwd:e,session:t,yolo:n,initialPrompt:o,version:s}){process.stdout.write(y({model:r.model,cwd:e,version:s,platform:/^(agent|workflow|datahuman)-/.test(String(r.model||"")),yolo:n,consult:r.consult?r.consultLabel||r.consult:null,ink:!0})+"\n");const{extraTools:c,clients:u}=await k(r,e),{waitUntilExit:i}=l(q(App,{config:r,cwd:e,session:t,extraTools:c,yolo:n,initialPrompt:o,version:s,onExit:()=>T(u)}));await i(),T(u)}
package/src/usage.js CHANGED
@@ -1,43 +1 @@
1
- // Token 用量窗口告警判定(纯函数,零依赖)。从 /v1/usage 的快照推导「是否该提示 / 是否用尽」。
2
- // limit 约定:>0 上限;-1 无限;0 该档未开放。两窗(5小时/7天)取占比最高者。
3
-
4
- /**
5
- * @param {{allowed?:boolean, used5h?:number, limit5h?:number, used7d?:number, limit7d?:number, resetAtEpochMs?:number|null}|null} win
6
- * @returns {{level:'warn'|'exhausted', label:string, used:number, limit:number, pct:number, resetAtEpochMs?:number|null}|null}
7
- * null = 正常(<80% 或无可判定窗口)。warn ≥80%;exhausted ≥100% 或平台判定 allowed=false。
8
- */
9
- export function usageStatus(win) {
10
- if (!win) return null;
11
- let worst = null;
12
- for (const w of [
13
- { label: '5小时', used: win.used5h, limit: win.limit5h },
14
- { label: '7天', used: win.used7d, limit: win.limit7d },
15
- ]) {
16
- if (typeof w.limit !== 'number' || w.limit <= 0 || typeof w.used !== 'number') continue; // -1 无限 / 0 未开放
17
- const pct = w.used / w.limit;
18
- if (!worst || pct > worst.pct) worst = { ...w, pct };
19
- }
20
- if (win.allowed === false) {
21
- return {
22
- level: 'exhausted',
23
- label: worst?.label || '窗口',
24
- used: worst?.used,
25
- limit: worst?.limit,
26
- pct: worst ? worst.pct : 1,
27
- resetAtEpochMs: win.resetAtEpochMs,
28
- };
29
- }
30
- if (!worst) return null;
31
- if (worst.pct >= 1) return { level: 'exhausted', ...worst, resetAtEpochMs: win.resetAtEpochMs };
32
- if (worst.pct >= 0.8) return { level: 'warn', ...worst, resetAtEpochMs: win.resetAtEpochMs };
33
- return null;
34
- }
35
-
36
- /** 剩余时长(到 resetAtEpochMs)转「N小时M分后重置」;已过或缺省返回空串。 */
37
- export function fmtReset(ms) {
38
- const d = (ms || 0) - Date.now();
39
- if (d <= 0) return '';
40
- const h = Math.floor(d / 3600000);
41
- const m = Math.round((d % 3600000) / 60000);
42
- return h > 0 ? `${h}小时${m}分后重置` : `${m}分后重置`;
43
- }
1
+ export function usageStatus(e){if(!e)return null;let t=null;for(const l of[{label:"5小时",used:e.used5h,limit:e.limit5h},{label:"7天",used:e.used7d,limit:e.limit7d}]){if("number"!=typeof l.limit||l.limit<=0||"number"!=typeof l.used)continue;const e=l.used/l.limit;(!t||e>t.pct)&&(t={...l,pct:e})}return!1===e.allowed?{level:"exhausted",label:t?.label||"窗口",used:t?.used,limit:t?.limit,pct:t?t.pct:1,resetAtEpochMs:e.resetAtEpochMs}:t?t.pct>=1?{level:"exhausted",...t,resetAtEpochMs:e.resetAtEpochMs}:t.pct>=.8?{level:"warn",...t,resetAtEpochMs:e.resetAtEpochMs}:null:null}export function fmtReset(e){const t=(e||0)-Date.now();if(t<=0)return"";const l=Math.floor(t/36e5),s=Math.round(t%36e5/6e4);return l>0?`${l}小时${s}分后重置`:`${s}分后重置`}
package/src/watch.js ADDED
@@ -0,0 +1 @@
1
+ import{spawn as e}from"node:child_process";import t from"node:fs";import n from"node:path";import{huicaiBin as o}from"./forks.js";const r=new Set([".git","node_modules","dist","build","target","out",".next",".nuxt",".venv","venv","__pycache__",".idea",".gradle","coverage",".cache"]);export async function runWatch({cwd:c,prompt:s,pattern:i=null,debounceMs:a=1500,log:l=e=>process.stdout.write(e+"\n"),spawnImpl:p}){const u=o();if(!u&&!p)throw new Error("无法定位 huicai 可执行文件(watch 需在 CLI 环境运行)");const d=i?function(e){return new RegExp("^"+String(e).replace(/[.+^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*")+"$")}(i):null;let m=null,h=!1;const w=new Set,g=p||(t=>e(process.execPath,[u,"-p",t,"--yolo","--plain"],{cwd:c,stdio:"inherit",env:process.env})),f=()=>{if(h)return;h=!0;const e=[...w];w.clear(),l(`\n▶ 变更 ${e.length} 文件 → 触发智能体:${s}`);const t=s+(e.length?`\n(刚变更的文件:${e.slice(0,20).join(", ")})`:""),n=g(t),o=()=>{h=!1,w.size&&$()};n&&n.on?n.on("close",o):o()},$=()=>{m&&clearTimeout(m),m=setTimeout(f,a)},v=t.watch(c,{recursive:!0},(e,t)=>{if(!t)return;const o=String(t),c=o.split(n.sep)[0];r.has(c)||d&&!d.test(n.basename(o))||(w.add(o),$())});l(`huicai 监听已启动:${c}${i?` (${i})`:""} → 变更即跑「${s}」· Ctrl-C 退出`),await new Promise(e=>{process.on("SIGINT",()=>{v.close(),m&&clearTimeout(m),l("\n监听已停止。"),e()})})}