minimal-agent 0.5.3 → 0.5.5

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/loop.js CHANGED
@@ -15,18 +15,23 @@
15
15
  * a. 自动压缩检查(防止 context 撑爆)
16
16
  * b. 调 LLM,把流式响应组装成完整的 assistant message
17
17
  * c. 如果 assistant 不调工具 → 结束,return
18
- * d. 否则按顺序执行每个工具调用,把 tool 消息追加到历史
18
+ * d. 否则并行执行所有工具调用(OpenAI 协议要求 tool 消息按 tool_calls 顺序回填)
19
19
  * e. 进入下一轮(让模型看到 tool_result 后继续推理)
20
20
  *
21
21
  * 失控保护:maxTurns(默认 50)。
22
22
  * 中断支持:AbortSignal 透传到 chat() 和 tool.call()。
23
+ *
24
+ * v0.5.3 新增"工作过程显示"事件:
25
+ * - stage_change:thinking / streaming / tool_executing / compacting
26
+ * - reasoning:思维链流式增量(仅字符串型 field)
27
+ * - token_tick:200ms 节流,UI 用来显示 token / 耗时心跳
23
28
  * ============================================================
24
29
  */
25
30
  import { autoCompactIfNeeded } from './context/compact.js';
26
31
  import { microCompact, incrementTurn, expireOldEntries } from './context/microCompactLite.js';
27
32
  import { isPromptTooLongError, reactiveCompactIfApplicable, } from './context/reactiveCompact.js';
28
- import { chat } from './llm/client.js';
29
- import { ALL_TOOLS, executeTool } from './tools/index.js';
33
+ import { chat as defaultChat } from './llm/client.js';
34
+ import { ALL_TOOLS, executeTool as defaultExecuteTool } from './tools/index.js';
30
35
  /**
31
36
  * 执行一次"用户输入 → 模型回答完成"的完整流程。
32
37
  *
@@ -35,11 +40,20 @@ import { ALL_TOOLS, executeTool } from './tools/index.js';
35
40
  export async function* runQuery(userInput, options) {
36
41
  const { provider, history, signal, sessionState } = options;
37
42
  const maxTurns = options.maxTurns ?? 50;
38
- // 1. 用户消息入栈
39
- history.push({ role: 'user', content: userInput });
43
+ // DI fallback:测试可传 stub,生产走真实模块单例
44
+ const chatFn = options.chat ?? defaultChat;
45
+ const executeToolFn = options.executeTool ?? defaultExecuteTool;
46
+ // 1. 用户消息入栈(v2:填 timestamp,让 MessageList 的 turn 边界横线
47
+ // 能拿到首条 user 的真实时间,否则 fallback Date.now() 会丢失"提交时刻"语义)
48
+ history.push({ role: 'user', content: userInput, timestamp: Date.now() });
40
49
  // 反应式压缩自救:本 runQuery 只允许触发一次,避免压缩失败导致死循环。
41
50
  // 配合 reactiveCompact.ts 的 attempted(session 级)双层防爆。
42
51
  let reactiveAttempted = false;
52
+ // token_tick 心跳起点:跨多轮累计估算 token 与耗时
53
+ const turnStart = Date.now();
54
+ // v2 工具 step 计数器:跨整次 runQuery 累计每个工具 worker 的派发序号
55
+ // (每个 worker 启动时 ++)。token_tick yield 时携带 stepN 给 UI 显示 "step N"。
56
+ let stepN = 0;
43
57
  let turn = 0;
44
58
  while (turn < maxTurns) {
45
59
  turn++;
@@ -50,6 +64,7 @@ export async function* runQuery(userInput, options) {
50
64
  history.push({
51
65
  role: 'user',
52
66
  content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
67
+ timestamp: Date.now(),
53
68
  });
54
69
  yield { type: 'error', error: '已被用户中断' };
55
70
  return;
@@ -59,10 +74,13 @@ export async function* runQuery(userInput, options) {
59
74
  const compact = await autoCompactIfNeeded(history, provider);
60
75
  if (compact.compacted) {
61
76
  yield { type: 'compact_start' };
77
+ yield { type: 'stage_change', stage: 'compacting' };
62
78
  // in-place 替换 history(保持调用方持有的引用有效)
63
79
  history.length = 0;
64
80
  history.push(...compact.messages);
65
81
  yield { type: 'compact_done', before: compact.before, after: compact.after };
82
+ // 压缩完会回到 LLM 调用,stage 退回 thinking
83
+ yield { type: 'stage_change', stage: 'thinking' };
66
84
  }
67
85
  }
68
86
  catch (e) {
@@ -83,14 +101,24 @@ export async function* runQuery(userInput, options) {
83
101
  let reasoningContent = '';
84
102
  let reasoningString = '';
85
103
  const reasoningDetails = [];
104
+ /** 首个 text_delta 时切到 streaming,再来不重复 yield */
105
+ let stageStreamingYielded = false;
106
+ /** 200ms token_tick 节流游标 */
107
+ let lastTick = Date.now();
108
+ // 进入 LLM 流前:等首 token / reasoning,本质是 thinking
109
+ yield { type: 'stage_change', stage: 'thinking' };
86
110
  try {
87
- for await (const ev of chat({
111
+ for await (const ev of chatFn({
88
112
  provider,
89
113
  messages: history,
90
114
  tools: ALL_TOOLS,
91
115
  signal,
92
116
  })) {
93
117
  if (ev.type === 'text_delta') {
118
+ if (!stageStreamingYielded) {
119
+ stageStreamingYielded = true;
120
+ yield { type: 'stage_change', stage: 'streaming' };
121
+ }
94
122
  assistantText += ev.delta;
95
123
  yield { type: 'text', delta: ev.delta };
96
124
  }
@@ -100,15 +128,35 @@ export async function* runQuery(userInput, options) {
100
128
  else if (ev.type === 'reasoning_delta') {
101
129
  if (ev.field === 'reasoning_content' && ev.delta) {
102
130
  reasoningContent += ev.delta;
131
+ yield { type: 'reasoning', delta: ev.delta };
103
132
  }
104
133
  else if (ev.field === 'reasoning' && ev.delta) {
105
134
  reasoningString += ev.delta;
135
+ yield { type: 'reasoning', delta: ev.delta };
106
136
  }
107
137
  else if (ev.field === 'reasoning_details' && ev.items) {
138
+ // reasoning_details 是结构化数组,没有 delta 字符串,跳过 yield
108
139
  reasoningDetails.push(...ev.items);
109
140
  }
141
+ if (ev.delta) {
142
+ yield { type: 'reasoning_delta', field: ev.field, delta: ev.delta };
143
+ }
110
144
  }
111
145
  // ev.type === 'done' 时无需处理:循环自然结束
146
+ // 每 chunk 同步检查 200ms 节流,避免起 setInterval 与 generator yield 时机冲突
147
+ const now = Date.now();
148
+ if (now - lastTick >= 200) {
149
+ lastTick = now;
150
+ const usedThisTurn = Math.ceil(assistantText.length / 4) +
151
+ Math.ceil(reasoningContent.length / 4) +
152
+ Math.ceil(reasoningString.length / 4);
153
+ yield {
154
+ type: 'token_tick',
155
+ usedThisTurn,
156
+ elapsedMs: now - turnStart,
157
+ stepN,
158
+ };
159
+ }
112
160
  }
113
161
  }
114
162
  catch (e) {
@@ -117,6 +165,7 @@ export async function* runQuery(userInput, options) {
117
165
  history.push({
118
166
  role: 'user',
119
167
  content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
168
+ timestamp: Date.now(),
120
169
  });
121
170
  yield { type: 'interrupted' };
122
171
  return;
@@ -127,6 +176,7 @@ export async function* runQuery(userInput, options) {
127
176
  if (isPromptTooLongError(e) && !reactiveAttempted) {
128
177
  reactiveAttempted = true;
129
178
  yield { type: 'compact_start' };
179
+ yield { type: 'stage_change', stage: 'compacting' };
130
180
  const result = await reactiveCompactIfApplicable(history, provider, e, sessionState?.reactive);
131
181
  if (result.recovered) {
132
182
  history.length = 0;
@@ -136,6 +186,8 @@ export async function* runQuery(userInput, options) {
136
186
  before: result.before ?? 0,
137
187
  after: result.after ?? 0,
138
188
  };
189
+ // 压缩完回到 LLM 调用
190
+ yield { type: 'stage_change', stage: 'thinking' };
139
191
  turn--; // 不消耗 turn 配额,本轮重新走
140
192
  continue;
141
193
  }
@@ -151,6 +203,7 @@ export async function* runQuery(userInput, options) {
151
203
  ...(reasoningContent ? { reasoning_content: reasoningContent } : {}),
152
204
  ...(reasoningString ? { reasoning: reasoningString } : {}),
153
205
  ...(reasoningDetails.length > 0 ? { reasoning_details: reasoningDetails } : {}),
206
+ timestamp: Date.now(),
154
207
  };
155
208
  history.push(assistantMsg);
156
209
  yield { type: 'assistant_message', message: assistantMsg };
@@ -159,41 +212,116 @@ export async function* runQuery(userInput, options) {
159
212
  yield { type: 'turn_done' };
160
213
  return;
161
214
  }
162
- // 5. 顺序执行每个工具
163
- for (const tc of toolCallsByIndex) {
164
- if (signal?.aborted) {
165
- // 中断标记帮助模型理解上下文:为什么输出被截断,用户可以重新输入
215
+ // 5. 并行执行所有工具
216
+ //
217
+ // 设计:用 Promise.allSettled 启动 N 个 worker,配合一个简易事件队列
218
+ // (queue + signalNew)把多个 worker 的 tool_start / tool_end 事件按
219
+ // 真实完成顺序交错 yield 给 UI;但 history 里的 tool 消息严格按
220
+ // toolCallsByIndex 索引顺序 push(OpenAI 协议要求 tool 消息和上一条
221
+ // assistant 的 tool_calls 一一对应)。
222
+ yield { type: 'stage_change', stage: 'tool_executing' };
223
+ const queue = [];
224
+ let signalNew = null;
225
+ const enqueue = (ev) => {
226
+ queue.push(ev);
227
+ signalNew?.();
228
+ signalNew = null;
229
+ };
230
+ const workers = toolCallsByIndex.map(async (tc) => {
231
+ // 每个 worker 启动时累加 step 序号;token_tick 携带它给 UI 显示进度感
232
+ stepN++;
233
+ enqueue({
234
+ type: 'tool_start',
235
+ toolCallId: tc.id,
236
+ toolName: tc.function.name,
237
+ argsPreview: previewArgs(tc.function.arguments),
238
+ argsFriendly: friendlyToolDescription(tc.function.name, tc.function.arguments),
239
+ });
240
+ try {
241
+ const result = await executeToolFn(tc.function.name, tc.function.arguments, signal);
242
+ enqueue({
243
+ type: 'tool_end',
244
+ toolCallId: tc.id,
245
+ toolName: tc.function.name,
246
+ ok: result.ok,
247
+ content: result.ok ? result.content : `Error: ${result.error}`,
248
+ });
249
+ return { tc, result };
250
+ }
251
+ catch (e) {
252
+ if (e.name === 'AbortError') {
253
+ enqueue({
254
+ type: 'tool_end',
255
+ toolCallId: tc.id,
256
+ toolName: tc.function.name,
257
+ ok: false,
258
+ content: '(已中断)',
259
+ });
260
+ return { tc, result: { ok: false, error: 'aborted' } };
261
+ }
262
+ const msg = e.message;
263
+ enqueue({
264
+ type: 'tool_end',
265
+ toolCallId: tc.id,
266
+ toolName: tc.function.name,
267
+ ok: false,
268
+ content: msg,
269
+ });
270
+ return { tc, result: { ok: false, error: msg } };
271
+ }
272
+ });
273
+ const allDone = Promise.allSettled(workers);
274
+ let finished = false;
275
+ allDone.then(() => {
276
+ finished = true;
277
+ signalNew?.();
278
+ signalNew = null;
279
+ });
280
+ while (!finished || queue.length > 0) {
281
+ while (queue.length > 0)
282
+ yield queue.shift();
283
+ if (finished)
284
+ break;
285
+ await new Promise((r) => {
286
+ signalNew = r;
287
+ });
288
+ }
289
+ // 严格按 toolCallsByIndex 顺序 push tool 消息进 history(OpenAI 协议要求)
290
+ const settled = await allDone;
291
+ let anyAborted = false;
292
+ for (let i = 0; i < toolCallsByIndex.length; i++) {
293
+ const tc = toolCallsByIndex[i];
294
+ const r = settled[i];
295
+ if (r.status === 'rejected') {
296
+ // 理论上 worker 已经 catch 了所有异常,这里只是防御
297
+ const errMsg = r.reason?.message ?? 'unknown';
166
298
  history.push({
167
- role: 'user',
168
- content: '(用户按下了 ESC/Ctrl+C 中断了任务)',
299
+ role: 'tool',
300
+ tool_call_id: tc.id,
301
+ content: `Error: ${errMsg}`,
302
+ timestamp: Date.now(),
169
303
  });
170
- yield { type: 'error', error: '已被用户中断' };
171
- return;
304
+ continue;
172
305
  }
173
- const argsPreview = previewArgs(tc.function.arguments);
174
- yield {
175
- type: 'tool_start',
176
- toolName: tc.function.name,
177
- toolCallId: tc.id,
178
- argsPreview,
179
- };
180
- const result = await executeTool(tc.function.name, tc.function.arguments, signal);
181
- // 工具结果:失败保留错误信息,成功经微压缩后保留
306
+ const { result } = r.value;
307
+ if (!result.ok && result.error === 'aborted')
308
+ anyAborted = true;
182
309
  const rawContent = result.ok ? result.content : `Error: ${result.error}`;
183
- const content = microCompact(tc.function.name, rawContent, sessionState?.microCompact);
184
- // 工具结果作为 tool 消息回填
310
+ // 微压缩仅对成功结果走(失败结果通常很短,不需要压缩)
311
+ const content = result.ok
312
+ ? microCompact(tc.function.name, rawContent, sessionState?.microCompact)
313
+ : rawContent;
185
314
  history.push({
186
315
  role: 'tool',
187
316
  content,
188
317
  tool_call_id: tc.id,
318
+ timestamp: Date.now(),
189
319
  });
190
- yield {
191
- type: 'tool_end',
192
- toolName: tc.function.name,
193
- toolCallId: tc.id,
194
- ok: result.ok,
195
- content,
196
- };
320
+ }
321
+ if (anyAborted || signal?.aborted) {
322
+ // 任一工具抛 AbortError 时,外层 yield interrupted 并 return
323
+ yield { type: 'interrupted' };
324
+ return;
197
325
  }
198
326
  // 6. 继续 while 让模型看到 tool_result 后继续推理
199
327
  }
@@ -228,3 +356,47 @@ function previewArgs(rawJson) {
228
356
  return oneLine;
229
357
  return oneLine.slice(0, 60) + '...';
230
358
  }
359
+ /**
360
+ * 把工具名 + raw arguments JSON 转换成人话描述。
361
+ * UI 优先用这个显示在 spinner 行(如 "Reading src/foo.ts")。
362
+ * 已知工具走 switch;未知工具或 JSON parse 失败 fallback 到 `${toolName}(...)`。
363
+ */
364
+ function friendlyToolDescription(toolName, rawArgsJson) {
365
+ let args = {};
366
+ try {
367
+ args = JSON.parse(rawArgsJson) ?? {};
368
+ }
369
+ catch {
370
+ return `${toolName}(...)`;
371
+ }
372
+ const truncate = (s, n) => {
373
+ if (typeof s !== 'string')
374
+ return '';
375
+ return s.length > n ? s.slice(0, n) + '…' : s;
376
+ };
377
+ switch (toolName) {
378
+ case 'Read': return `Reading ${truncate(args.file_path, 60)}`;
379
+ case 'Edit': return `Editing ${truncate(args.file_path, 60)}`;
380
+ case 'Write': return `Writing ${truncate(args.file_path, 60)}`;
381
+ case 'Bash': return `Running \`${truncate(args.command, 40)}\``;
382
+ case 'Grep': {
383
+ const p = truncate(args.pattern, 30);
384
+ const g = typeof args.glob === 'string' && args.glob.length > 0 ? ` in ${truncate(args.glob, 25)}` : '';
385
+ return `Searching \`${p}\`${g}`;
386
+ }
387
+ case 'Glob': return `Globbing \`${truncate(args.pattern, 50)}\``;
388
+ case 'WebFetch':
389
+ case 'WebBrowser': {
390
+ try {
391
+ return `Fetching ${new URL(String(args.url)).host}`;
392
+ }
393
+ catch {
394
+ return `Fetching ...`;
395
+ }
396
+ }
397
+ case 'WebSearch': return `Searching \`${truncate(args.query, 50)}\``;
398
+ default: return `${toolName}(...)`;
399
+ }
400
+ }
401
+ // 导出供测试用(friendlyToolDescription 是 module-private 纯函数)
402
+ export { friendlyToolDescription };
@@ -34,6 +34,17 @@ export async function getSystemPrompt(cwd, tools) {
34
34
 
35
35
  # 思维与立场(核心:像专家一样独立思考)
36
36
  - **你是工程协作者,不是奉承者**。不无条件夸赞、不为附和而附和、不同意时直说。不用"很好的问题"、"你说得对"、"完全同意"这类前缀——直接答内容。
37
+ - **先判断用户意图再行动**——这是最重要的第一步,区分以下三种情况:
38
+ - ✅ **明确执行(直接动手,不要犹豫)**:用户表述包含明确的行动指令。
39
+ 动词特征:「修改/改/调整/修复/优化/更新/替换/删/移除/写/创建/实现/做/生成/新建/开发/执行/跑/运行/提交/部署/安装」
40
+ 确认性指令也算:「对就这么改」「是的按这个方案实现」「好的麻烦改一下」
41
+ - ❌ **纯讨论(绝对不能动文件,只回答)**:用户只是提问、咨询、探讨、评估。
42
+ 特征:问号结尾、「是不是/怎么样/为什么/好不好/能否/你觉得」、纯观点表达「我觉得...」「这里好像有...」
43
+ 即使你发现明显问题,也只能给文字建议,**禁止调用 Edit/Write/MultiEdit/Bash 写操作等任何修改类工具**
44
+ - ❓ **模糊场景(仅一次确认,不要啰嗦)**:介于两者之间无法判断时。
45
+ 例:「这个接口响应太慢了」「这个正则好像有问题」
46
+ 只问一句:「你是需要我实际修改/修复,还是只是讨论这个问题?」—— 用户说改就立刻改,说讨论就停
47
+ - **兜底原则**:宁可多问一次也不要擅自修改;但只要用户明确说要改,必须立刻执行,不要二次确认。
37
48
  - **基于证据,不基于揣测用户想听什么**。没读过的代码先 Read,没验证过的行为先验证,不凭印象答。回答前先问自己:这个判断有具体证据吗,还是我在猜?
38
49
  - **不知道就说不知道**。明确边界:"这个版本号我不确定"、"这个 API 我没看过文档"、"这部分行为我没验证过"。胡编一个看起来对的答案,代价远高于承认不知道。
39
50
  - **明示 confidence 分层**。结论按确定度分层表达——"已 verify / 高度确信"、"基于代码合理推测"、"未验证猜测"——让用户基于你的不确定程度决策,不要把推测说成事实。
package/src/ui/App.js CHANGED
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  * ============================================================
9
9
  */
10
10
  import React from 'react';
11
- import { Box, Text } from 'ink';
11
+ import { Box, Text, Static } from 'ink';
12
12
  import { saveContext } from '../context/persistContext.js';
13
13
  import { InputBox } from './InputBox.js';
14
14
  import { MessageList } from './MessageList.js';
@@ -33,5 +33,5 @@ export function App({ provider, initialHistory }) {
33
33
  await chat.clearHistory();
34
34
  process.stdout.write(CLEAR_SCREEN);
35
35
  }, [chat]);
36
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "minimal-agent" }), _jsx(Text, { color: "gray", children: ` · ${provider.name}/${provider.model} · /new 清空 · /compact 压缩 · /exit 退出 · Ctrl+C 中断` })] }), _jsx(MessageList, { history: chat.history, streamingText: chat.streamingText }), _jsx(ToolStatus, { status: chat.toolStatus, compacting: chat.compacting }), chat.error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["\u26A0 ", chat.error] }) })), chat.interrupted && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A1 \u64CD\u4F5C\u88AB\u7528\u6237\u4E2D\u65AD\uFF0C\u7B49\u5F85\u65B0\u7684\u4EFB\u52A1\u8F93\u5165..." }) })), _jsx(InputBox, { onSubmit: chat.submit, disabled: chat.isLoading, onAbort: chat.abort, onClear: handleClear, onCompact: chat.compactNow }), _jsx(StatusLine, { provider: provider, history: chat.history, pluginProgress: chat.pluginProgress })] }));
36
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [null], children: () => (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "minimal-agent" }), _jsx(Text, { color: "gray", children: ` · ${provider.name}/${provider.model} · /new 清空 · /compact 压缩 · /exit 退出 · Ctrl+C 中断` })] })) }), _jsx(MessageList, { history: chat.history, streamingText: chat.streamingText, streamingReasoning: chat.streamingReasoning }), _jsx(ToolStatus, { status: chat.toolStatus, compacting: chat.compacting }), chat.error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["\u26A0 ", chat.error] }) })), chat.interrupted && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "yellow", children: "\u26A1 \u64CD\u4F5C\u88AB\u7528\u6237\u4E2D\u65AD\uFF0C\u7B49\u5F85\u65B0\u7684\u4EFB\u52A1\u8F93\u5165..." }) })), _jsx(InputBox, { onSubmit: chat.submit, disabled: chat.isLoading, onAbort: chat.abort, onClear: handleClear, onCompact: chat.compactNow }), _jsx(Static, { items: [chat.history.length], children: () => (_jsx(StatusLine, { provider: provider, history: chat.history, pluginProgress: chat.pluginProgress })) })] }));
37
37
  }
@@ -20,19 +20,38 @@ import { useRef, useState, useCallback } from 'react';
20
20
  import { Box, Text, useApp, useInput } from 'ink';
21
21
  import { useTextBuffer } from './hooks/useTextBuffer.js';
22
22
  import { usePasteHandler } from './hooks/usePasteHandler.js';
23
+ import { useInputHistory } from './hooks/useInputHistory.js';
23
24
  export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
24
25
  const buf = useTextBuffer();
26
+ const hist = useInputHistory();
25
27
  const ctrlCCountRef = useRef(0);
26
28
  const ctrlCTimerRef = useRef(null);
27
29
  const { exit } = useApp();
28
30
  // ✅ 精准刷新计数器:只在必要时触发重渲染
29
31
  const [refreshCounter, setRefreshCounter] = useState(0);
30
- // ✅ Delete 专用去重:只防止 Windows 双事件,不影响其他键
31
- const lastDeleteTime = useRef(0);
32
- const DELETE_DEBOUNCE_MS = 20; // 仅 Delete 键用极短防抖
32
+ // ✅ 精准刷新函数
33
33
  const forceRefresh = useCallback(() => {
34
34
  setRefreshCounter((c) => c + 1);
35
35
  }, []);
36
+ // ✅ IME 输入缓冲:非 ASCII 字符(CJK/emoji)累积后批量刷新,减少 rerender 频率
37
+ const imeBufRef = useRef('');
38
+ const imeTimerRef = useRef(null);
39
+ const flushImeBuffer = useCallback(() => {
40
+ if (imeTimerRef.current) {
41
+ clearTimeout(imeTimerRef.current);
42
+ imeTimerRef.current = null;
43
+ }
44
+ if (imeBufRef.current.length > 0) {
45
+ buf.insert(imeBufRef.current);
46
+ imeBufRef.current = '';
47
+ forceRefresh();
48
+ }
49
+ }, [buf, forceRefresh]);
50
+ // ✅ Ctrl+R 反向搜索模式(null 表示不在搜索)
51
+ const [searchMode, setSearchMode] = useState(null);
52
+ // ✅ Delete 专用去重:只防止 Windows 双事件,不影响其他键
53
+ const lastDeleteTime = useRef(0);
54
+ const DELETE_DEBOUNCE_MS = 20; // 仅 Delete 键用极短防抖
36
55
  // ✅ 粘贴处理 Hook:支持多行粘贴
37
56
  // v0.3 设计:Enter 始终发送, Alt+Enter(Mac: Option+Enter)换行, 粘贴期间拦截 Enter
38
57
  const { handleInput: handlePasteInput } = usePasteHandler({
@@ -61,15 +80,72 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
61
80
  return;
62
81
  }
63
82
  // pasteResult === 'continue',继续普通处理
83
+ // ---- ✅ Ctrl+R 反向搜索模式(独占按键流)----
84
+ if (searchMode !== null) {
85
+ // ESC 退出搜索
86
+ if (key.escape) {
87
+ setSearchMode(null);
88
+ return;
89
+ }
90
+ // Enter 选中并提交
91
+ if (key.return) {
92
+ if (searchMode.hit !== null) {
93
+ const text = searchMode.hit;
94
+ setSearchMode(null);
95
+ buf.clear();
96
+ forceRefresh();
97
+ hist.push(text);
98
+ hist.resetCursor();
99
+ onSubmit(text);
100
+ }
101
+ else {
102
+ setSearchMode(null);
103
+ }
104
+ return;
105
+ }
106
+ // Backspace 削 query;query 削空了退出
107
+ if (key.backspace || key.delete) {
108
+ const nextQuery = searchMode.query.slice(0, -1);
109
+ if (nextQuery.length === 0) {
110
+ setSearchMode(null);
111
+ return;
112
+ }
113
+ const nextHit = hist.search(nextQuery)[0] ?? null;
114
+ setSearchMode({ query: nextQuery, hit: nextHit });
115
+ return;
116
+ }
117
+ // Ctrl+C 退出搜索(不退出程序)
118
+ if (key.ctrl && input === 'c') {
119
+ setSearchMode(null);
120
+ return;
121
+ }
122
+ // 字面字符(排除控制键)追加到 query
123
+ if (input && !key.ctrl && !key.meta && !key.return && !key.escape) {
124
+ const nextQuery = searchMode.query + input;
125
+ const nextHit = hist.search(nextQuery)[0] ?? null;
126
+ setSearchMode({ query: nextQuery, hit: nextHit });
127
+ return;
128
+ }
129
+ // 其他按键在搜索模式下静默吞掉
130
+ return;
131
+ }
64
132
  // ---- ESC ----
65
133
  if (key.escape) {
66
134
  if (disabled)
67
135
  onAbort();
68
136
  return;
69
137
  }
138
+ // ---- ✅ Ctrl+R 进入反向搜索(仅在空 buf + 非 disabled)----
139
+ if (key.ctrl && input === 'r') {
140
+ if (!disabled && buf.state.text.length === 0) {
141
+ setSearchMode({ query: '', hit: null });
142
+ }
143
+ return;
144
+ }
70
145
  // ---- Ctrl+C ----
71
146
  if (key.ctrl && input === 'c') {
72
147
  if (buf.state.text.length > 0) {
148
+ flushImeBuffer();
73
149
  buf.clear();
74
150
  forceRefresh();
75
151
  return;
@@ -93,9 +169,13 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
93
169
  // - Ctrl+Enter:返回 'continue' → 这里也执行**强制发送**
94
170
  // - 粘贴期间 + Enter:返回 'enter-in-paste' → **忽略**(防误发)
95
171
  if (key.return) {
172
+ flushImeBuffer(); // 先提交残留 CJK 输入
96
173
  const text = buf.state.text.trim();
97
174
  if (text.length === 0)
98
175
  return;
176
+ // 入历史(所有非空提交都记,包括 slash 命令;连续重复 hist 内部去重)
177
+ hist.push(text);
178
+ hist.resetCursor();
99
179
  if (text === '/exit' || text === '/quit')
100
180
  exit();
101
181
  else if (text === '/new' || text === '/clear') {
@@ -133,11 +213,29 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
133
213
  return;
134
214
  }
135
215
  if (key.upArrow) {
216
+ // 首行按 ↑ → 翻历史;中间行 → 正常移动光标
217
+ if (buf.layout.cursorRow === 0) {
218
+ const prev = hist.prev();
219
+ if (prev !== null) {
220
+ buf.setText(prev);
221
+ forceRefresh();
222
+ }
223
+ return;
224
+ }
136
225
  buf.moveUp();
137
226
  forceRefresh();
138
227
  return;
139
228
  }
140
229
  if (key.downArrow) {
230
+ // 末行按 ↓ → 翻历史;中间行 → 正常移动光标
231
+ const lastRow = buf.layout.lines.length - 1;
232
+ if (buf.layout.cursorRow >= lastRow) {
233
+ const nxt = hist.next();
234
+ // next 返 null 表示走到 "新输入" 位 → 清空缓冲
235
+ buf.setText(nxt ?? '');
236
+ forceRefresh();
237
+ return;
238
+ }
141
239
  buf.moveDown();
142
240
  forceRefresh();
143
241
  return;
@@ -207,15 +305,54 @@ export function InputBox({ onSubmit, disabled, onAbort, onClear, onCompact }) {
207
305
  // Alt+Enter 在多数终端发 \x1b\r:Ink 把 \x1b 剥掉后 input='\r'、key.return=false、
208
306
  // key.meta=false,从这里落地。把 \r/\r\n 统一成 \n,textBuffer 才能真切分逻辑行
209
307
  // (否则 layout 永远 1 行 → ↑/↓ 失效、输入框不长高,光标只能左右)。
308
+ //
309
+ // IME 兼容优化(方案C:启发式过滤 + 延迟刷新):
310
+ // - ASCII 可打印字符 → 立即插入+刷新(英文打字无延迟)
311
+ // - 非 ASCII 字符(CJK/emoji)→ 累积到 imeBuf,50ms 后批量刷新
312
+ // (合并连续 CJK 输入,减少 Ink 整树 rerender 次数)
210
313
  if (input && !key.meta) {
211
314
  const normalized = input.replace(/\r\n?/g, '\n');
212
- buf.insert(normalized);
213
- forceRefresh();
315
+ if (normalized.length === 0)
316
+ return;
317
+ // ASCII 可打印 + 换行:立即处理
318
+ if (/^[\x20-\x7E\n]+$/.test(normalized)) {
319
+ flushImeBuffer(); // 先提交残留 CJK
320
+ buf.insert(normalized);
321
+ forceRefresh();
322
+ return;
323
+ }
324
+ // 非 ASCII(CJK / emoji / 特殊字符):累积后延迟刷新
325
+ imeBufRef.current += normalized;
326
+ if (!imeTimerRef.current) {
327
+ imeTimerRef.current = setTimeout(() => {
328
+ if (imeBufRef.current.length > 0) {
329
+ buf.insert(imeBufRef.current);
330
+ imeBufRef.current = '';
331
+ forceRefresh();
332
+ }
333
+ imeTimerRef.current = null;
334
+ }, 50);
335
+ }
214
336
  }
215
337
  });
338
+ // ✅ 搜索模式下覆盖整个 BufferView,显示 (reverse-i-search)`<query>': <hit>
339
+ if (searchMode !== null) {
340
+ return _jsx(ReverseSearchView, { query: searchMode.query, hit: searchMode.hit });
341
+ }
216
342
  // ✅ 传递 refreshCounter 作为 prop(不放入 key)
217
343
  return (_jsx(BufferView, { buf: buf, disabled: disabled, refreshCounter: refreshCounter }));
218
344
  }
345
+ // ---------- 反向搜索视图 ----------
346
+ function ReverseSearchView({ query, hit, }) {
347
+ // hit 可能含换行;仅展示首行避免撑爆 InputBox
348
+ const hitFirstLine = hit !== null ? hit.split('\n', 1)[0] : null;
349
+ const hitDisplay = hitFirstLine === null
350
+ ? '<no match>'
351
+ : hit !== null && hit.includes('\n')
352
+ ? `${hitFirstLine} …`
353
+ : hitFirstLine;
354
+ return (_jsxs(Box, { borderStyle: "round", borderColor: "magenta", paddingX: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "magenta", bold: true, children: '(reverse-i-search)`' }), _jsx(Text, { color: "yellow", children: query }), _jsx(Text, { color: "magenta", bold: true, children: "': " }), _jsx(Text, { color: hitFirstLine === null ? 'gray' : 'white', children: hitDisplay })] }), _jsx(Box, { children: _jsx(Text, { color: "gray", dimColor: true, children: "Enter \u63D0\u4EA4 \u00B7 ESC \u53D6\u6D88 \u00B7 Backspace \u5220\u5B57\u7B26" }) })] }));
355
+ }
219
356
  function BufferView({ buf, disabled, refreshCounter }) {
220
357
  // ✅ 关键:用 useMemo 依赖 refreshCounter,而非改变 key
221
358
  // 这样不会重建组件,只是强制重新计算 layout
@@ -1,8 +1,18 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  const MAX_TOOL_PREVIEW_LINES = 3;
4
- export function MessageList({ history, streamingText }) {
5
- return (_jsxs(Box, { flexDirection: "column", children: [history.map((m, i) => (_jsx(MessageRow, { message: m }, i))), streamingText.length > 0 && (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { children: streamingText }), _jsx(Text, { color: "gray", children: " \u258D" })] }))] }));
4
+ export function MessageList({ history, streamingText, streamingReasoning }) {
5
+ return (_jsxs(Box, { flexDirection: "column", children: [history.map((m, i) => (_jsx(MessageRow, { message: m }, i))), streamingReasoning.length > 0 && (_jsx(Box, { marginBottom: 1, flexDirection: "column", children: (() => {
6
+ const MAX_LINES = 5;
7
+ const THRESHOLD = 300;
8
+ if (streamingReasoning.length <= THRESHOLD) {
9
+ return (_jsxs(Text, { color: "gray", dimColor: true, children: ["\uD83D\uDCA1 ", streamingReasoning] }));
10
+ }
11
+ const allLines = streamingReasoning.split('\n');
12
+ const tailLines = allLines.slice(-MAX_LINES);
13
+ const omitted = allLines.length - MAX_LINES;
14
+ return (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "gray", dimColor: true, children: ["\uD83D\uDCA1 ... (", Math.round(streamingReasoning.length / 1000), "K\u5B57, \u7701\u7565 ", omitted, " \u884C)"] }), tailLines.slice(0, -1).map((line, i) => (_jsxs(Text, { color: "gray", dimColor: true, children: [' ', line || ' '] }, i))), _jsxs(Text, { color: "gray", dimColor: true, children: [' ', tailLines[tailLines.length - 1] || ' '] })] }));
15
+ })() })), streamingText.length > 0 && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: streamingText }) }))] }));
6
16
  }
7
17
  function MessageRow({ message }) {
8
18
  switch (message.role) {