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