@huicai/cli 0.5.1

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/src/todo.js ADDED
@@ -0,0 +1,75 @@
1
+ // 任务清单工具:
2
+ // 模型用 todo_write 维护当前任务的步骤清单(每次提交完整列表),CLI 实时渲染进度。
3
+ // 纯客户端状态(不落盘、不进模型上下文之外的系统),状态由调用方(会话)持有以跨轮保留。
4
+
5
+ const STATUSES = new Set(['pending', 'in_progress', 'completed']);
6
+
7
+ export const TODO_TOOL_NAME = 'todo_write';
8
+
9
+ /**
10
+ * 构造 todo_write 工具。
11
+ * @param {{state:{list:any[]}, emit:(list:any[])=>void}} p state 由调用方持有(跨轮保留);emit 用于 UI 渲染
12
+ * @returns {{spec:object, run:Function, readOnly:boolean}}
13
+ */
14
+ export function makeTodoTool({ state, emit }) {
15
+ const spec = {
16
+ type: 'function',
17
+ function: {
18
+ name: TODO_TOOL_NAME,
19
+ description:
20
+ '维护当前任务的步骤清单,帮助用户看到进度。多步骤/非琐碎任务开始前先列出计划,'
21
+ + '每完成一步就更新状态(同一时刻至多一项 in_progress)。每次调用需提交完整清单(全量覆盖)。'
22
+ + '单步小事无需使用。',
23
+ parameters: {
24
+ type: 'object',
25
+ properties: {
26
+ todos: {
27
+ type: 'array',
28
+ description: '完整的任务清单(全量覆盖旧清单)',
29
+ items: {
30
+ type: 'object',
31
+ properties: {
32
+ content: { type: 'string', description: '任务内容(简短祈使句)' },
33
+ status: { type: 'string', enum: ['pending', 'in_progress', 'completed'], description: '任务状态' },
34
+ },
35
+ required: ['content', 'status'],
36
+ },
37
+ },
38
+ },
39
+ required: ['todos'],
40
+ },
41
+ },
42
+ };
43
+
44
+ const run = async (args) => {
45
+ const raw = Array.isArray(args?.todos) ? args.todos : null;
46
+ if (!raw) return 'todo_write 需要 todos 数组';
47
+ const list = raw
48
+ .map((t) => ({
49
+ content: String(t?.content ?? '').trim(),
50
+ status: STATUSES.has(t?.status) ? t.status : 'pending',
51
+ }))
52
+ .filter((t) => t.content)
53
+ .slice(0, 50);
54
+ state.list = list;
55
+ try {
56
+ emit?.(list);
57
+ } catch {
58
+ /* 渲染失败不影响流程 */
59
+ }
60
+ const done = list.filter((t) => t.status === 'completed').length;
61
+ return `已更新任务清单:共 ${list.length} 项,完成 ${done} 项。`;
62
+ };
63
+
64
+ return { readOnly: true, spec, run };
65
+ }
66
+
67
+ /** 把清单渲染为多行文本(REPL/降级输出用)。 */
68
+ export function formatTodos(list) {
69
+ return (list || [])
70
+ .map((t) => {
71
+ const mark = t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '◐' : '☐';
72
+ return `${mark} ${t.content}`;
73
+ })
74
+ .join('\n');
75
+ }
package/src/tools.js ADDED
@@ -0,0 +1,132 @@
1
+ // 本机工具的 OpenAI 函数规格(随请求透传给平台模型)+ 名称→执行器分发。
2
+
3
+ import * as ex from './executors.js';
4
+
5
+ const obj = (properties, required) => ({ type: 'object', properties, required: required || [] });
6
+ const str = (description) => ({ type: 'string', description });
7
+ const int = (description) => ({ type: 'integer', description });
8
+ const bool = (description) => ({ type: 'boolean', description });
9
+
10
+ /** @type {any[]} 暴露给模型的工具(函数调用规格)。 */
11
+ export const TOOL_SPECS = [
12
+ {
13
+ type: 'function',
14
+ function: {
15
+ name: 'read_file',
16
+ description:
17
+ '读取文本文件内容,每行带「行号→」前缀(行号仅供定位,不属于文件内容,编辑时不要包含它)。'
18
+ + '默认最多 2000 行,可用 offset(起始行,1基)/limit(行数) 分段读取大文件。',
19
+ parameters: obj(
20
+ { path: str('相对项目根的文件路径'), offset: int('可选:起始行(1基)'), limit: int('可选:读取行数') },
21
+ ['path'],
22
+ ),
23
+ },
24
+ },
25
+ {
26
+ type: 'function',
27
+ function: {
28
+ name: 'list_files',
29
+ description: '列出目录下的文件树(跳过 .git/node_modules 等)。可选 path 指定子目录(默认项目根)。',
30
+ parameters: obj({ path: str('可选:子目录路径,默认当前项目根') }, []),
31
+ },
32
+ },
33
+ {
34
+ type: 'function',
35
+ function: {
36
+ name: 'glob',
37
+ description: '按 glob 模式查找文件(支持 ** 与 *),如 "**/*.ts"、"src/**/*.java"。',
38
+ parameters: obj({ pattern: str('glob 模式') }, ['pattern']),
39
+ },
40
+ },
41
+ {
42
+ type: 'function',
43
+ function: {
44
+ name: 'grep',
45
+ description:
46
+ '在文件内容中按正则搜索,返回 匹配文件:行号: 该行。可选 path 限定目录、glob 限定文件名模式、'
47
+ + 'ignore_case 忽略大小写、files_only 只列出命中文件。',
48
+ parameters: obj(
49
+ {
50
+ pattern: str('正则表达式'),
51
+ path: str('可选:搜索目录,默认项目根'),
52
+ glob: str('可选:文件名 glob 过滤'),
53
+ ignore_case: bool('可选:忽略大小写(默认否)'),
54
+ files_only: bool('可选:只返回命中文件列表,不含匹配行(默认否)'),
55
+ max_results: int('可选:最多返回条数(默认 200,上限 500)'),
56
+ },
57
+ ['pattern'],
58
+ ),
59
+ },
60
+ },
61
+ {
62
+ type: 'function',
63
+ function: {
64
+ name: 'write_file',
65
+ description: '把文本写入文件(覆盖式,自动建父目录)。仅限当前项目目录内。仅用于新建文件或全量重写;小改动优先 edit_file。',
66
+ parameters: obj({ path: str('相对项目根的文件路径'), content: str('完整文件内容') }, ['path', 'content']),
67
+ },
68
+ },
69
+ {
70
+ type: 'function',
71
+ function: {
72
+ name: 'edit_file',
73
+ description:
74
+ '精确替换文件中的片段:old_string 必须是文件原文(不含 read_file 的行号前缀)且在文件中唯一'
75
+ + '(除非 replace_all=true)。仅限项目目录内。',
76
+ parameters: obj(
77
+ {
78
+ path: str('相对项目根的文件路径'),
79
+ old_string: str('被替换的原文(需在文件中唯一,除非 replace_all;不要包含行号前缀)'),
80
+ new_string: str('替换为的新内容'),
81
+ replace_all: bool('是否替换全部匹配(默认否)'),
82
+ },
83
+ ['path', 'old_string', 'new_string'],
84
+ ),
85
+ },
86
+ },
87
+ {
88
+ type: 'function',
89
+ function: {
90
+ name: 'run_bash',
91
+ description:
92
+ '在项目目录下执行一条 shell 命令并返回输出(stdout+stderr+退出码)。用于构建、测试、git、脚手架等。'
93
+ + '默认超时 120s(可用 timeout 调整)。避免用它读文件/搜索(用 read_file/grep 更高效)。',
94
+ parameters: obj({ command: str('要执行的 shell 命令'), timeout: int('可选:超时毫秒') }, ['command']),
95
+ },
96
+ },
97
+ {
98
+ type: 'function',
99
+ function: {
100
+ name: 'fetch_url',
101
+ description:
102
+ '抓取一个 http/https 网页或接口(GET),HTML 自动转为纯文本。用于查在线文档、API 说明、Issue 等。'
103
+ + '结果可能很长,尽量带着明确目的使用。',
104
+ parameters: obj({ url: str('完整 URL(http/https)'), timeout: int('可选:超时毫秒,默认 30000') }, ['url']),
105
+ },
106
+ },
107
+ ];
108
+
109
+ /** 只读工具(默认自动放行,无需审批)。fetch_url 涉及外发请求,需用户授权。 */
110
+ export const READONLY_TOOLS = new Set(['read_file', 'list_files', 'glob', 'grep']);
111
+
112
+ const EXECUTORS = {
113
+ read_file: ex.readFile,
114
+ list_files: ex.listFiles,
115
+ glob: ex.glob,
116
+ grep: ex.grep,
117
+ write_file: ex.writeFile,
118
+ edit_file: ex.editFile,
119
+ run_bash: ex.runBash,
120
+ fetch_url: ex.fetchUrl,
121
+ };
122
+
123
+ /** 执行一个本机工具,返回文本结果(回填给模型)。抛错转为可读文本,保证循环不中断。 */
124
+ export async function runTool(name, args, cwd) {
125
+ const fn = EXECUTORS[name];
126
+ if (!fn) return `未知工具:${name}`;
127
+ try {
128
+ return await fn(cwd, args || {});
129
+ } catch (e) {
130
+ return `工具执行失败:${e.message}`;
131
+ }
132
+ }
package/src/tui/app.js ADDED
@@ -0,0 +1,468 @@
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 } from 'react';
14
+ import { render, Box, Text, Static, useInput, useApp } from 'ink';
15
+ import { runTurn } from '../agent.js';
16
+ import { toolLabel } from '../render.js';
17
+ import { createSession, saveSession } from '../session.js';
18
+ import { buildExtraTools } from '../repl.js';
19
+ import { closeMcp } from '../mcp.js';
20
+ import { runSlash } from '../commands.js';
21
+ import { expandAtMentions, runBangCommand, loadHistory, appendHistory } from '../input.js';
22
+ import { loadAllowRules, matchAllowRules, suggestRule, addProjectAllowRule, riskWarning } from '../settings.js';
23
+
24
+ const h = React.createElement;
25
+
26
+ function shortLine(s, max = 200) {
27
+ const first = String(s).split('\n')[0];
28
+ return first.length > max ? first.slice(0, max) + '…' : first;
29
+ }
30
+
31
+ function clipLines(s, maxLines = 12) {
32
+ const lines = String(s).split('\n');
33
+ if (lines.length <= maxLines) return s;
34
+ return lines.slice(0, maxLines).join('\n') + `\n…(共 ${lines.length} 行)`;
35
+ }
36
+
37
+ function fmtTok(n) {
38
+ return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n);
39
+ }
40
+
41
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
42
+
43
+ function App({ config, cwd, session: initialSession, extraTools, yolo, initialPrompt, version, onExit }) {
44
+ const app = useApp();
45
+ const logRef = useRef([]); // 已定稿记录:{id,kind,text}
46
+ const idRef = useRef(0);
47
+ const liveRef = useRef(''); // 当前流式正文
48
+ const sessionRef = useRef(initialSession);
49
+ const abortRef = useRef(null);
50
+ const pendingRef = useRef(null); // 授权中:{name,args,resolve}
51
+ const sessionAllow = useRef(new Set());
52
+ const rulesRef = useRef(loadAllowRules(cwd)); // settings.json 允许规则
53
+ const yoloRef = useRef(!!yolo);
54
+ const startedRef = useRef(false);
55
+ const statusRef = useRef('idle');
56
+ const queueRef = useRef([]); // 忙时排队的用户消息
57
+ const todoStateRef = useRef({ list: [] });
58
+ const historyRef = useRef(loadHistory(cwd));
59
+ const histIdxRef = useRef(null);
60
+ const draftRef = useRef('');
61
+
62
+ const [, force] = useReducer((x) => x + 1, 0);
63
+ const [status, setStatus] = useState('idle'); // idle | busy
64
+ const [inp, setInp] = useState({ text: '', cursor: 0 });
65
+ const [pending, setPending] = useState(null);
66
+ const [usage, setUsage] = useState({ prompt: 0, completion: 0 });
67
+ const [ctxTok, setCtxTok] = useState(0);
68
+ const [todos, setTodos] = useState([]);
69
+ const [queueLen, setQueueLen] = useState(0);
70
+ const [spin, setSpin] = useState(0);
71
+
72
+ const addLog = (kind, text) => {
73
+ logRef.current.push({ id: idRef.current++, kind, text });
74
+ force();
75
+ };
76
+ const flushLive = () => {
77
+ if (liveRef.current) {
78
+ addLog('assistant', liveRef.current);
79
+ liveRef.current = '';
80
+ }
81
+ };
82
+ const setBusy = (b) => {
83
+ statusRef.current = b ? 'busy' : 'idle';
84
+ setStatus(statusRef.current);
85
+ };
86
+
87
+ // 思考中 spinner
88
+ useEffect(() => {
89
+ if (status !== 'busy') return;
90
+ const t = setInterval(() => setSpin((s) => (s + 1) % SPINNER.length), 80);
91
+ return () => clearInterval(t);
92
+ }, [status]);
93
+
94
+ const thinkRef = useRef(''); // 思考过程(reasoning)流式缓冲,仅动态区展示不入定稿记录
95
+ const events = {
96
+ onDelta: (t) => {
97
+ if (thinkRef.current) thinkRef.current = ''; // 正文开始,思考收起
98
+ liveRef.current += t;
99
+ force();
100
+ },
101
+ onReasoning: (t) => {
102
+ thinkRef.current = (thinkRef.current + t).slice(-400);
103
+ force();
104
+ },
105
+ onToolStart: (name, args) => {
106
+ thinkRef.current = '';
107
+ flushLive();
108
+ addLog('tool', toolLabel(name, args));
109
+ },
110
+ onToolResult: (name, res) => addLog('toolresult', shortLine(res)),
111
+ onSkip: (name, args) => addLog('skip', toolLabel(name, args) + ' — 已跳过'),
112
+ onInfo: (t) => {
113
+ flushLive();
114
+ addLog('info', t);
115
+ },
116
+ onTodo: (list) => setTodos(list),
117
+ onTurnEnd: () => {
118
+ thinkRef.current = '';
119
+ flushLive();
120
+ },
121
+ };
122
+
123
+ // ink 版权限:只读/yolo/本会话/settings 规则放行;否则弹提示等待按键(y/a/A/n)
124
+ const permissions = {
125
+ request: (name, args, _cwd, readOnly) => {
126
+ if (readOnly || yoloRef.current || sessionAllow.current.has(name)
127
+ || matchAllowRules(rulesRef.current, name, args)) {
128
+ return Promise.resolve({ allow: true });
129
+ }
130
+ return new Promise((resolve) => {
131
+ pendingRef.current = { name, args, resolve };
132
+ setPending({ label: toolLabel(name, args), name, args, risk: riskWarning(name, args) });
133
+ });
134
+ },
135
+ isYolo: () => yoloRef.current,
136
+ setYolo: (v) => {
137
+ yoloRef.current = !!v;
138
+ },
139
+ };
140
+
141
+ const pullQueued = () => {
142
+ const q = queueRef.current.shift();
143
+ setQueueLen(queueRef.current.length);
144
+ if (q == null) return null;
145
+ return expandAtMentions(q, cwd).text;
146
+ };
147
+
148
+ const runUserTurn = async (text) => {
149
+ sessionRef.current.messages.push({ role: 'user', content: expandAtMentions(text, cwd).text });
150
+ setBusy(true);
151
+ abortRef.current = new AbortController();
152
+ if (!sessionRef.current.compaction) sessionRef.current.compaction = {};
153
+ const r = await runTurn({
154
+ config,
155
+ messages: sessionRef.current.messages,
156
+ permissions,
157
+ cwd,
158
+ extraTools,
159
+ events,
160
+ signal: abortRef.current.signal,
161
+ todoState: todoStateRef.current,
162
+ compaction: sessionRef.current.compaction,
163
+ pullQueued,
164
+ });
165
+ abortRef.current = null;
166
+ flushLive();
167
+ if (r?.usage) setUsage((u) => ({ prompt: u.prompt + r.usage.prompt, completion: u.completion + r.usage.completion }));
168
+ if (r?.lastPromptTokens) {
169
+ setCtxTok(r.lastPromptTokens);
170
+ sessionRef.current.lastPromptTokens = r.lastPromptTokens;
171
+ }
172
+ saveSession(sessionRef.current);
173
+ if ((r?.aborted || r?.error) && queueRef.current.length) {
174
+ queueRef.current = [];
175
+ setQueueLen(0);
176
+ addLog('info', '已清空排队消息');
177
+ }
178
+ setBusy(false);
179
+ };
180
+
181
+ const doExit = () => {
182
+ onExit?.();
183
+ app.exit();
184
+ };
185
+
186
+ const handleSlash = async (line) => {
187
+ const ctx = {
188
+ version,
189
+ config,
190
+ cwd,
191
+ usage,
192
+ permissions,
193
+ extraTools,
194
+ session: sessionRef.current,
195
+ print: (t) => addLog('info', String(t)),
196
+ };
197
+ const r = await runSlash(line, ctx);
198
+ if (r.action === 'exit') return doExit();
199
+ if (r.action === 'clear') {
200
+ sessionRef.current = createSession({ cwd, model: config.model, messages: [sessionRef.current.messages[0]] });
201
+ todoStateRef.current.list = [];
202
+ setTodos([]);
203
+ setCtxTok(0);
204
+ logRef.current = [];
205
+ liveRef.current = '';
206
+ addLog('info', '已开启新会话(旧会话仍可 --resume 恢复)');
207
+ }
208
+ if (r.prompt) {
209
+ if (statusRef.current === 'busy') {
210
+ queueRef.current.push(r.prompt);
211
+ setQueueLen(queueRef.current.length);
212
+ } else {
213
+ addLog('user', r.prompt);
214
+ await runUserTurn(r.prompt);
215
+ }
216
+ }
217
+ };
218
+
219
+ const handleBang = async (command) => {
220
+ if (statusRef.current === 'busy') {
221
+ addLog('info', '正在处理上一条,! 命令请稍候再试');
222
+ return;
223
+ }
224
+ addLog('user', '! ' + command);
225
+ const { output, record } = await runBangCommand(command, cwd);
226
+ addLog('toolresult', clipLines(output));
227
+ sessionRef.current.messages.push({ role: 'user', content: record });
228
+ saveSession(sessionRef.current);
229
+ };
230
+
231
+ const submit = async (raw) => {
232
+ const t = raw.trim();
233
+ setInp({ text: '', cursor: 0 });
234
+ histIdxRef.current = null;
235
+ if (!t) return;
236
+ appendHistory(cwd, t);
237
+ if (historyRef.current[historyRef.current.length - 1] !== t) historyRef.current.push(t);
238
+ if (t.startsWith('/')) return handleSlash(t);
239
+ if (t.startsWith('!') && t.length > 1) return handleBang(t.slice(1).trim());
240
+ if (statusRef.current === 'busy') {
241
+ // 边跑边补充:排队,工具轮间隙自动注入本回合
242
+ addLog('user', t);
243
+ queueRef.current.push(t);
244
+ setQueueLen(queueRef.current.length);
245
+ return;
246
+ }
247
+ addLog('user', t);
248
+ await runUserTurn(t);
249
+ };
250
+
251
+ // 首个自动指令
252
+ useEffect(() => {
253
+ if (!startedRef.current && initialPrompt) {
254
+ startedRef.current = true;
255
+ void submit(initialPrompt);
256
+ }
257
+ // eslint-disable-next-line react-hooks/exhaustive-deps
258
+ }, []);
259
+
260
+ // 历史翻阅
261
+ const histUp = () => {
262
+ const hist = historyRef.current;
263
+ if (!hist.length) return;
264
+ if (histIdxRef.current == null) {
265
+ draftRef.current = inp.text;
266
+ histIdxRef.current = hist.length - 1;
267
+ } else if (histIdxRef.current > 0) {
268
+ histIdxRef.current--;
269
+ }
270
+ const t = hist[histIdxRef.current] ?? '';
271
+ setInp({ text: t, cursor: t.length });
272
+ };
273
+ const histDown = () => {
274
+ if (histIdxRef.current == null) return;
275
+ const hist = historyRef.current;
276
+ if (histIdxRef.current < hist.length - 1) {
277
+ histIdxRef.current++;
278
+ const t = hist[histIdxRef.current];
279
+ setInp({ text: t, cursor: t.length });
280
+ } else {
281
+ histIdxRef.current = null;
282
+ setInp({ text: draftRef.current, cursor: draftRef.current.length });
283
+ }
284
+ };
285
+
286
+ useInput((ch, key) => {
287
+ // 授权提示优先(y 一次 / a 本会话 / A 永久规则 / n·Esc·回车 拒绝)
288
+ if (pendingRef.current) {
289
+ const p = pendingRef.current;
290
+ const done = (res) => {
291
+ pendingRef.current = null;
292
+ setPending(null);
293
+ p.resolve(res);
294
+ };
295
+ if (ch === 'y') return done({ allow: true });
296
+ if (ch === 'a') {
297
+ sessionAllow.current.add(p.name);
298
+ return done({ allow: true });
299
+ }
300
+ if (ch === 'A') {
301
+ sessionAllow.current.add(p.name);
302
+ const rule = suggestRule(p.name, p.args);
303
+ try {
304
+ addProjectAllowRule(cwd, rule);
305
+ rulesRef.current = loadAllowRules(cwd);
306
+ addLog('info', `已写入允许规则:${rule} → .huicai/settings.json`);
307
+ } catch (e) {
308
+ addLog('info', `规则写入失败(本会话仍生效):${e.message}`);
309
+ }
310
+ return done({ allow: true });
311
+ }
312
+ if (ch === 'n' || key.escape || key.return) return done({ allow: false, reason: '用户拒绝了该操作。' });
313
+ return;
314
+ }
315
+ if (key.escape) {
316
+ if (statusRef.current === 'busy' && abortRef.current) {
317
+ abortRef.current.abort();
318
+ addLog('info', '已请求中断(Esc)');
319
+ }
320
+ return;
321
+ }
322
+ if (key.ctrl && ch === 'c') {
323
+ if (statusRef.current === 'busy' && abortRef.current) {
324
+ abortRef.current.abort();
325
+ addLog('info', '已请求中断');
326
+ } else {
327
+ doExit();
328
+ }
329
+ return;
330
+ }
331
+ if (key.return) {
332
+ void submit(inp.text);
333
+ return;
334
+ }
335
+ if (key.upArrow) return histUp();
336
+ if (key.downArrow) return histDown();
337
+ if (key.leftArrow) return setInp((s) => ({ ...s, cursor: Math.max(0, s.cursor - 1) }));
338
+ if (key.rightArrow) return setInp((s) => ({ ...s, cursor: Math.min(s.text.length, s.cursor + 1) }));
339
+ if (key.ctrl && ch === 'a') return setInp((s) => ({ ...s, cursor: 0 }));
340
+ if (key.ctrl && ch === 'e') return setInp((s) => ({ ...s, cursor: s.text.length }));
341
+ if (key.ctrl && ch === 'u') return setInp((s) => ({ text: s.text.slice(s.cursor), cursor: 0 }));
342
+ if (key.ctrl && ch === 'k') return setInp((s) => ({ text: s.text.slice(0, s.cursor), cursor: s.cursor }));
343
+ if (key.ctrl && ch === 'w') {
344
+ return setInp((s) => {
345
+ const before = s.text.slice(0, s.cursor).replace(/\S+\s*$/, '');
346
+ return { text: before + s.text.slice(s.cursor), cursor: before.length };
347
+ });
348
+ }
349
+ if (key.backspace || key.delete) {
350
+ histIdxRef.current = null;
351
+ return setInp((s) => (s.cursor > 0
352
+ ? { text: s.text.slice(0, s.cursor - 1) + s.text.slice(s.cursor), cursor: s.cursor - 1 }
353
+ : s));
354
+ }
355
+ if (ch && !key.ctrl && !key.meta) {
356
+ histIdxRef.current = null;
357
+ const str = ch.replace(/\r\n?/g, '\n'); // 粘贴多行归一化
358
+ setInp((s) => ({ text: s.text.slice(0, s.cursor) + str + s.text.slice(s.cursor), cursor: s.cursor + str.length }));
359
+ }
360
+ });
361
+
362
+ // ---- 渲染 ----
363
+ const logItems = logRef.current;
364
+ const cwdName = cwd.split('/').filter(Boolean).pop() || cwd;
365
+ const before = inp.text.slice(0, inp.cursor);
366
+ const at = inp.text[inp.cursor] ?? ' ';
367
+ const after = inp.text.slice(inp.cursor + 1);
368
+
369
+ return h(
370
+ Box,
371
+ { flexDirection: 'column' },
372
+ // 已定稿记录(Static:只渲染一次,天然滚动)
373
+ h(Static, { items: logItems }, (item) => h(LogLine, { key: item.id, item })),
374
+ // 思考过程(reasoning,流式、不入定稿记录)
375
+ thinkRef.current
376
+ ? h(Box, null, h(Text, { color: 'gray', italic: true }, '✦ 思考 ' + thinkRef.current.replace(/\n/g, ' ').slice(-160)))
377
+ : null,
378
+ // 流式正文
379
+ liveRef.current ? h(Box, { marginTop: 0 }, h(Text, null, liveRef.current)) : null,
380
+ // 任务清单(todo_write 实时更新)
381
+ todos.length
382
+ ? h(
383
+ Box,
384
+ { flexDirection: 'column', paddingLeft: 2, marginTop: 1 },
385
+ ...todos.map((t, i) =>
386
+ h(
387
+ Text,
388
+ {
389
+ key: i,
390
+ color: t.status === 'completed' ? 'green' : t.status === 'in_progress' ? 'cyan' : 'gray',
391
+ dimColor: t.status === 'completed',
392
+ bold: t.status === 'in_progress',
393
+ },
394
+ (t.status === 'completed' ? '☑ ' : t.status === 'in_progress' ? '◐ ' : '☐ ') + t.content,
395
+ ),
396
+ ),
397
+ )
398
+ : null,
399
+ // 思考 spinner
400
+ status === 'busy' && !liveRef.current
401
+ ? h(Box, null, h(Text, { color: 'cyan' }, SPINNER[spin] + ' 思考中…'), h(Text, { color: 'gray' }, '(Esc 中断 · 输入回车可排队补充)'))
402
+ : null,
403
+ // 授权提示
404
+ pending
405
+ ? h(
406
+ Box,
407
+ { flexDirection: 'column', borderStyle: 'round', borderColor: 'yellow', paddingX: 1, marginTop: 1 },
408
+ h(Text, { color: 'yellow', bold: true }, '● 需要授权:' + pending.label),
409
+ pending.risk ? h(Text, { color: 'red', bold: true }, '⚠ ' + pending.risk) : null,
410
+ pending.name === 'run_bash' ? h(Text, { color: 'gray' }, '$ ' + (pending.args.command || '')) : null,
411
+ pending.name === 'fetch_url' ? h(Text, { color: 'gray' }, 'GET ' + (pending.args.url || '')) : null,
412
+ h(Text, null, '[y] 允许一次 · [a] 本会话 · [A] 永久(写入项目设置) · [n/Esc] 拒绝'),
413
+ )
414
+ : null,
415
+ // 输入行(带光标;忙时输入会排队)
416
+ h(
417
+ Box,
418
+ { marginTop: 1 },
419
+ h(Text, { color: status === 'busy' ? 'yellow' : 'cyan' }, status === 'busy' ? '⧗ ' : '› '),
420
+ h(Text, null, before),
421
+ h(Text, { inverse: true }, at),
422
+ h(Text, null, after),
423
+ ),
424
+ // 状态栏
425
+ h(
426
+ Box,
427
+ null,
428
+ h(
429
+ Text,
430
+ { color: 'gray' },
431
+ `${cwdName} · ${config.model}${yoloRef.current ? ' · ⚠ yolo' : ''}`
432
+ + ` · tok ${usage.prompt}/${usage.completion}`
433
+ + (ctxTok ? ` · ctx ~${fmtTok(ctxTok)}` : '')
434
+ + (queueLen ? ` · 排队 ${queueLen}` : '')
435
+ + ' · /help',
436
+ ),
437
+ ),
438
+ );
439
+ }
440
+
441
+ /** 单行记录渲染(按 kind 着色)。 */
442
+ function LogLine({ item }) {
443
+ const { kind, text } = item;
444
+ if (kind === 'user') return h(Box, { marginTop: 1 }, h(Text, { color: 'cyan', bold: true }, '› '), h(Text, { bold: true }, text));
445
+ if (kind === 'assistant') return h(Box, null, h(Text, null, text));
446
+ if (kind === 'tool') return h(Text, { color: 'cyan' }, ' ⏺ ' + text);
447
+ if (kind === 'toolresult') return h(Text, { color: 'gray' }, ' ' + text);
448
+ if (kind === 'skip') return h(Text, { color: 'gray' }, ' ⊘ ' + text);
449
+ if (kind === 'info') {
450
+ // 失败/错误类信息用红色高亮,避免被淹没(如「模型调用失败:令牌校验失败」)
451
+ const isErr = /✗|失败|错误|error/i.test(text);
452
+ return h(Text, { color: isErr ? 'red' : 'yellow' }, (isErr ? ' ✗ ' : ' · ') + text.replace(/^✗\s*/, ''));
453
+ }
454
+ return h(Text, null, text);
455
+ }
456
+
457
+ /**
458
+ * 启动 ink TUI。先(在 ink 接管屏幕前)连接 MCP 并打印连接信息,再进入全屏界面;退出后关闭 MCP。
459
+ */
460
+ export async function runInk({ config, cwd, session, yolo, initialPrompt, version }) {
461
+ process.stdout.write(`\n huicai · 终端智能体(ink TUI) · ${config.model}\n`);
462
+ const { extraTools, clients } = await buildExtraTools(config, cwd);
463
+ const { waitUntilExit } = render(
464
+ h(App, { config, cwd, session, extraTools, yolo, initialPrompt, version, onExit: () => closeMcp(clients) }),
465
+ );
466
+ await waitUntilExit();
467
+ closeMcp(clients);
468
+ }