/**
 * ============================================================
 *  src/ui/hooks/useChat.ts —— 把 loop.runQuery 桥接到 React 状态
 * ------------------------------------------------------------
 *  这个 hook 是 UI 层的核心：
 *    - 维护 history 数组（用 ref 持久引用，version 计数器触发重渲染）
 *    - 维护"当前正在打字"的 streamingText
 *    - 维护"工作过程显示"用的 progressStage / reasoningPreview / runningTools
 *    - 暴露 submit() 给 InputBox 调用、abort() 给 Ctrl+C 处理
 *
 *  为什么 history 用 ref 而不是 state？
 *    runQuery 内部会 mutate history 数组（push 新消息、压缩时清空重建），
 *    React state 的"不可变更新"规则不适合。用 ref 持有可变引用，
 *    用 version 计数器驱动 setState 触发 re-render 是更顺手的写法。
 * ============================================================
 */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getWorkingDir } from '../../bootstrap/workingDir.js';
import { forceCompact } from '../../context/compact.js';
import { clearContext, loadContext, saveContext, } from '../../context/persistContext.js';
import { resetReactiveCompactState } from '../../context/reactiveCompact.js';
import { getActiveSession, setActiveSession, } from '../../context/sessionRegistry.js';
import { resolveSessionFile, sessionFileFor, } from '../../context/sessionPath.js';
import { buildFullSystemPrompt } from '../../prompts/system.js';
import { ALL_TOOLS } from '../../tools/index.js';
import { setAskHandler, } from '../../askBridge.js';
import { clearFileState } from '../../tools/shared/fileState.js';
import { runWithPlugins } from '../../plugins/pluginRunner.js';
import { dlog } from '../../utils/debugLog.js';
/**
 * reasoningPreview 环形 buffer 容量（字符）。
 * LiveArea 现在多行显示思维链尾部（最近 N 行），需要喂饱足够文本；
 * 1200 字符约 15~20 行中文，LiveArea wrapToDisplayLines 后再取尾部最近 N 行。
 * 上界由 LiveArea 的 contentMaxLines 兜底，这里只管"留够素材"。
 */
const REASONING_PREVIEW_MAX_CHARS = 1200;
/**
 * streamingText 环形 buffer 容量（字符）—— 仅用于 live 区"答案流式预览"。
 *
 * ★ 安全性：完整答案由 loop.ts 独立组装进 history（loop.ts 的 assistantText →
 *   assistantMsg.content，整条 push），streamingText 是**纯预览**，环形截断不丢
 *   最终结果（落定后 <Static> 渲染完整 message.content 接管）。
 *   截断保证长答案流式时 LiveArea 只需 wrap 末段、render 成本恒定。
 */
const STREAM_PREVIEW_MAX_CHARS = 1500;
/**
 * Streaming text setState 节流参数。
 *
 * Why throttle：LLM 流式输出 60+ chunk/秒，原本每个 delta 立即 setStreamingText
 * 在 Windows PowerShell 终端下 Ink ANSI cursor 重绘跟不上，导致每个字打成新行
 * （已确认根因，Plan agent 验证过）。这里用 leading-edge throttle：首个 delta
 * 启动 100ms timer，期间所有 delta 累积到 ref，到点 flush 一次 setStreamingText。
 *
 *   STREAM_FLUSH_MS    : 100ms 节流（React rerender 频率 ≤ 10 Hz，叠加 Ink 内部
 *                        32ms throttle 后实际重画 ≤ 10 Hz，Windows 终端能跟上）
 *   STREAM_FLUSH_MAX_CHARS : 缓冲超 200 字符立即 flush（防长段中文 100ms 内塞太
 *                        多触发 React 大量 reconcile）
 *
 * Why not debounce：debounce 在 token 持续到达时永远刷新定时器，流式期间可能
 * 完全不更新 UI，等于"看不见"。leading-edge throttle 保证稳定 10 Hz 出现新内容。
 */
const STREAM_FLUSH_MS = 100;
const STREAM_FLUSH_MAX_CHARS = 200;
/** 按字符尾部截断（多字节字符 JS 字符串以 UTF-16 code unit 计，对中文已足够"差不多"）。 */
function sliceLatest(s, maxChars) {
    if (s.length <= maxChars)
        return s;
    return s.slice(s.length - maxChars);
}
function useThrottledFlush({ delayMs, maxChars, flushEmpty, onFlush, }) {
    const bufferRef = useRef('');
    const timerRef = useRef(null);
    const flush = useCallback(() => {
        if (timerRef.current) {
            clearTimeout(timerRef.current);
            timerRef.current = null;
        }
        const buffered = bufferRef.current;
        bufferRef.current = '';
        if (buffered.length === 0 && !flushEmpty)
            return;
        onFlush(buffered);
    }, [flushEmpty, onFlush]);
    const cancel = useCallback(() => {
        if (timerRef.current) {
            clearTimeout(timerRef.current);
            timerRef.current = null;
        }
        bufferRef.current = '';
    }, []);
    const schedule = useCallback(() => {
        if (timerRef.current !== null)
            return;
        timerRef.current = setTimeout(flush, delayMs);
    }, [delayMs, flush]);
    const append = useCallback((delta) => {
        bufferRef.current += delta;
        if (typeof maxChars === 'number' && bufferRef.current.length >= maxChars) {
            flush();
            return;
        }
        schedule();
    }, [flush, maxChars, schedule]);
    return useMemo(() => ({ append, flush, cancel, schedule }), [append, flush, cancel, schedule]);
}
export function useChat(args) {
    // 历史用 ref（可变），用 version 触发 re-render
    const historyRef = useRef(args.initialHistory.slice());
    const [version, setVersion] = useState(0);
    const bump = useCallback(() => setVersion((v) => v + 1), []);
    const [streamingText, setStreamingText] = useState('');
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState(null);
    const [interrupted, setInterrupted] = useState(false);
    const [pluginProgress, setPluginProgress] = useState(null);
    // ---- 工作过程显示相关 state ----
    const [compactGeneration, setCompactGeneration] = useState(0);
    const [progressStage, setProgressStage] = useState('idle');
    const [reasoningPreview, setReasoningPreview] = useState('');
    /**
     * 工具并行执行：以 callId 为 key 保存所有运行中工具。
     * 用 Map 是为了 O(1) 增删；对外 memo 物化成稳定数组（Map 迭代顺序 = 插入顺序，
     * 给 React 渲染天然稳定）。
     */
    const [runningToolsMap, setRunningToolsMap] = useState(new Map());
    const runningTools = useMemo(() => Array.from(runningToolsMap.values()), [runningToolsMap]);
    const abortRef = useRef(null);
    // ---- AskUserQuestion：执行中向用户提问 ----
    // pendingAsk 非 null 时 App 渲染 <AskQuestion> 独占输入（不渲染 InputBox）。
    // askResolverRef 持当前提问的 resolve/reject —— 用户答完 answerAsk 调 resolve，
    // 让 askBridge.requestUserChoice 的 Promise 落定、工具 call() 返回、执行继续。
    const [pendingAsk, setPendingAsk] = useState(null);
    const askResolverRef = useRef(null);
    const streamingFlush = useThrottledFlush({
        delayMs: STREAM_FLUSH_MS,
        maxChars: STREAM_FLUSH_MAX_CHARS,
        onFlush: useCallback((buffered) => {
            setStreamingText((prev) => sliceLatest(prev + buffered, STREAM_PREVIEW_MAX_CHARS));
        }, []),
    });
    // ---- reasoning preview setState 节流 ----
    // 用户反馈思考阶段需要看到流式思维链。reasoning delta 频率跟 text delta 一样高
    // （60+ chunk/秒），不节流的话每个 delta 立即 setReasoningPreview → LiveArea
    // rerender → Ink 重绘扛不住。复用 streaming 一样的 100ms / 200 字符节流参数。
    const reasoningFlush = useThrottledFlush({
        delayMs: STREAM_FLUSH_MS,
        maxChars: STREAM_FLUSH_MAX_CHARS,
        onFlush: useCallback((buffered) => {
            setReasoningPreview((prev) => sliceLatest(prev + buffered, REASONING_PREVIEW_MAX_CHARS));
        }, []),
    });
    // ---- tool_start/end 的 bump 节流 ----
    // tool_start / tool_end 单独触发 bump 会导致每个工具事件都是一次顶层 rerender；
    // 200ms 内合并成单次 bump，避免高频"工具状态切换"压垮 Ink ANSI 重绘。
    // 注意：assistant_message 走立即 bump（不节流），因为 history 增长时 Static 需要
    // 立即 commit 新 item，不能延迟。
    const toolBumpFlush = useThrottledFlush({
        delayMs: 200,
        flushEmpty: true,
        onFlush: useCallback(() => {
            bump();
        }, [bump]),
    });
    /**
     * 把 turn 结束 / 切换 session / clearHistory 时的"工作过程显示 state
     * 归位"集中成一个函数。React 18 自动 batch 这一组 setState（同一个 microtask 内
     * 调用），结果就是 1 次 commit。集中后调用点更清晰，也方便后续再优化。
     */
    const resetTurnState = useCallback(() => {
        setStreamingText('');
        setProgressStage('idle');
        setReasoningPreview('');
        setRunningToolsMap(new Map());
    }, []);
    /** turn 结束 / 中断 / 切换时一并取消所有节流 buffer 的 pending timer。 */
    const cancelAllThrottles = useCallback(() => {
        streamingFlush.cancel();
        reasoningFlush.cancel();
        toolBumpFlush.cancel();
    }, [streamingFlush, reasoningFlush, toolBumpFlush]);
    // ---- v2 multi-session ----
    // activeSessionId 启动时同步默认 'default'；mount 后异步读 active-sessions.json
    // 更新为该 cwd 真正激活的 session。所有持久化路径都按 ref 取最新值，
    // ref 比 state 顺手——switchSession 内同步切换 ref 后下一行立刻 saveContext 能用新值。
    const activeSessionIdRef = useRef('default');
    const [activeSessionId, setActiveSessionId] = useState('default');
    /** 当前 active session 对应的持久化文件路径（同步，按 ref 取） */
    const sessionFile = useCallback(() => {
        return sessionFileFor(getWorkingDir(), activeSessionIdRef.current);
    }, []);
    // 卸载时取消任何进行中的请求
    useEffect(() => {
        return () => {
            abortRef.current?.abort();
        };
    }, []);
    // mount 时把「弹选择界面」的处理器注册到 askBridge；unmount 注销。
    // 工具 isConcurrencySafe:false + workflow 串行 → 同一时刻至多一个 pending，单槽即可。
    // signal abort（用户 ESC / 中断）时 reject 并清掉 pending，让工具按「失败/取消」收尾。
    useEffect(() => {
        setAskHandler((questions, signal) => {
            return new Promise((resolve, reject) => {
                askResolverRef.current?.reject(new Error('被新的提问取代'));
                askResolverRef.current = { resolve, reject };
                dlog('ui', 'ask handler INVOKED (等待用户答题)', {
                    n: questions.length,
                    headers: questions.map((q) => q.header),
                });
                setPendingAsk(questions);
                const onAbort = () => {
                    if (!askResolverRef.current)
                        return;
                    askResolverRef.current = null;
                    setPendingAsk(null);
                    reject(new Error('已被用户中断'));
                };
                if (signal?.aborted) {
                    onAbort();
                    return;
                }
                signal?.addEventListener('abort', onAbort, { once: true });
            });
        });
        return () => setAskHandler(null);
    }, []);
    /** 用户答完当前提问：清 pending + resolve 工具侧 Promise，执行继续。 */
    const answerAsk = useCallback((answers) => {
        const r = askResolverRef.current;
        askResolverRef.current = null;
        setPendingAsk(null);
        dlog('ui', 'ask ANSWERED (继续执行)', { selected: answers.map((a) => a.selected) });
        r?.resolve(answers);
    }, []);
    // mount 时异步读 active session（默认 'default'，注册表里有就用注册值）。
    // 启动时初始 history 已经由 main.tsx/Root 走默认路径加载完了；
    // 如果注册表说 active 不是 default，理论上 main.tsx 应该按 active 加载——
    // 但当前 main.tsx 用的是 getContextPath() 走 default。为了兼容这个不一致，
    // 此处把 ref/state 设为 active id 但不重新 loadContext —— 由用户手动 /session
    // 切回正确 session（极少数 edge case，避免启动时异步加载导致界面闪烁）。
    useEffect(() => {
        let cancelled = false;
        void (async () => {
            try {
                const id = await getActiveSession(getWorkingDir());
                if (cancelled)
                    return;
                activeSessionIdRef.current = id;
                setActiveSessionId(id);
            }
            catch {
                // 读失败保持 default，不影响功能
            }
        })();
        return () => {
            cancelled = true;
        };
    }, []);
    const submit = useCallback(async (input) => {
        if (isLoading)
            return;
        const trimmed = input.trim();
        if (trimmed.length === 0)
            return;
        setIsLoading(true);
        setError(null);
        setInterrupted(false);
        cancelAllThrottles();
        setStreamingText('');
        setPluginProgress(null);
        // 进入新一轮，把工作过程显示相关 state 全部归位
        setProgressStage('idle');
        setReasoningPreview('');
        setRunningToolsMap(new Map());
        const ac = new AbortController();
        abortRef.current = ac;
        dlog('ui', 'turn START', { input: trimmed.slice(0, 80) });
        try {
            for await (const ev of runWithPlugins(trimmed, {
                provider: args.provider,
                history: historyRef.current,
                signal: ac.signal,
            })) {
                handleEvent(ev, {
                    setStreamingText,
                    setError,
                    setInterrupted,
                    setPluginProgress,
                    setCompactGeneration,
                    setProgressStage,
                    setReasoningPreview,
                    setRunningToolsMap,
                    // 节流相关 —— buffer 累积 + 100ms timer flush
                    appendStreamingDelta: streamingFlush.append,
                    flushStreamingBuffer: streamingFlush.flush,
                    cancelStreamingFlush: streamingFlush.cancel,
                    // reasoning preview 节流 + 落定清空
                    appendReasoningDelta: reasoningFlush.append,
                    cancelReasoningFlush: reasoningFlush.cancel,
                    // tool_start/end 节流 bump
                    scheduleToolBump: toolBumpFlush.schedule,
                    cancelToolBump: toolBumpFlush.cancel,
                    bump,
                    // 工作流进度逐步可见（持久暗色行）
                    emitProgressLine: ephemeralLine,
                });
            }
        }
        catch (e) {
            setError(`未捕获异常：${e.message}`);
            dlog('ui', 'turn THREW', { err: e.message });
        }
        finally {
            dlog('ui', 'turn END (finally)');
            setIsLoading(false);
            cancelAllThrottles();
            resetTurnState();
            setPluginProgress(null);
            abortRef.current = null;
            // v2 multi-session：持久化到当前 active session 文件。
            saveContext(historyRef.current, sessionFile()).catch(() => { });
        }
    }, [
        args.provider,
        bump,
        isLoading,
        sessionFile,
        streamingFlush,
        cancelAllThrottles,
        reasoningFlush,
        toolBumpFlush,
        resetTurnState,
    ]);
    const abort = useCallback(() => {
        abortRef.current?.abort();
    }, []);
    /**
     * v2.x 新增：把一段临时提示文本作为 _ephemeral 的 system 消息插入 history。
     *
     * 用途：替代 slash 命令分支直接 process.stdout.write 旁路 Ink 的写法
     * （那种写法每次都让 ink log-update 的 previousLineCount 失步，
     * 下一帧 eraseLines 会留下未擦行）。
     *
     * 流程：push 进 historyRef → bump() 触发 history useMemo 重算 →
     * MessageList <Static> 把它作为新 item commit 进 scrollback。
     * saveContext 写盘时过滤掉这些 ephemeral 消息。
     */
    const ephemeralLine = useCallback((text) => {
        historyRef.current.push({
            role: 'system',
            content: text,
            _ephemeral: true,
            timestamp: Date.now(),
        });
        bump();
    }, [bump]);
    /**
     * /new —— 清空历史，重新生成 system prompt（带最新 skills + 项目指令）。
     * 不复用启动时的初始 history 快照，避免把磁盘里的损坏数据带回来。
     * isLoading 时拒绝执行，避免和正在跑的 runQuery 抢状态。
     */
    const clearHistory = useCallback(async () => {
        if (isLoading)
            return;
        // 生成全新的 system prompt（实时扫 skills + 拼项目指令），
        // 与启动时 main.tsx buildInitialHistory 行为完全一致
        const newSystemPrompt = await buildFullSystemPrompt(getWorkingDir(), ALL_TOOLS);
        historyRef.current.length = 0;
        historyRef.current.push({ role: 'system', content: newSystemPrompt });
        // 允许新 session 再次触发反应式压缩自救
        resetReactiveCompactState();
        cancelAllThrottles();
        setError(null);
        setInterrupted(false);
        // 清空工作过程显示状态，避免 /new 后旧 turn 残留闪现
        resetTurnState();
        // 强制 MessageList 内 <Static> remount，避免旧消息卡在屏幕上
        setCompactGeneration((g) => g + 1);
        bump();
        // 清空磁盘上的持久化文件（防止下次启动又加载损坏数据）
        await clearContext();
        // 清空 pre-read guard 状态表（防止跨 session 的 Read 记录污染新会话）
        clearFileState();
        // 刚清空，不需要再保存一次"空历史"
    }, [bump, isLoading, cancelAllThrottles, resetTurnState]);
    /**
     * /compact —— 手动跑一次压缩（不看阈值）。
     * isLoading 时拒绝执行；压缩自身的 setIsLoading(true) 会让 InputBox 显示 ⏳。
     *
     * ✅ 关键改进：压缩失败时不保存 history（可能包含损坏数据）
     */
    const compactNow = useCallback(async () => {
        if (isLoading)
            return;
        setIsLoading(true);
        // 手动压缩仍然把 progressStage 切到 compacting，给 LiveArea 显示用
        setProgressStage('compacting');
        setError(null);
        // 压缩前清掉任何待 flush 的 streaming buffer（避免压缩中 timer 触发干扰）
        cancelAllThrottles();
        let success = false;
        try {
            const r = await forceCompact(historyRef.current, args.provider);
            historyRef.current.length = 0;
            historyRef.current.push(...r.messages);
            // 压缩后 MessageList 要 remount，丢掉旧 <Static> 缓存
            setCompactGeneration((g) => g + 1);
            bump();
            success = true; // ✅ 标记成功
        }
        catch (e) {
            setError(`手动压缩失败：${e.message}`);
            // ❌ 不修改 historyRef.current，保持原样（不保存可能损坏的数据）
        }
        finally {
            setProgressStage('idle');
            setIsLoading(false);
            // ✅ 只在成功时才持久化，避免保存损坏的历史到磁盘
            if (success) {
                // v2 multi-session：持久化到当前 active session 文件
                saveContext(historyRef.current, sessionFile()).catch(() => { });
                args.onCompactDone?.();
            }
        }
    }, [
        args.provider,
        args.onCompactDone,
        bump,
        isLoading,
        sessionFile,
        cancelAllThrottles,
    ]);
    /**
     * /session <id> —— 切换到指定 session（同 cwd 下）。
     *
     * 流程：
     *   1) saveContext 保住当前 session 的最新历史
     *   2) setActiveSession 更新注册表
     *   3) 改 activeSessionIdRef + state（让 UI 显示 / 持久化路径都走新 id）
     *   4) resolveSessionFile + loadContext 读新 session 历史（不存在则新建空）
     *   5) 替换 historyRef.current（保留 system prompt 在头部）
     *   6) bump + setCompactGeneration 触发 Static remount
     *
     * isLoading 时拒绝执行，避免与 runQuery 抢状态。
     */
    const switchSession = useCallback(async (id, opts) => {
        if (isLoading)
            return;
        const trimmed = id.trim();
        if (trimmed.length === 0)
            return;
        // force=true 时即便同 id 也强制重新加载（/restore 覆盖文件后用）
        if (!opts?.force && trimmed === activeSessionIdRef.current)
            return;
        const cwd = getWorkingDir();
        // 1. 保住当前 session
        try {
            await saveContext(historyRef.current, sessionFileFor(cwd, activeSessionIdRef.current));
        }
        catch {
            // 保存失败不阻塞切换（用户可能就是想切走止损）
        }
        // 2. 更新注册表
        try {
            await setActiveSession(cwd, trimmed);
        }
        catch {
            // 注册失败不阻塞切换
        }
        // 3. 切 ref + state
        activeSessionIdRef.current = trimmed;
        setActiveSessionId(trimmed);
        // 4. 加载新 session 历史（resolveSessionFile 支持 v1 → v2 路径兜底）
        let loaded = null;
        try {
            const newPath = await resolveSessionFile(cwd, trimmed);
            loaded = await loadContext(newPath);
        }
        catch {
            loaded = null;
        }
        // 5. 替换 historyRef，保留启动时的 system prompt 在头部
        const systemMsg = historyRef.current.find((m) => m.role === 'system');
        historyRef.current.length = 0;
        if (systemMsg)
            historyRef.current.push(systemMsg);
        if (loaded && loaded.length > 0) {
            // loaded 可能含旧 system prompt（旧版 last-context.json 保存了一份），跳过
            const rest = loaded[0]?.role === 'system' ? loaded.slice(1) : loaded;
            historyRef.current.push(...rest);
        }
        // 6. 清掉中间态、触发 Static remount
        cancelAllThrottles();
        setError(null);
        setInterrupted(false);
        resetTurnState();
        setCompactGeneration((g) => g + 1);
        bump();
    }, [bump, isLoading, cancelAllThrottles, resetTurnState]);
    // history 用 useMemo 托管：version 变化时返回 historyRef.current 的浅拷贝。
    // 为什么必须 .slice()：runQuery 内部 mutate 同一个数组（push 新消息），如果这里
    // 返回同一引用，Ink <Static items={history}> 浅比较看不到 prop 变化 → 不 commit
    // 新增 message → turn 结束 LiveArea 消失后屏幕空白，看起来像"输出被吃掉"。
    // slice 后元素引用不变，只是数组容器是新的，足够让 Static 检测到 length 增长并 commit。
    const history = useMemo(() => historyRef.current.slice(), [version]);
    return {
        history,
        streamingText,
        isLoading,
        error,
        interrupted,
        pluginProgress,
        compactGeneration,
        progressStage,
        reasoningPreview,
        runningTools,
        activeSessionId,
        submit,
        abort,
        clearHistory,
        compactNow,
        switchSession,
        ephemeralLine,
        pendingAsk,
        answerAsk,
    };
}
export function handleEvent(ev, setters) {
    // ---- 工作流进度事件（WorkflowEvent，不在 LoopEvent union 里）----
    // 渲染成持久的暗色行落进 scrollback：跑到哪一步、成功/失败/跳过都看得见，
    // 且出错后仍停留在屏幕上 —— 解决"工作流执行过程不可见、不知道停在哪"。
    const wf = ev;
    switch (wf.type) {
        case 'workflow_start':
            setters.emitProgressLine?.(`▶ 工作流 ${wf.name} 开始（共 ${wf.totalSteps} 步）`);
            return;
        case 'workflow_step_start':
            setters.emitProgressLine?.(`  ▶ ${(wf.index ?? 0) + 1}/${wf.total ?? '?'} ${wf.id} (${wf.kind})`);
            return;
        case 'workflow_step_end':
            setters.emitProgressLine?.(`  ${wf.ok ? '✓' : '✗'} ${wf.id}${wf.error ? `：${wf.error}` : ''}`);
            return;
        case 'workflow_step_skipped':
            setters.emitProgressLine?.(`  ⊘ ${wf.id}：${wf.reason ?? ''}`);
            return;
        case 'workflow_done':
            setters.emitProgressLine?.(`✓ 工作流 ${wf.name} 完成`);
            return;
    }
    // 收口到 LoopEvent：未识别的 PluginEvent type 走 default no-op
    const e = ev;
    switch (e.type) {
        case 'user_message_committed':
            // loop 刚把用户消息 push 进 history。立即 bump → history useMemo 重算 →
            // <Static> 把用户消息 commit 进 scrollback，第一时间置顶（T-A-O-R）。
            setters.bump();
            break;
        case 'text':
            setters.appendStreamingDelta(e.delta);
            break;
        case 'assistant_message':
            // history 已被 runQuery 直接 push。先 flush 残留 streaming buffer
            // 把末段 text 落到 streamingText state（avoid 视觉跳跃），再清空
            // streamingText —— Static 已 commit 完整 assistant message 接管显示。
            setters.flushStreamingBuffer();
            setters.setStreamingText(() => '');
            // 该段 reasoning 已随 assistant 消息进 history → MessageList 提交进 scrollback（💭 块）。
            // 清空 Zone B 的 live 预览：① 本轮 streaming 阶段不再回显旧 reasoning；
            // ② 多轮工具调用时，下一轮 thinking 从空白起步，不残留上一轮思维链。
            setters.cancelReasoningFlush();
            setters.setReasoningPreview(() => '');
            setters.cancelToolBump();
            setters.setProgressStage('idle');
            setters.setRunningToolsMap(() => new Map());
            setters.bump();
            break;
        case 'tool_start':
            // 把工具加入运行集合；并行多个工具时这里会累积。
            // v2: argsFriendly 是 loop.ts 通过 LoopEvent 扩展字段传过来的人话描述
            // （如 "Reading src/foo.ts"）。LoopEvent 类型上没声明此字段时通过
            // (e as { argsFriendly?: string }) 安全读取，缺省 fallback 在 UI 层。
            setters.setRunningToolsMap((prev) => {
                const next = new Map(prev);
                next.set(e.toolCallId, {
                    toolName: e.toolName,
                    argsPreview: e.argsPreview,
                    status: 'running',
                    argsFriendly: e.argsFriendly,
                });
                return next;
            });
            // 工具状态切换走 200ms 节流，避免连续 tool_start/end 高频 rerender
            // 压垮 Ink ANSI 重绘。
            setters.scheduleToolBump();
            break;
        case 'tool_end':
            // 仅删除对应 callId 的 entry；其余并行工具继续
            setters.setRunningToolsMap((prev) => {
                if (!prev.has(e.toolCallId))
                    return prev;
                const next = new Map(prev);
                next.delete(e.toolCallId);
                return next;
            });
            setters.scheduleToolBump();
            break;
        case 'tool_messages_committed':
            setters.cancelStreamingFlush();
            setters.setStreamingText(() => '');
            setters.cancelReasoningFlush();
            setters.setReasoningPreview(() => '');
            setters.cancelToolBump();
            setters.setProgressStage('idle');
            setters.setRunningToolsMap(() => new Map());
            setters.bump();
            break;
        case 'compact_start':
            // reactive 自救场景：LLM 失败前可能已经 yield 过部分 text delta，
            // 压缩前清掉残留 streamingText + 待 flush buffer，避免重发后新文本追加在
            // 旧 buffer 后面。autoCompact 场景下 streamingText 本来就是空。
            // 注意：progressStage 由 loop 端的 stage_change 'compacting' 负责切换。
            setters.cancelStreamingFlush();
            setters.setStreamingText(() => '');
            setters.cancelReasoningFlush();
            setters.setReasoningPreview(() => '');
            setters.bump();
            break;
        case 'compact_done':
            // 触发 MessageList 内 <Static> remount，丢弃旧消息缓存
            setters.setCompactGeneration((g) => g + 1);
            setters.bump();
            break;
        case 'turn_done':
            setters.bump();
            break;
        case 'interrupted':
            setters.setInterrupted(true);
            setters.bump();
            break;
        case 'error':
            setters.setError(e.error);
            setters.bump();
            break;
        case 'warning':
            // 通用非致命警告。TUI 不把它当红色 error 渲染；workflow 这类插件已另发
            // plugin_progress 把同一文案镜像到状态行（LiveArea）。这里显式 no-op 让它
            // 成为"已识别事件"，不触发 default 分支对未知事件的 dev 告警（见 default）。
            break;
        case 'plugin_progress':
            setters.setPluginProgress({
                pluginId: e.pluginId,
                current: e.current,
                max: e.max ?? 0,
                message: e.message,
            });
            setters.bump();
            break;
        case 'reasoning':
            // reasoning delta 频率跟 text delta 一样高（60+/秒），不节流的话每个 delta 立即
            // setReasoningPreview → LiveArea rerender → Ink 重绘扛不住。
            setters.appendReasoningDelta(e.delta);
            // 不调用 bump：reasoningPreview 是 LiveArea 单独的 state，history 没变化，
            // 避免高频 reasoning token 引起 MessageList re-render
            break;
        case 'stage_change':
            // 注意：tool_executing → streaming 切回时不清 runningTools，
            // 工具集合的清空时机是各自 tool_end
            setters.setProgressStage(e.stage);
            break;
        default:
            // 落到这里 = 插件私有事件（PluginEvent，type 任意字符串），TUI 不识别即忽略。
            // 这里**故意不**写 console.warn —— Ink 渲染期间写 stdout/stderr 会撕裂终端画面。
            // 要排查插件 yield 了拼错的事件 type，请用 `minimal-agent -p --verbose`（print.ts 落 stderr）。
            break;
    }
}
