minimal-agent 0.5.4 → 0.5.6

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.
@@ -5,7 +5,7 @@
5
5
  * 这个 hook 是 UI 层的核心:
6
6
  * - 维护 history 数组(用 ref 持久引用,version 计数器触发重渲染)
7
7
  * - 维护"当前正在打字"的 streamingText
8
- * - 维护"当前工具状态" toolStatus
8
+ * - 维护"工作过程显示"用的 progressStage / reasoningPreview / runningTools
9
9
  * - 暴露 submit() 给 InputBox 调用、abort() 给 Ctrl+C 处理
10
10
  *
11
11
  * 为什么 history 用 ref 而不是 state?
@@ -17,32 +17,199 @@
17
17
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
18
  import { getWorkingDir } from '../../bootstrap/workingDir.js';
19
19
  import { forceCompact } from '../../context/compact.js';
20
- import { clearContext } from '../../context/persistContext.js';
20
+ import { clearContext, loadContext, saveContext, } from '../../context/persistContext.js';
21
21
  import { resetReactiveCompactState } from '../../context/reactiveCompact.js';
22
+ import { getActiveSession, setActiveSession, } from '../../context/sessionRegistry.js';
23
+ import { resolveSessionFile, sessionFileFor, } from '../../context/sessionPath.js';
22
24
  import { buildFullSystemPrompt } from '../../prompts/system.js';
23
25
  import { ALL_TOOLS } from '../../tools/index.js';
24
26
  import { clearFileState } from '../../tools/shared/fileState.js';
25
27
  import { runWithPlugins } from '../../plugins/pluginRunner.js';
28
+ /**
29
+ * reasoningPreview 环形 buffer 容量(字符)。
30
+ * LiveArea 现在多行显示思维链尾部(最近 N 行),需要喂饱足够文本;
31
+ * 1200 字符约 15~20 行中文,LiveArea wrapToDisplayLines 后再取尾部最近 N 行。
32
+ * 上界由 LiveArea 的 contentMaxLines 兜底,这里只管"留够素材"。
33
+ */
34
+ const REASONING_PREVIEW_MAX_CHARS = 1200;
35
+ /**
36
+ * streamingText 环形 buffer 容量(字符)—— 仅用于 live 区"答案流式预览"。
37
+ *
38
+ * ★ 安全性:完整答案由 loop.ts 独立组装进 history(loop.ts 的 assistantText →
39
+ * assistantMsg.content,整条 push),streamingText 是**纯预览**,环形截断不丢
40
+ * 最终结果(落定后 <Static> 渲染完整 message.content 接管)。
41
+ * 截断保证长答案流式时 LiveArea 只需 wrap 末段、render 成本恒定。
42
+ */
43
+ const STREAM_PREVIEW_MAX_CHARS = 1500;
44
+ /**
45
+ * Streaming text setState 节流参数。
46
+ *
47
+ * Why throttle:LLM 流式输出 60+ chunk/秒,原本每个 delta 立即 setStreamingText
48
+ * 在 Windows PowerShell 终端下 Ink ANSI cursor 重绘跟不上,导致每个字打成新行
49
+ * (已确认根因,Plan agent 验证过)。这里用 leading-edge throttle:首个 delta
50
+ * 启动 100ms timer,期间所有 delta 累积到 ref,到点 flush 一次 setStreamingText。
51
+ *
52
+ * STREAM_FLUSH_MS : 100ms 节流(React rerender 频率 ≤ 10 Hz,叠加 Ink 内部
53
+ * 32ms throttle 后实际重画 ≤ 10 Hz,Windows 终端能跟上)
54
+ * STREAM_FLUSH_MAX_CHARS : 缓冲超 200 字符立即 flush(防长段中文 100ms 内塞太
55
+ * 多触发 React 大量 reconcile)
56
+ *
57
+ * Why not debounce:debounce 在 token 持续到达时永远刷新定时器,流式期间可能
58
+ * 完全不更新 UI,等于"看不见"。leading-edge throttle 保证稳定 10 Hz 出现新内容。
59
+ */
60
+ const STREAM_FLUSH_MS = 100;
61
+ const STREAM_FLUSH_MAX_CHARS = 200;
62
+ /** 按字符尾部截断(多字节字符 JS 字符串以 UTF-16 code unit 计,对中文已足够"差不多")。 */
63
+ function sliceLatest(s, maxChars) {
64
+ if (s.length <= maxChars)
65
+ return s;
66
+ return s.slice(s.length - maxChars);
67
+ }
68
+ function useThrottledFlush({ delayMs, maxChars, flushEmpty, onFlush, }) {
69
+ const bufferRef = useRef('');
70
+ const timerRef = useRef(null);
71
+ const flush = useCallback(() => {
72
+ if (timerRef.current) {
73
+ clearTimeout(timerRef.current);
74
+ timerRef.current = null;
75
+ }
76
+ const buffered = bufferRef.current;
77
+ bufferRef.current = '';
78
+ if (buffered.length === 0 && !flushEmpty)
79
+ return;
80
+ onFlush(buffered);
81
+ }, [flushEmpty, onFlush]);
82
+ const cancel = useCallback(() => {
83
+ if (timerRef.current) {
84
+ clearTimeout(timerRef.current);
85
+ timerRef.current = null;
86
+ }
87
+ bufferRef.current = '';
88
+ }, []);
89
+ const schedule = useCallback(() => {
90
+ if (timerRef.current !== null)
91
+ return;
92
+ timerRef.current = setTimeout(flush, delayMs);
93
+ }, [delayMs, flush]);
94
+ const append = useCallback((delta) => {
95
+ bufferRef.current += delta;
96
+ if (typeof maxChars === 'number' && bufferRef.current.length >= maxChars) {
97
+ flush();
98
+ return;
99
+ }
100
+ schedule();
101
+ }, [flush, maxChars, schedule]);
102
+ return useMemo(() => ({ append, flush, cancel, schedule }), [append, flush, cancel, schedule]);
103
+ }
26
104
  export function useChat(args) {
27
105
  // 历史用 ref(可变),用 version 触发 re-render
28
106
  const historyRef = useRef(args.initialHistory.slice());
29
107
  const [version, setVersion] = useState(0);
30
108
  const bump = useCallback(() => setVersion((v) => v + 1), []);
31
109
  const [streamingText, setStreamingText] = useState('');
32
- const [streamingReasoning, setStreamingReasoning] = useState('');
33
- const [toolStatus, setToolStatus] = useState(null);
34
110
  const [isLoading, setIsLoading] = useState(false);
35
111
  const [error, setError] = useState(null);
36
112
  const [interrupted, setInterrupted] = useState(false);
37
- const [compacting, setCompacting] = useState(false);
38
113
  const [pluginProgress, setPluginProgress] = useState(null);
114
+ // ---- 工作过程显示相关 state ----
115
+ const [compactGeneration, setCompactGeneration] = useState(0);
116
+ const [progressStage, setProgressStage] = useState('idle');
117
+ const [reasoningPreview, setReasoningPreview] = useState('');
118
+ /**
119
+ * 工具并行执行:以 callId 为 key 保存所有运行中工具。
120
+ * 用 Map 是为了 O(1) 增删;对外 memo 物化成稳定数组(Map 迭代顺序 = 插入顺序,
121
+ * 给 React 渲染天然稳定)。
122
+ */
123
+ const [runningToolsMap, setRunningToolsMap] = useState(new Map());
124
+ const runningTools = useMemo(() => Array.from(runningToolsMap.values()), [runningToolsMap]);
39
125
  const abortRef = useRef(null);
126
+ const streamingFlush = useThrottledFlush({
127
+ delayMs: STREAM_FLUSH_MS,
128
+ maxChars: STREAM_FLUSH_MAX_CHARS,
129
+ onFlush: useCallback((buffered) => {
130
+ setStreamingText((prev) => sliceLatest(prev + buffered, STREAM_PREVIEW_MAX_CHARS));
131
+ }, []),
132
+ });
133
+ // ---- reasoning preview setState 节流 ----
134
+ // 用户反馈思考阶段需要看到流式思维链。reasoning delta 频率跟 text delta 一样高
135
+ // (60+ chunk/秒),不节流的话每个 delta 立即 setReasoningPreview → LiveArea
136
+ // rerender → Ink 重绘扛不住。复用 streaming 一样的 100ms / 200 字符节流参数。
137
+ const reasoningFlush = useThrottledFlush({
138
+ delayMs: STREAM_FLUSH_MS,
139
+ maxChars: STREAM_FLUSH_MAX_CHARS,
140
+ onFlush: useCallback((buffered) => {
141
+ setReasoningPreview((prev) => sliceLatest(prev + buffered, REASONING_PREVIEW_MAX_CHARS));
142
+ }, []),
143
+ });
144
+ // ---- tool_start/end 的 bump 节流 ----
145
+ // tool_start / tool_end 单独触发 bump 会导致每个工具事件都是一次顶层 rerender;
146
+ // 200ms 内合并成单次 bump,避免高频"工具状态切换"压垮 Ink ANSI 重绘。
147
+ // 注意:assistant_message 走立即 bump(不节流),因为 history 增长时 Static 需要
148
+ // 立即 commit 新 item,不能延迟。
149
+ const toolBumpFlush = useThrottledFlush({
150
+ delayMs: 200,
151
+ flushEmpty: true,
152
+ onFlush: useCallback(() => {
153
+ bump();
154
+ }, [bump]),
155
+ });
156
+ /**
157
+ * 把 turn 结束 / 切换 session / clearHistory 时的"工作过程显示 state
158
+ * 归位"集中成一个函数。React 18 自动 batch 这一组 setState(同一个 microtask 内
159
+ * 调用),结果就是 1 次 commit。集中后调用点更清晰,也方便后续再优化。
160
+ */
161
+ const resetTurnState = useCallback(() => {
162
+ setStreamingText('');
163
+ setProgressStage('idle');
164
+ setReasoningPreview('');
165
+ setRunningToolsMap(new Map());
166
+ }, []);
167
+ /** turn 结束 / 中断 / 切换时一并取消所有节流 buffer 的 pending timer。 */
168
+ const cancelAllThrottles = useCallback(() => {
169
+ streamingFlush.cancel();
170
+ reasoningFlush.cancel();
171
+ toolBumpFlush.cancel();
172
+ }, [streamingFlush, reasoningFlush, toolBumpFlush]);
173
+ // ---- v2 multi-session ----
174
+ // activeSessionId 启动时同步默认 'default';mount 后异步读 active-sessions.json
175
+ // 更新为该 cwd 真正激活的 session。所有持久化路径都按 ref 取最新值,
176
+ // ref 比 state 顺手——switchSession 内同步切换 ref 后下一行立刻 saveContext 能用新值。
177
+ const activeSessionIdRef = useRef('default');
178
+ const [activeSessionId, setActiveSessionId] = useState('default');
179
+ /** 当前 active session 对应的持久化文件路径(同步,按 ref 取) */
180
+ const sessionFile = useCallback(() => {
181
+ return sessionFileFor(getWorkingDir(), activeSessionIdRef.current);
182
+ }, []);
40
183
  // 卸载时取消任何进行中的请求
41
184
  useEffect(() => {
42
185
  return () => {
43
186
  abortRef.current?.abort();
44
187
  };
45
188
  }, []);
189
+ // mount 时异步读 active session(默认 'default',注册表里有就用注册值)。
190
+ // 启动时初始 history 已经由 main.tsx/Root 走默认路径加载完了;
191
+ // 如果注册表说 active 不是 default,理论上 main.tsx 应该按 active 加载——
192
+ // 但当前 main.tsx 用的是 getContextPath() 走 default。为了兼容这个不一致,
193
+ // 此处把 ref/state 设为 active id 但不重新 loadContext —— 由用户手动 /session
194
+ // 切回正确 session(极少数 edge case,避免启动时异步加载导致界面闪烁)。
195
+ useEffect(() => {
196
+ let cancelled = false;
197
+ void (async () => {
198
+ try {
199
+ const id = await getActiveSession(getWorkingDir());
200
+ if (cancelled)
201
+ return;
202
+ activeSessionIdRef.current = id;
203
+ setActiveSessionId(id);
204
+ }
205
+ catch {
206
+ // 读失败保持 default,不影响功能
207
+ }
208
+ })();
209
+ return () => {
210
+ cancelled = true;
211
+ };
212
+ }, []);
46
213
  const submit = useCallback(async (input) => {
47
214
  if (isLoading)
48
215
  return;
@@ -52,53 +219,15 @@ export function useChat(args) {
52
219
  setIsLoading(true);
53
220
  setError(null);
54
221
  setInterrupted(false);
222
+ cancelAllThrottles();
55
223
  setStreamingText('');
56
224
  setPluginProgress(null);
225
+ // 进入新一轮,把工作过程显示相关 state 全部归位
226
+ setProgressStage('idle');
227
+ setReasoningPreview('');
228
+ setRunningToolsMap(new Map());
57
229
  const ac = new AbortController();
58
230
  abortRef.current = ac;
59
- let streamBuf = '';
60
- let reasoningBuf = '';
61
- let streamTimer = null;
62
- const throttledSetStreamingText = (value) => {
63
- streamBuf = typeof value === 'function' ? value(streamBuf) : value;
64
- if (!streamTimer) {
65
- streamTimer = setInterval(() => {
66
- if (streamBuf.length > 0) {
67
- setStreamingText(streamBuf);
68
- }
69
- if (reasoningBuf.length > 0) {
70
- setStreamingReasoning(reasoningBuf);
71
- }
72
- }, 200);
73
- }
74
- };
75
- const throttledSetStreamingReasoning = (value) => {
76
- reasoningBuf = typeof value === 'function' ? value(reasoningBuf) : value;
77
- if (!streamTimer) {
78
- streamTimer = setInterval(() => {
79
- if (streamBuf.length > 0) {
80
- setStreamingText(streamBuf);
81
- }
82
- if (reasoningBuf.length > 0) {
83
- setStreamingReasoning(reasoningBuf);
84
- }
85
- }, 200);
86
- }
87
- };
88
- const flushStream = () => {
89
- if (streamTimer) {
90
- clearInterval(streamTimer);
91
- streamTimer = null;
92
- }
93
- if (streamBuf.length > 0) {
94
- setStreamingText(streamBuf);
95
- streamBuf = '';
96
- }
97
- if (reasoningBuf.length > 0) {
98
- setStreamingReasoning(reasoningBuf);
99
- reasoningBuf = '';
100
- }
101
- };
102
231
  try {
103
232
  for await (const ev of runWithPlugins(trimmed, {
104
233
  provider: args.provider,
@@ -106,13 +235,24 @@ export function useChat(args) {
106
235
  signal: ac.signal,
107
236
  })) {
108
237
  handleEvent(ev, {
109
- setStreamingText: throttledSetStreamingText,
110
- setStreamingReasoning: throttledSetStreamingReasoning,
111
- setToolStatus,
112
- setCompacting,
238
+ setStreamingText,
113
239
  setError,
114
240
  setInterrupted,
115
241
  setPluginProgress,
242
+ setCompactGeneration,
243
+ setProgressStage,
244
+ setReasoningPreview,
245
+ setRunningToolsMap,
246
+ // 节流相关 —— buffer 累积 + 100ms timer flush
247
+ appendStreamingDelta: streamingFlush.append,
248
+ flushStreamingBuffer: streamingFlush.flush,
249
+ cancelStreamingFlush: streamingFlush.cancel,
250
+ // reasoning preview 节流 + 落定清空
251
+ appendReasoningDelta: reasoningFlush.append,
252
+ cancelReasoningFlush: reasoningFlush.cancel,
253
+ // tool_start/end 节流 bump
254
+ scheduleToolBump: toolBumpFlush.schedule,
255
+ cancelToolBump: toolBumpFlush.cancel,
116
256
  bump,
117
257
  });
118
258
  }
@@ -121,20 +261,48 @@ export function useChat(args) {
121
261
  setError(`未捕获异常:${e.message}`);
122
262
  }
123
263
  finally {
124
- flushStream();
125
264
  setIsLoading(false);
126
- setStreamingText('');
127
- setStreamingReasoning('');
128
- setToolStatus(null);
129
- setCompacting(false);
265
+ cancelAllThrottles();
266
+ resetTurnState();
130
267
  setPluginProgress(null);
131
268
  abortRef.current = null;
132
- args.onPersist?.(historyRef.current);
269
+ // v2 multi-session:持久化到当前 active session 文件。
270
+ saveContext(historyRef.current, sessionFile()).catch(() => { });
133
271
  }
134
- }, [args.provider, args.onPersist, bump, isLoading]);
272
+ }, [
273
+ args.provider,
274
+ bump,
275
+ isLoading,
276
+ sessionFile,
277
+ streamingFlush,
278
+ cancelAllThrottles,
279
+ reasoningFlush,
280
+ toolBumpFlush,
281
+ resetTurnState,
282
+ ]);
135
283
  const abort = useCallback(() => {
136
284
  abortRef.current?.abort();
137
285
  }, []);
286
+ /**
287
+ * v2.x 新增:把一段临时提示文本作为 _ephemeral 的 system 消息插入 history。
288
+ *
289
+ * 用途:替代 slash 命令分支直接 process.stdout.write 旁路 Ink 的写法
290
+ * (那种写法每次都让 ink log-update 的 previousLineCount 失步,
291
+ * 下一帧 eraseLines 会留下未擦行)。
292
+ *
293
+ * 流程:push 进 historyRef → bump() 触发 history useMemo 重算 →
294
+ * MessageList <Static> 把它作为新 item commit 进 scrollback。
295
+ * saveContext 写盘时过滤掉这些 ephemeral 消息。
296
+ */
297
+ const ephemeralLine = useCallback((text) => {
298
+ historyRef.current.push({
299
+ role: 'system',
300
+ content: text,
301
+ _ephemeral: true,
302
+ timestamp: Date.now(),
303
+ });
304
+ bump();
305
+ }, [bump]);
138
306
  /**
139
307
  * /new —— 清空历史,重新生成 system prompt(带最新 skills + 项目指令)。
140
308
  * 不复用启动时的初始 history 快照,避免把磁盘里的损坏数据带回来。
@@ -150,19 +318,20 @@ export function useChat(args) {
150
318
  historyRef.current.push({ role: 'system', content: newSystemPrompt });
151
319
  // 允许新 session 再次触发反应式压缩自救
152
320
  resetReactiveCompactState();
153
- setStreamingText('');
154
- setStreamingReasoning('');
155
- setToolStatus(null);
321
+ cancelAllThrottles();
156
322
  setError(null);
157
323
  setInterrupted(false);
158
- setCompacting(false);
324
+ // 清空工作过程显示状态,避免 /new 后旧 turn 残留闪现
325
+ resetTurnState();
326
+ // 强制 MessageList 内 <Static> remount,避免旧消息卡在屏幕上
327
+ setCompactGeneration((g) => g + 1);
159
328
  bump();
160
329
  // 清空磁盘上的持久化文件(防止下次启动又加载损坏数据)
161
330
  await clearContext();
162
331
  // 清空 pre-read guard 状态表(防止跨 session 的 Read 记录污染新会话)
163
332
  clearFileState();
164
- // 注意:这里不调用 onPersist,因为我们刚刚清空了,没必要再保存一次"空历史"
165
- }, [bump, isLoading]);
333
+ // 刚清空,不需要再保存一次"空历史"
334
+ }, [bump, isLoading, cancelAllThrottles, resetTurnState]);
166
335
  /**
167
336
  * /compact —— 手动跑一次压缩(不看阈值)。
168
337
  * isLoading 时拒绝执行;压缩自身的 setIsLoading(true) 会让 InputBox 显示 ⏳。
@@ -173,13 +342,18 @@ export function useChat(args) {
173
342
  if (isLoading)
174
343
  return;
175
344
  setIsLoading(true);
176
- setCompacting(true);
345
+ // 手动压缩仍然把 progressStage 切到 compacting,给 LiveArea 显示用
346
+ setProgressStage('compacting');
177
347
  setError(null);
348
+ // 压缩前清掉任何待 flush 的 streaming buffer(避免压缩中 timer 触发干扰)
349
+ cancelAllThrottles();
178
350
  let success = false;
179
351
  try {
180
352
  const r = await forceCompact(historyRef.current, args.provider);
181
353
  historyRef.current.length = 0;
182
354
  historyRef.current.push(...r.messages);
355
+ // 压缩后 MessageList 要 remount,丢掉旧 <Static> 缓存
356
+ setCompactGeneration((g) => g + 1);
183
357
  bump();
184
358
  success = true; // ✅ 标记成功
185
359
  }
@@ -188,78 +362,198 @@ export function useChat(args) {
188
362
  // ❌ 不修改 historyRef.current,保持原样(不保存可能损坏的数据)
189
363
  }
190
364
  finally {
191
- setCompacting(false);
365
+ setProgressStage('idle');
192
366
  setIsLoading(false);
193
367
  // ✅ 只在成功时才持久化,避免保存损坏的历史到磁盘
194
368
  if (success) {
195
- args.onPersist?.(historyRef.current);
369
+ // v2 multi-session:持久化到当前 active session 文件
370
+ saveContext(historyRef.current, sessionFile()).catch(() => { });
196
371
  args.onCompactDone?.();
197
372
  }
198
373
  }
199
- }, [args.provider, args.onPersist, args.onCompactDone, bump, isLoading]);
200
- // history 用 useMemo 托管:version 变化时重新计算,返回当前 historyRef.current
201
- // 这样任何导致 version++ 的操作(submit/clearHistory/compactNow/工具回调 bump)都会
202
- // 让所有消费 history 的组件(MessageList/StatusLine/ToolStatus)自动收到新引用、触发 re-render。
203
- const history = useMemo(() => historyRef.current, [version]);
374
+ }, [
375
+ args.provider,
376
+ args.onCompactDone,
377
+ bump,
378
+ isLoading,
379
+ sessionFile,
380
+ cancelAllThrottles,
381
+ ]);
382
+ /**
383
+ * /session <id> —— 切换到指定 session(同 cwd 下)。
384
+ *
385
+ * 流程:
386
+ * 1) saveContext 保住当前 session 的最新历史
387
+ * 2) setActiveSession 更新注册表
388
+ * 3) 改 activeSessionIdRef + state(让 UI 显示 / 持久化路径都走新 id)
389
+ * 4) resolveSessionFile + loadContext 读新 session 历史(不存在则新建空)
390
+ * 5) 替换 historyRef.current(保留 system prompt 在头部)
391
+ * 6) bump + setCompactGeneration 触发 Static remount
392
+ *
393
+ * isLoading 时拒绝执行,避免与 runQuery 抢状态。
394
+ */
395
+ const switchSession = useCallback(async (id, opts) => {
396
+ if (isLoading)
397
+ return;
398
+ const trimmed = id.trim();
399
+ if (trimmed.length === 0)
400
+ return;
401
+ // force=true 时即便同 id 也强制重新加载(/restore 覆盖文件后用)
402
+ if (!opts?.force && trimmed === activeSessionIdRef.current)
403
+ return;
404
+ const cwd = getWorkingDir();
405
+ // 1. 保住当前 session
406
+ try {
407
+ await saveContext(historyRef.current, sessionFileFor(cwd, activeSessionIdRef.current));
408
+ }
409
+ catch {
410
+ // 保存失败不阻塞切换(用户可能就是想切走止损)
411
+ }
412
+ // 2. 更新注册表
413
+ try {
414
+ await setActiveSession(cwd, trimmed);
415
+ }
416
+ catch {
417
+ // 注册失败不阻塞切换
418
+ }
419
+ // 3. 切 ref + state
420
+ activeSessionIdRef.current = trimmed;
421
+ setActiveSessionId(trimmed);
422
+ // 4. 加载新 session 历史(resolveSessionFile 支持 v1 → v2 路径兜底)
423
+ let loaded = null;
424
+ try {
425
+ const newPath = await resolveSessionFile(cwd, trimmed);
426
+ loaded = await loadContext(newPath);
427
+ }
428
+ catch {
429
+ loaded = null;
430
+ }
431
+ // 5. 替换 historyRef,保留启动时的 system prompt 在头部
432
+ const systemMsg = historyRef.current.find((m) => m.role === 'system');
433
+ historyRef.current.length = 0;
434
+ if (systemMsg)
435
+ historyRef.current.push(systemMsg);
436
+ if (loaded && loaded.length > 0) {
437
+ // loaded 可能含旧 system prompt(旧版 last-context.json 保存了一份),跳过
438
+ const rest = loaded[0]?.role === 'system' ? loaded.slice(1) : loaded;
439
+ historyRef.current.push(...rest);
440
+ }
441
+ // 6. 清掉中间态、触发 Static remount
442
+ cancelAllThrottles();
443
+ setError(null);
444
+ setInterrupted(false);
445
+ resetTurnState();
446
+ setCompactGeneration((g) => g + 1);
447
+ bump();
448
+ }, [bump, isLoading, cancelAllThrottles, resetTurnState]);
449
+ // history 用 useMemo 托管:version 变化时返回 historyRef.current 的浅拷贝。
450
+ // 为什么必须 .slice():runQuery 内部 mutate 同一个数组(push 新消息),如果这里
451
+ // 返回同一引用,Ink <Static items={history}> 浅比较看不到 prop 变化 → 不 commit
452
+ // 新增 message → turn 结束 LiveArea 消失后屏幕空白,看起来像"输出被吃掉"。
453
+ // slice 后元素引用不变,只是数组容器是新的,足够让 Static 检测到 length 增长并 commit。
454
+ const history = useMemo(() => historyRef.current.slice(), [version]);
204
455
  return {
205
456
  history,
206
457
  streamingText,
207
- streamingReasoning,
208
- toolStatus,
209
458
  isLoading,
210
459
  error,
211
460
  interrupted,
212
- compacting,
213
461
  pluginProgress,
462
+ compactGeneration,
463
+ progressStage,
464
+ reasoningPreview,
465
+ runningTools,
466
+ activeSessionId,
214
467
  submit,
215
468
  abort,
216
469
  clearHistory,
217
470
  compactNow,
471
+ switchSession,
472
+ ephemeralLine,
218
473
  };
219
474
  }
220
- /**
221
- * 把一个事件派发到对应的 setState。
222
- *
223
- * 接受 LoopEvent(框架已识别)或 PluginEvent(插件私有,type 可为任意字符串)。
224
- * switch 只匹配 LoopEvent 字面量 type;未识别走 default no-op,等同静默忽略。
225
- */
226
- function handleEvent(ev, setters) {
475
+ export function handleEvent(ev, setters) {
227
476
  // 收口到 LoopEvent:未识别的 PluginEvent type 走 default no-op
228
477
  const e = ev;
229
478
  switch (e.type) {
230
- case 'text':
231
- setters.setStreamingText(prev => prev + e.delta);
479
+ case 'user_message_committed':
480
+ // loop 刚把用户消息 push 进 history。立即 bump → history useMemo 重算 →
481
+ // <Static> 把用户消息 commit 进 scrollback,第一时间置顶(T-A-O-R)。
482
+ setters.bump();
232
483
  break;
233
- case 'reasoning_delta':
234
- if (e.delta) {
235
- setters.setStreamingReasoning(prev => prev + e.delta);
236
- }
484
+ case 'text':
485
+ setters.appendStreamingDelta(e.delta);
237
486
  break;
238
487
  case 'assistant_message':
239
- setters.setStreamingText('');
240
- setters.setStreamingReasoning('');
488
+ // history 已被 runQuery 直接 push。先 flush 残留 streaming buffer
489
+ // 把末段 text 落到 streamingText state(avoid 视觉跳跃),再清空
490
+ // streamingText —— Static 已 commit 完整 assistant message 接管显示。
491
+ setters.flushStreamingBuffer();
492
+ setters.setStreamingText(() => '');
493
+ // 该段 reasoning 已随 assistant 消息进 history → MessageList 提交进 scrollback(💭 块)。
494
+ // 清空 Zone B 的 live 预览:① 本轮 streaming 阶段不再回显旧 reasoning;
495
+ // ② 多轮工具调用时,下一轮 thinking 从空白起步,不残留上一轮思维链。
496
+ setters.cancelReasoningFlush();
497
+ setters.setReasoningPreview(() => '');
498
+ setters.cancelToolBump();
499
+ setters.setProgressStage('idle');
500
+ setters.setRunningToolsMap(() => new Map());
241
501
  setters.bump();
242
502
  break;
243
503
  case 'tool_start':
244
- setters.setToolStatus({
245
- toolName: e.toolName,
246
- argsPreview: e.argsPreview,
247
- status: 'running',
504
+ // 把工具加入运行集合;并行多个工具时这里会累积。
505
+ // v2: argsFriendly 是 loop.ts 通过 LoopEvent 扩展字段传过来的人话描述
506
+ // (如 "Reading src/foo.ts")。LoopEvent 类型上没声明此字段时通过
507
+ // (e as { argsFriendly?: string }) 安全读取,缺省 fallback 在 UI 层。
508
+ setters.setRunningToolsMap((prev) => {
509
+ const next = new Map(prev);
510
+ next.set(e.toolCallId, {
511
+ toolName: e.toolName,
512
+ argsPreview: e.argsPreview,
513
+ status: 'running',
514
+ argsFriendly: e.argsFriendly,
515
+ });
516
+ return next;
248
517
  });
249
- setters.bump();
518
+ // 工具状态切换走 200ms 节流,避免连续 tool_start/end 高频 rerender
519
+ // 压垮 Ink ANSI 重绘。
520
+ setters.scheduleToolBump();
250
521
  break;
251
522
  case 'tool_end':
252
- setters.setToolStatus(null);
523
+ // 仅删除对应 callId 的 entry;其余并行工具继续
524
+ setters.setRunningToolsMap((prev) => {
525
+ if (!prev.has(e.toolCallId))
526
+ return prev;
527
+ const next = new Map(prev);
528
+ next.delete(e.toolCallId);
529
+ return next;
530
+ });
531
+ setters.scheduleToolBump();
532
+ break;
533
+ case 'tool_messages_committed':
534
+ setters.cancelStreamingFlush();
535
+ setters.setStreamingText(() => '');
536
+ setters.cancelReasoningFlush();
537
+ setters.setReasoningPreview(() => '');
538
+ setters.cancelToolBump();
539
+ setters.setProgressStage('idle');
540
+ setters.setRunningToolsMap(() => new Map());
253
541
  setters.bump();
254
542
  break;
255
543
  case 'compact_start':
256
- setters.setStreamingText('');
257
- setters.setStreamingReasoning('');
258
- setters.setCompacting(true);
544
+ // reactive 自救场景:LLM 失败前可能已经 yield 过部分 text delta,
545
+ // 压缩前清掉残留 streamingText + 待 flush buffer,避免重发后新文本追加在
546
+ // 旧 buffer 后面。autoCompact 场景下 streamingText 本来就是空。
547
+ // 注意:progressStage 由 loop 端的 stage_change 'compacting' 负责切换。
548
+ setters.cancelStreamingFlush();
549
+ setters.setStreamingText(() => '');
550
+ setters.cancelReasoningFlush();
551
+ setters.setReasoningPreview(() => '');
259
552
  setters.bump();
260
553
  break;
261
554
  case 'compact_done':
262
- setters.setCompacting(false);
555
+ // 触发 MessageList 内 <Static> remount,丢弃旧消息缓存
556
+ setters.setCompactGeneration((g) => g + 1);
263
557
  setters.bump();
264
558
  break;
265
559
  case 'turn_done':
@@ -267,14 +561,10 @@ function handleEvent(ev, setters) {
267
561
  break;
268
562
  case 'interrupted':
269
563
  setters.setInterrupted(true);
270
- setters.setStreamingText('');
271
- setters.setStreamingReasoning('');
272
564
  setters.bump();
273
565
  break;
274
566
  case 'error':
275
567
  setters.setError(e.error);
276
- setters.setStreamingText('');
277
- setters.setStreamingReasoning('');
278
568
  setters.bump();
279
569
  break;
280
570
  case 'plugin_progress':
@@ -286,5 +576,17 @@ function handleEvent(ev, setters) {
286
576
  });
287
577
  setters.bump();
288
578
  break;
579
+ case 'reasoning':
580
+ // reasoning delta 频率跟 text delta 一样高(60+/秒),不节流的话每个 delta 立即
581
+ // setReasoningPreview → LiveArea rerender → Ink 重绘扛不住。
582
+ setters.appendReasoningDelta(e.delta);
583
+ // 不调用 bump:reasoningPreview 是 LiveArea 单独的 state,history 没变化,
584
+ // 避免高频 reasoning token 引起 MessageList re-render
585
+ break;
586
+ case 'stage_change':
587
+ // 注意:tool_executing → streaming 切回时不清 runningTools,
588
+ // 工具集合的清空时机是各自 tool_end
589
+ setters.setProgressStage(e.stage);
590
+ break;
289
591
  }
290
592
  }