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/package.json +1 -1
- package/src/context/archive.js +117 -0
- package/src/context/persistContext.js +16 -0
- package/src/context/sessionPath.js +59 -4
- package/src/context/sessionRegistry.js +104 -0
- package/src/loop.js +204 -32
- package/src/prompts/system.js +11 -0
- package/src/ui/App.js +2 -2
- package/src/ui/InputBox.js +142 -5
- package/src/ui/MessageList.js +13 -3
- package/src/ui/ProgressPanel.js +98 -0
- package/src/ui/StreamingBlock.js +11 -0
- package/src/ui/ToolStatus.js +2 -2
- package/src/ui/hooks/useChat.js +64 -8
- package/src/ui/hooks/useInputHistory.js +186 -0
- package/src/ui/hooks/useTerminalSize.js +31 -0
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.
|
|
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
|
-
//
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
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: '
|
|
168
|
-
|
|
299
|
+
role: 'tool',
|
|
300
|
+
tool_call_id: tc.id,
|
|
301
|
+
content: `Error: ${errMsg}`,
|
|
302
|
+
timestamp: Date.now(),
|
|
169
303
|
});
|
|
170
|
-
|
|
171
|
-
return;
|
|
304
|
+
continue;
|
|
172
305
|
}
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 };
|
package/src/prompts/system.js
CHANGED
|
@@ -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
|
}
|
package/src/ui/InputBox.js
CHANGED
|
@@ -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
|
-
// ✅
|
|
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
|
-
|
|
213
|
-
|
|
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
|
package/src/ui/MessageList.js
CHANGED
|
@@ -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))),
|
|
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) {
|