# Read: /home/hyc/projects/agent-sdk/packages/comate_cli/comate_cli/terminal_agent/event_renderer.py

Lines 1-1690 of 1690

     1	from __future__ import annotations
     2	
     3	import logging
     4	import random
     5	import re
     6	import threading
     7	import time
     8	from collections import deque
     9	from dataclasses import dataclass, field
    10	from pathlib import Path
    11	from typing import Any, Callable, Literal
    12	
    13	from comate_agent_sdk.agent.events import (
    14	    CompactionResultEvent,
    15	    CompactionStartedEvent,
    16	    PlanApprovalRequiredEvent,
    17	    SessionInitEvent,
    18	    StepCompleteEvent,
    19	    StopEvent,
    20	    SubagentProgressEvent,
    21	    SubagentStartEvent,
    22	    SubagentStopEvent,
    23	    SubagentToolCallEvent,
    24	    SubagentToolResultEvent,
    25	    TeamMessageEvent,
    26	    TextEvent,
    27	    TextDeltaEvent,
    28	    ThinkingEvent,
    29	    ThinkingDeltaEvent,
    30	    TaskUpdatedEvent,
    31	    ToolCallEvent,
    32	    ToolCallStartEvent,
    33	    ToolResultEvent,
    34	    UsageDeltaEvent,
    35	    UserQuestionEvent,
    36	)
    37	
    38	from rich.console import RenderableType
    39	from rich.text import Text
    40	
    41	from comate_cli.terminal_agent.animations import HIDDEN_THINKING_BADGES
    42	from comate_cli.terminal_agent.figures import (
    43	    BOTTOM_LEFT_CROP,
    44	    BULLET_OPERATOR,
    45	    CHECK_MARK,
    46	    CROSS_MARK,
    47	    ELLIPSIS,
    48	    HEAVY_HORIZONTAL,
    49	    INJECTED_ARROW,
    50	    TASK_BLOCKED,
    51	    TASK_IN_PROGRESS,
    52	    TASK_PENDING,
    53	)
    54	from comate_cli.terminal_agent.models import HistoryEntry, LoadingState, LogSubtype
    55	from comate_cli.terminal_agent.tool_result_formatters import (
    56	    render_diff_text,
    57	    scrollback_preview,
    58	)
    59	from comate_cli.terminal_agent.tool_result_store import (
    60	    ToolResultRecord,
    61	    ToolResultRegistry,
    62	)
    63	from comate_cli.terminal_agent.tool_view import summarize_tool_args, resolve_display_tool_name, should_show_tool_in_scrollback
    64	from comate_cli.terminal_agent.tool_fold import (
    65	    ActiveToolFold,
    66	    ToolFoldSnapshot,
    67	    is_foldable_tool,
    68	)
    69	from comate_cli.terminal_agent.env_utils import read_env_int
    70	from comate_cli.terminal_agent.custom_slash_commands import FILE_REF_PATTERN
    71	logger = logging.getLogger(__name__)
    72	
    73	_DEFAULT_TOOL_ERROR_SUMMARY_MAX_LEN = 160
    74	_DEFAULT_TOOL_PANEL_MAX_LINES = 4
    75	_DEFAULT_TASK_PANEL_MAX_LINES = 6
    76	_RECENT_TEAM_EVENT_CACHE_SIZE = 128
    77	_FILE_REF_MAX_COUNT_BYTES = 10 * 1024 * 1024  # 10 MB
    78	_SYSTEM_MESSAGE_DEDUPE_WINDOW_SECONDS = 0.5
    79	
    80	
    81	def _truncate(content: str, max_len: int = 120) -> str:
    82	    if len(content) <= max_len:
    83	        return content
    84	    return f"{content[:max_len]}..."
    85	
    86	
    87	def _format_duration(seconds: float) -> str:
    88	    elapsed = max(seconds, 0.0)
    89	    if elapsed < 60:
    90	        return f"{elapsed:.1f}s"
    91	    minutes = int(elapsed // 60)
    92	    remaining_seconds = int(elapsed % 60)
    93	    if minutes < 60:
    94	        return f"{minutes}m{remaining_seconds:02d}s"
    95	    hours = minutes // 60
    96	    remaining_minutes = minutes % 60
    97	    return f"{hours}h{remaining_minutes:02d}m"
    98	
    99	
   100	def _format_tokens(token_count: int) -> str:
   101	    tokens = max(int(token_count), 0)
   102	    if tokens < 1_000:
   103	        return f"{tokens} tok"
   104	    compact = f"{tokens / 1_000:.1f}".rstrip("0").rstrip(".")
   105	    return f"{compact}k tok"
   106	
   107	
   108	def _split_fragments_by_newline(
   109	    fragments: list[tuple[str, str]],
   110	) -> list[list[tuple[str, str]]]:
   111	    """Split prompt_toolkit fragments into lines, removing newline tokens."""
   112	    lines: list[list[tuple[str, str]]] = [[]]
   113	    for style, text in fragments:
   114	        parts = text.split("\n")
   115	        for idx, part in enumerate(parts):
   116	            if part:
   117	                lines[-1].append((style, part))
   118	            if idx < len(parts) - 1:
   119	                lines.append([])
   120	    return lines
   121	
   122	
   123	def _extract_task_title(args: dict[str, Any]) -> str:
   124	    description = str(args.get("description", "")).strip()
   125	    if description:
   126	        return description
   127	
   128	    subagent_name = str(args.get("subagent_type", "")).strip() or "Agent"
   129	    return subagent_name
   130	
   131	
   132	def _one_line(text: str) -> str:
   133	    return " ".join(str(text).split())
   134	
   135	
   136	def _tool_signature(tool_name: str, args_summary: str) -> str:
   137	    normalized = args_summary.strip()
   138	    if normalized:
   139	        return f"{tool_name}({normalized})"
   140	    return f"{tool_name}()"
   141	
   142	
   143	def _task_sort_key(task: dict[str, Any]) -> tuple[int, int]:
   144	    status = str(task.get("status", "pending")).strip().lower()
   145	    open_blocked_by = task.get("open_blocked_by", [])
   146	    is_blocked = isinstance(open_blocked_by, list) and len(open_blocked_by) > 0
   147	    if status == "in_progress":
   148	        rank = 0
   149	    elif status == "pending" and not is_blocked:
   150	        rank = 1
   151	    elif status == "pending":
   152	        rank = 2
   153	    else:
   154	        rank = 3
   155	
   156	    try:
   157	        numeric_id = int(task.get("id", 0))
   158	    except (TypeError, ValueError):
   159	        numeric_id = 0
   160	    return rank, numeric_id
   161	
   162	
   163	def _task_stats_header(tasks: list[dict[str, Any]]) -> str:
   164	    """生成 task 统计标题，如 '3 tasks (1 done, 2 open)'。"""
   165	    total = len(tasks)
   166	    done = sum(
   167	        1 for t in tasks
   168	        if str(t.get("status", "")).strip().lower() == "completed"
   169	    )
   170	    open_count = total - done
   171	    noun = "task" if total == 1 else "tasks"
   172	    return f"{total} {noun} ({done} done, {open_count} open)"
   173	
   174	
   175	def _format_task_row(task: dict[str, Any]) -> str:
   176	    """格式化单条 task 行。
   177	
   178	    符号约定：
   179	    - ✓ completed
   180	    - ◼ in_progress
   181	    - ▢ pending (unblocked)
   182	    - ▫ pending (blocked) — 视觉相似的中间符号，渲染层统一显示为 ▢ 但应用不同颜色
   183	    """
   184	    subject = str(task.get("subject", "")).strip() or "(untitled)"
   185	    status = str(task.get("status", "pending")).strip().lower()
   186	    open_blocked_by = task.get("open_blocked_by", [])
   187	
   188	    if status == "completed":
   189	        return f"{CHECK_MARK} {subject}"
   190	    if status == "in_progress":
   191	        return f"{TASK_IN_PROGRESS} {subject}"
   192	    if isinstance(open_blocked_by, list) and open_blocked_by:
   193	        return f"{TASK_BLOCKED} {subject}"
   194	    return f"{TASK_PENDING} {subject}"
   195	
   196	
   197	@dataclass
   198	class _SubagentTool:
   199	    """Subagent 内部的工具调用"""
   200	    tool_name: str
   201	    args_summary: str
   202	    started_at_monotonic: float
   203	    status: Literal["running", "completed", "error"] = "running"
   204	    duration_ms: float = 0.0
   205	
   206	
   207	@dataclass
   208	class _RunningTool:
   209	    tool_name: str
   210	    title: str
   211	    started_at_monotonic: float
   212	    is_task: bool
   213	    args_summary: str
   214	    display_tool_name: str = ""
   215	    progress_tokens: int = 0
   216	    subagent_name: str = ""
   217	    subagent_status: str = ""
   218	    subagent_description: str = ""
   219	    nested_tools: list[tuple[str, _SubagentTool]] = field(default_factory=list)
   220	    show_init: bool = False
   221	    subagent_model_name: str = ""
   222	
   223	
   224	@dataclass
   225	class ContainerState:
   226	    """正在缓冲的 markdown 容器。仅 fence 与 table 两种 —— list / blockquote
   227	    单行渲染效果可接受，不进入容器。"""
   228	
   229	    kind: Literal["fence", "table"]
   230	    lines: list[str] = field(default_factory=list)
   231	    fence_marker: str | None = None
   232	    """fence kind 用，verbatim 存开 fence 行的 marker（如 "```" / "~~~~"）。
   233	    turn-end synthetic close 时直接 append 这个字符串作为闭合行，与
   234	    _is_fence_close 的"同 char + ≥ 同长度"判定天然满足。"""
   235	    fence_lang: str | None = None
   236	    """fence kind 用，仅供 _loading_aux_text 显示（"正在写 python 代码块..."）。
   237	    渲染时 rich.Markdown 自己从 lines[0] 重新解析。"""
   238	
   239	
   240	# spec §6.3：fence 开 marker 正则。CommonMark 允许 ≥ 3 个 backtick / tilde；
   241	# lang 字段允许空（裸 fence），但 lang 后不允许有其他内容（仅 trailing whitespace）。
   242	_FENCE_OPEN_RE = re.compile(r"^(?P<marker>`{3,}|~{3,})\s*(?P<lang>\S*)\s*$")
   243	
   244	# spec §6.3：GFM table 分隔符行正则。要求 ≥ 2 列（即 ≥ 1 个内部 | 分隔符）。
   245	# 每个 cell 形如 [可选:]-+[可选:]，cell 之间用 | 分隔；首尾 | 可选。
   246	_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$")
   247	
   248	
   249	@dataclass
   250	class FenceMatch:
   251	    """spec §6.3：_detect_fence_open 返回值。"""
   252	
   253	    marker: str
   254	    lang: str
   255	
   256	
   257	class EventRenderer:
   258	    """Convert SDK events to lightweight terminal state for prompt_toolkit UI."""
   259	
   260	    def __init__(
   261	        self,
   262	        project_root: Path | None = None,
   263	        tool_results: ToolResultRegistry | None = None,
   264	    ) -> None:
   265	        self._history: list[HistoryEntry] = []
   266	        self._running_tools: dict[str, _RunningTool] = {}
   267	        self._tool_call_args: dict[str, dict[str, Any]] = {}
   268	        self._active_tool_fold = ActiveToolFold()
   269	        self._tool_results = tool_results
   270	        self._fallback_tool_result_sequence = 0
   271	        self._assistant_buffer = ""
   272	        self._pending_tool_starts: set[str] = set()
   273	        self._loading_state: LoadingState = LoadingState.idle()
   274	        self._current_tasks: list[dict[str, Any]] = []
   275	        self._current_task_title: str | None = None
   276	        self._task_started_at_monotonic: float | None = None
   277	        self._project_root = project_root
   278	        self._tool_error_summary_max_len = read_env_int(
   279	            "AGENT_SDK_TUI_TOOL_ERROR_SUMMARY_MAX_LEN",
   280	            _DEFAULT_TOOL_ERROR_SUMMARY_MAX_LEN,
   281	        )
   282	        self._tool_panel_max_lines = read_env_int(
   283	            "AGENT_SDK_TUI_TOOL_PANEL_MAX_LINES",
   284	            _DEFAULT_TOOL_PANEL_MAX_LINES,
   285	        )
   286	        self._task_panel_max_lines = read_env_int(
   287	            "AGENT_SDK_TUI_TASK_PANEL_MAX_LINES",
   288	            _DEFAULT_TASK_PANEL_MAX_LINES,
   289	        )
   290	        self._recent_team_event_keys: deque[tuple[str, str, str, str, str, str]] = deque(
   291	            maxlen=_RECENT_TEAM_EVENT_CACHE_SIZE
   292	        )
   293	        self._last_history_append_at: float = 0.0
   294	        self._show_thinking_cb: Callable[[], bool] = lambda: True
   295	        self._turn_received_text_delta: bool = False
   296	        self._turn_received_thinking_delta: bool = False
   297	        self._hidden_thinking_badge_text: str = ""
   298	        self._text_delta_started: bool = False
   299	        self._active_text_message_id: str | None = None
   300	        # ━━━━━ spec §4.1 新管道状态字段（Phase 1.2 引入，Phase 4-6 接入） ━━━━━
   301	        # Pipeline A: text line-commit
   302	        self._text_pending: str = ""
   303	        self._held_pipe_line: str | None = None
   304	        self._container: ContainerState | None = None
   305	        # Pipeline B: thinking line-commit + tail
   306	        self._thinking_batch: str = ""
   307	        # Pipeline C: loading 行 aux
   308	        self._loading_aux_text: str = ""
   309	        self._is_auto_compacting: bool = False
   310	        self._pending_logs: list[tuple[Literal["warning", "error"], str, LogSubtype]] = []
   311	        self._pending_logs_lock = threading.Lock()
   312	
   313	    def set_tool_result_registry(self, tool_results: ToolResultRegistry) -> None:
   314	        self._tool_results = tool_results
   315	
   316	    def is_auto_compacting(self) -> bool:
   317	        return self._is_auto_compacting
   318	
   319	    def clear_auto_compacting(self) -> None:
   320	        self._is_auto_compacting = False
   321	
   322	    def _make_tool_result_record(
   323	        self,
   324	        *,
   325	        tool_call_id: str,
   326	        tool_name: str,
   327	        display_name: str,
   328	        started_at_monotonic: float,
   329	        args: dict[str, Any],
   330	        is_error: bool,
   331	        result: Any,
   332	        metadata: dict[str, Any] | None,
   333	        output: Any,
   334	        subagent_name: str = "",
   335	        subagent_description: str = "",
   336	        nested_tool_count: int = 0,
   337	    ) -> ToolResultRecord:
   338	        rec_kwargs = {
   339	            "tool_call_id": tool_call_id,
   340	            "tool_name": tool_name,
   341	            "display_name": display_name,
   342	            "started_at_monotonic": started_at_monotonic,
   343	            "completed_at_wall": time.time(),
   344	            "args": args,
   345	            "is_error": is_error,
   346	            "typed_output": output,
   347	            "result_text": str(result),
   348	            "metadata": metadata,
   349	            "subagent_name": subagent_name,
   350	            "subagent_description": subagent_description,
   351	            "nested_tool_count": nested_tool_count,
   352	        }
   353	        if self._tool_results is not None:
   354	            return self._tool_results.record(rec_kwargs)
   355	        self._fallback_tool_result_sequence += 1
   356	        rec_kwargs["tool_name"] = str(tool_name).lower()
   357	        rec_kwargs["args"] = dict(args) if isinstance(args, dict) else {}
   358	        rec_kwargs["metadata"] = dict(metadata) if isinstance(metadata, dict) else metadata
   359	        return ToolResultRecord(
   360	            sequence=self._fallback_tool_result_sequence,
   361	            **rec_kwargs,
   362	        )
   363	
   364	    def _append_history_entry(self, entry: HistoryEntry) -> None:
   365	        self._history.append(entry)
   366	        self._last_history_append_at = time.monotonic()
   367	        if entry.entry_type == "tool_result":
   368	            self.flush_pending_logs()
   369	
   370	    def _commit_active_tool_fold(self) -> None:
   371	        snapshot = self._active_tool_fold.snapshot(active=False)
   372	        if snapshot is None:
   373	            return
   374	        self._append_history_entry(
   375	            HistoryEntry(
   376	                entry_type="tool_fold",
   377	                text=snapshot.summary,
   378	                severity="error" if snapshot.any_error else "info",
   379	                subtitle=snapshot.error_summary or snapshot.latest_hint,
   380	                tool_call_ids=snapshot.tool_call_ids,
   381	            )
   382	        )
   383	        self._active_tool_fold.clear()
   384	
   385	    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
   386	    # spec §6 流式新管道方法集（Phase 1-6 新增；Phase 7 接入 handle_event）
   387	    # 容器检测原语（spec §6.3）
   388	    # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
   389	
   390	    def _detect_fence_open(self, line: str) -> FenceMatch | None:
   391	        """spec §6.3：识别 fence 开行。
   392	        规则：行首 ≥ 3 个 backtick / tilde + 可选 lang 标识符（不含空格）。
   393	        lang 后不允许有其他内容（仅 trailing whitespace）。"""
   394	        m = _FENCE_OPEN_RE.match(line)
   395	        if m is None:
   396	            return None
   397	        return FenceMatch(marker=m.group("marker"), lang=m.group("lang") or "")
   398	
   399	    def _is_fence_close(self, line: str) -> bool:
   400	        """spec §6.3：判定 line 是否当前 fence 容器的闭合标记。
   401	        要求：line.strip() 仅由 marker char (` 或 ~) 组成，且长度 ≥ 开 marker。"""
   402	        assert self._container is not None and self._container.kind == "fence"
   403	        expected = self._container.fence_marker
   404	        assert expected is not None
   405	        char = expected[0]
   406	        stripped = line.strip()
   407	        return (
   408	            len(stripped) >= len(expected)
   409	            and len(stripped) > 0
   410	            and all(c == char for c in stripped)
   411	        )
   412	
   413	    def _is_table_separator(self, line: str) -> bool:
   414	        """spec §6.3：判定 line 是否 GFM table 分隔符行（如 "| --- | --- |"）。
   415	        要求至少 2 列（即 ≥ 1 个内部 | 分隔符）。"""
   416	        return bool(_TABLE_SEP_RE.match(line))
   417	
   418	    def _looks_like_table_row(self, line: str) -> bool:
   419	        """spec §6.3：判定 line 是否"看起来像 table row"。
   420	        用于 held 候选 + table 续行判定（共用同一谓词保证对称语义）。
   421	
   422	        规则：
   423	        - 空行 / 仅空白 → False
   424	        - 行首（去首部空白）以 | → True
   425	        - 否则要求至少 2 个 |（GFM 允许无围栏 table 但需 ≥ 2 内部分隔符）
   426	        """
   427	        if not line.strip():
   428	            return False
   429	        if line.lstrip().startswith("|"):
   430	            return True
   431	        return line.count("|") >= 2
   432	
   433	    # spec §6.3 别名机制：三方法语义完全等价，分别对应不同语境的可读性，
   434	    # 但实现共用 —— 保证 held 候选与续行判定语义对称（避免分裂导致的 corner-case bug）。
   435	    _is_table_continuation = _looks_like_table_row
   436	    _has_pipe_chars = _looks_like_table_row
   437	
   438	    # ── commit 出口（spec §6.5）──
   439	
   440	    def _emit_text_line(self, line: str) -> None:
   441	        """spec §6.5：单行 assistant text → HistoryEntry → 入队。"""
   442	        self._append_history_entry(
   443	            HistoryEntry(entry_type="assistant", text=line + "\n")
   444	        )
   445	
   446	    def _flush_container(self) -> None:
   447	        """spec §6.5：容器整段 → HistoryEntry → 入队。
   448	        rich.Markdown 在 history_printer 里把整段渲染为 syntax-highlighted
   449	        code block / aligned table。flush 后必须清 aux 避免 stale phrase。"""
   450	        assert self._container is not None
   451	        full_text = "\n".join(self._container.lines) + "\n"
   452	        self._append_history_entry(
   453	            HistoryEntry(entry_type="assistant", text=full_text)
   454	        )
   455	        self._container = None
   456	        self._loading_aux_text = ""
   457	
   458	    def _flush_thinking_batch(self) -> None:
   459	        """spec §6.5：thinking 残段 → HistoryEntry（受 _show_thinking_cb 过滤）。
   460	        即使 hidden 也清空 batch 避免持续累积。"""
   461	        text = self._thinking_batch
   462	        self._thinking_batch = ""
   463	        if not text:
   464	            return
   465	        if not self._show_thinking_cb():
   466	            return
   467	        self._append_history_entry(
   468	            HistoryEntry(entry_type="thinking", text=text)
   469	        )
   470	
   471	    def _consume_thinking_delta(self, delta: str) -> None:
   472	        """spec §6.1：thinking delta 主入口。
   473	
   474	        与 text delta 对称：按完整行即时 commit 到 scrollback，未换行尾巴留在
   475	        _thinking_batch，等待 text/tool/turn 边界通过 _flush_thinking_batch 落盘。
   476	        """
   477	        if not delta:
   478	            return
   479	        if self._show_thinking_cb():
   480	            self._hidden_thinking_badge_text = ""
   481	        elif not self._hidden_thinking_badge_text:
   482	            self._hidden_thinking_badge_text = random.choice(HIDDEN_THINKING_BADGES)
   483	        self._thinking_batch += delta
   484	        while "\n" in self._thinking_batch:
   485	            line, self._thinking_batch = self._thinking_batch.split("\n", 1)
   486	            if self._show_thinking_cb():
   487	                self._append_history_entry(
   488	                    HistoryEntry(entry_type="thinking", text=line + "\n")
   489	                )
   490	
   491	    # ── 行级路由（spec §6.2，Task 4.1-4.4 渐进扩展）──
   492	
   493	    def _handle_complete_line(self, line: str) -> None:
   494	        """spec §6.2 行级路由 —— Task 4.1-4.4 全分支完成。"""
   495	        # ━━━ 容器内 ━━━
   496	        if self._container is not None:
   497	            if self._container.kind == "fence":
   498	                self._container.lines.append(line)
   499	                if self._is_fence_close(line):
   500	                    self._flush_container()
   501	                else:
   502	                    self._refresh_loading_aux()
   503	                return
   504	            if self._container.kind == "table":
   505	                if self._is_table_continuation(line):
   506	                    self._container.lines.append(line)
   507	                    self._refresh_loading_aux()
   508	                    return
   509	                # table 结束（空行 / 非 table-row）：先 flush 容器，
   510	                # 当前 line 继续走容器外逻辑（递归 1 次）
   511	                self._flush_container()
   512	                # plan 第 3 轮加固：flush_container 后 _container 必须为 None，
   513	                # 否则递归无 bound（防御 _flush_container 实现退化）
   514	                assert self._container is None, "table flush 后 container 必须为 None，防递归无 bound"
   515	                self._handle_complete_line(line)
   516	                return
   517	
   518	        # ━━━ 容器外 ━━━
   519	
   520	        # 持有的 pipe 候选行待确认 table（1-line lookahead）
   521	        if self._held_pipe_line is not None:
   522	            held = self._held_pipe_line
   523	            self._held_pipe_line = None
   524	            if self._is_table_separator(line):
   525	                # 确认 table → 进入容器（held + sep 一起入容器）
   526	                self._container = ContainerState(
   527	                    kind="table", lines=[held, line],
   528	                )
   529	                self._refresh_loading_aux()
   530	                return
   531	            # 不是 separator → held 不是 table 头，落地为普通行
   532	            self._emit_text_line(held)
   533	            # 不 return：line 继续判定（可能它自己是 fence open / 新 pipe 候选）
   534	
   535	        fm = self._detect_fence_open(line)
   536	        if fm is not None:
   537	            self._container = ContainerState(
   538	                kind="fence", lines=[line],
   539	                fence_marker=fm.marker, fence_lang=fm.lang,
   540	            )
   541	            self._refresh_loading_aux()
   542	            return
   543	
   544	        # 检测 pipe 候选行（潜在 table 头）—— spec §6.3 收紧判定
   545	        if self._looks_like_table_row(line) and not self._is_table_separator(line):
   546	            self._held_pipe_line = line
   547	            return
   548	
   549	        self._emit_text_line(line)
   550	
   551	    def _refresh_loading_aux(self) -> None:
   552	        """spec §6.7：容器缓冲期 spinner phrase 后的 dim 后缀文案。
   553	        无容器时清空（防止旧 phrase 残留）。"""
   554	        if self._container is None:
   555	            self._loading_aux_text = ""
   556	            return
   557	        n = len(self._container.lines)
   558	        if self._container.kind == "fence":
   559	            lang = self._container.fence_lang or "code"
   560	            self._loading_aux_text = f"正在写 {lang} 代码块（{n} 行）..."
   561	        else:  # table
   562	            self._loading_aux_text = f"正在写表格（{n} 行）..."
   563	
   564	    def _consume_text_delta(self, delta: str) -> None:
   565	        """spec §6.1：text delta 主入口。
   566	
   567	        把 delta 累到 _text_pending，按 \\n 切完整行送 _handle_complete_line。
   568	
   569	        关键顺序保证：text 进入前先 flush 待 thinking 残段 —— 保证
   570	        scrollback 看到 "thinking → text" 而非 "text → thinking" 错序。
   571	        """
   572	        if not delta:
   573	            return
   574	        self._hidden_thinking_badge_text = ""
   575	        if self._thinking_batch:
   576	            self._flush_thinking_batch()
   577	        self._text_pending += delta
   578	        while "\n" in self._text_pending:
   579	            line, self._text_pending = self._text_pending.split("\n", 1)
   580	            if line.strip() == "" and not self._text_delta_started:
   581	                continue
   582	            self._text_delta_started = True
   583	            self._handle_complete_line(line)
   584	
   585	    # ── turn 边界（spec §6.4）──
   586	
   587	    def _force_flush_all(self) -> None:
   588	        """spec §6.4：StopEvent / finalize_turn 调用。
   589	        保证 scrollback 看到完整 turn 内容。
   590	
   591	        顺序：held → container[fence synthetic close] → text_pending
   592	        → thinking → 清 aux。
   593	        """
   594	        # ① held 落地
   595	        if self._held_pipe_line is not None:
   596	            self._emit_text_line(self._held_pipe_line)
   597	            self._held_pipe_line = None
   598	        # ② 容器残留：fence 自动追加 synthetic close（fence_marker verbatim
   599	        #    存的就是开 fence 用的 marker，append 后 _is_fence_close 天然满足）；
   600	        #    table 直接 flush，rich.Markdown 容错渲染
   601	        if self._container is not None:
   602	            if self._container.kind == "fence":
   603	                assert self._container.fence_marker is not None
   604	                self._container.lines.append(self._container.fence_marker)
   605	            self._flush_container()
   606	        # ③ text_pending 未换行残段（按完整行 emit）
   607	        if self._text_pending:
   608	            self._emit_text_line(self._text_pending)
   609	            self._text_pending = ""
   610	        # ④ thinking
   611	        if self._thinking_batch:
   612	            self._flush_thinking_batch()
   613	        # ⑤ aux
   614	        self._loading_aux_text = ""
   615	        self._hidden_thinking_badge_text = ""
   616	
   617	    def _drop_all_pending(self) -> None:
   618	        """spec §6.4：interrupt_turn 调用。
   619	        语义：用户不要剩下的内容 —— 全部丢弃不入队（与 _force_flush_all
   620	        相反，本方法不调用任何 _emit / _flush 出口）。"""
   621	        self._text_pending = ""
   622	        self._held_pipe_line = None
   623	        self._container = None
   624	        self._thinking_batch = ""
   625	        self._loading_aux_text = ""
   626	        self._hidden_thinking_badge_text = ""
   627	        self._text_delta_started = False
   628	        self._active_text_message_id = None
   629	
   630	    def _should_drop_duplicate_system_message(
   631	        self,
   632	        *,
   633	        content: str,
   634	        severity: Literal["info", "warning", "error"],
   635	    ) -> bool:
   636	        if severity not in {"warning", "error"}:
   637	            return False
   638	        if not self._history:
   639	            return False
   640	        last_entry = self._history[-1]
   641	        if last_entry.entry_type != "system":
   642	            return False
   643	        if last_entry.severity != severity:
   644	            return False
   645	        if str(last_entry.text).strip() != content:
   646	            return False
   647	        if time.monotonic() - self._last_history_append_at > _SYSTEM_MESSAGE_DEDUPE_WINDOW_SECONDS:
   648	            return False
   649	        logger.debug(
   650	            "Skip duplicate system message in scrollback: severity=%s content=%r",
   651	            severity,
   652	            content,
   653	        )
   654	        return True
   655	
   656	    def start_turn(self) -> None:
   657	        self.clear_auto_compacting()
   658	        self._flush_assistant_segment()
   659	        self._pending_tool_starts.clear()
   660	        self._turn_received_text_delta = False
   661	        self._turn_received_thinking_delta = False
   662	        self._text_delta_started = False
   663	        self._active_text_message_id = None
   664	        # spec §6.4：清空新 5 字段（每 turn 起点保证状态干净）
   665	        self._text_pending = ""
   666	        self._held_pipe_line = None
   667	        self._container = None
   668	        self._thinking_batch = ""
   669	        self._loading_aux_text = ""
   670	        self._hidden_thinking_badge_text = ""
   671	        self._rebuild_loading_line()
   672	
   673	    def seed_user_message(
   674	        self,
   675	        content: str,
   676	        *,
   677	        display_header: str | None = None,
   678	        display_subtitle: str | None = None,
   679	    ) -> None:
   680	        normalized = content.strip()
   681	        if not normalized:
   682	            return
   683	        self._flush_assistant_segment()
   684	        self._commit_active_tool_fold()
   685	        if display_header and display_subtitle:
   686	            # AskUserQuestion 答复入 scrollback：用 tool_result 样式渲染头行（●），
   687	            # 答案明细作为 ⎿ subtitle 紧贴其下；行间不插入用户 prefix。
   688	            self._append_history_entry(
   689	                HistoryEntry(
   690	                    entry_type="tool_result",
   691	                    text=display_header,
   692	                    subtitle=display_subtitle,
   693	                )
   694	            )
   695	        else:
   696	            self._append_history_entry(HistoryEntry(entry_type="user", text=normalized))
   697	            self._maybe_append_file_ref_hint(normalized)
   698	        self.flush_pending_logs()
   699	
   700	    def _maybe_append_file_ref_hint(self, text: str) -> None:
   701	        """Append dim ⎿ hints for valid @path references."""
   702	        if self._project_root is None:
   703	            return
   704	        for match in FILE_REF_PATTERN.finditer(text):
   705	            raw_path = match.group(1)
   706	            candidate = self._project_root / raw_path
   707	            try:
   708	                if candidate.is_dir():
   709	                    self._append_history_entry(
   710	                        HistoryEntry(entry_type="file_ref", text=f"Listed directory {raw_path}")
   711	                    )
   712	                    continue
   713	                if candidate.is_file():
   714	                    hint = f"Read {raw_path}"
   715	                    if candidate.stat().st_size <= _FILE_REF_MAX_COUNT_BYTES:
   716	                        try:
   717	                            with open(candidate, "rb") as fh:
   718	                                line_count = sum(1 for _ in fh)
   719	                            hint += f"  ({line_count} lines)"
   720	                        except OSError:
   721	                            pass
   722	                    self._append_history_entry(HistoryEntry(entry_type="file_ref", text=hint))
   723	                    continue
   724	            except OSError:
   725	                continue
   726	
   727	    def close(self) -> None:
   728	        return
   729	
   730	    def finalize_turn(self) -> None:
   731	        # spec §6.4：turn 结束 force flush 所有 pending（held / container /
   732	        # text_pending / thinking）保证 scrollback 看到完整内容
   733	        self._force_flush_all()
   734	        self._commit_active_tool_fold()
   735	        self._flush_assistant_segment()
   736	        self.flush_pending_logs()
   737	        self._pending_tool_starts.clear()
   738	        self._rebuild_loading_line()
   739	
   740	    def tick_progress(self) -> None:
   741	        self._rebuild_loading_line()
   742	
   743	    def refresh_loading_animation(self) -> None:
   744	        self._rebuild_loading_line()
   745	
   746	    def interrupt_turn(self) -> None:
   747	        if self._running_tools:
   748	            self._append_history_entry(
   749	                HistoryEntry(
   750	                    entry_type="system",
   751	                    text=f"Current task interrupted ({len(self._running_tools)} running tools)",
   752	                    severity="warning",
   753	                )
   754	            )
   755	        self._running_tools.clear()
   756	        self._flush_assistant_segment()
   757	        self._commit_active_tool_fold()
   758	        self._pending_tool_starts.clear()
   759	        # spec §6.4：用户中断 → 5 个新字段全部丢弃不入队
   760	        self._drop_all_pending()
   761	        self._rebuild_loading_line()
   762	
   763	    def history_entries(self) -> list[HistoryEntry]:
   764	        return list(self._history)
   765	
   766	    def active_tool_fold_snapshot(self) -> ToolFoldSnapshot | None:
   767	        return self._active_tool_fold.snapshot(active=True)
   768	
   769	    def reset_history_view(self) -> None:
   770	        """重置 history 视图状态（用于会话切换后的重新加载）。"""
   771	        self._history = []
   772	        self._recent_team_event_keys.clear()
   773	        self._running_tools.clear()
   774	        self._tool_call_args.clear()
   775	        self._active_tool_fold.clear()
   776	        self._assistant_buffer = ""
   777	        self._pending_tool_starts.clear()
   778	        self._turn_received_text_delta = False
   779	        self._turn_received_thinking_delta = False
   780	        self._text_delta_started = False
   781	        self._active_text_message_id = None
   782	        # spec §6.4：清空 5 个新字段（与 start_turn 同语义）
   783	        self._text_pending = ""
   784	        self._held_pipe_line = None
   785	        self._container = None
   786	        self._thinking_batch = ""
   787	        self._loading_aux_text = ""
   788	        self._loading_state = LoadingState.idle()
   789	        self._hidden_thinking_badge_text = ""
   790	        self._current_tasks = []
   791	        self._current_task_title = None
   792	        self._task_started_at_monotonic = None
   793	        with self._pending_logs_lock:
   794	            self._pending_logs.clear()
   795	
   796	    def has_running_tools(self) -> bool:
   797	        return bool(self._running_tools)
   798	
   799	    def has_running_subagents(self) -> bool:
   800	        """Return whether any running tool is an Agent(subagent) tool."""
   801	        return any(state.is_task for state in self._running_tools.values())
   802	
   803	    def has_active_tasks(self) -> bool:
   804	        return bool(self._current_tasks)
   805	
   806	    def has_active_todos(self) -> bool:
   807	        return self.has_active_tasks()
   808	
   809	    def has_in_progress_tasks(self) -> bool:
   810	        return any(
   811	            str(t.get("status", "")).strip().lower() == "in_progress"
   812	            for t in self._current_tasks
   813	        )
   814	
   815	    def compute_required_tool_panel_lines(self) -> int:
   816	        """计算显示所有 running tools 所需的最小行数。"""
   817	        if not self._running_tools:
   818	            return 0
   819	        total_lines = 0
   820	        for tool_call_id, state in self._running_tools.items():
   821	            if state.is_task:
   822	                total_lines += 1  # 主标题行
   823	                # 嵌套工具（最多 3 个）
   824	                total_lines += min(len(state.nested_tools), 3)
   825	                # init 行（仅在创建后、首个有效子事件前显示）
   826	                if state.show_init:
   827	                    total_lines += 1
   828	            else:
   829	                total_lines += 1
   830	        return total_lines
   831	
   832	    def loading_state(self) -> LoadingState:
   833	        """返回语义化的 loading 状态，用于 UI 层决定渲染策略。"""
   834	        return self._loading_state
   835	
   836	    def loading_line(self) -> str:
   837	        """兼容旧接口，返回 loading 状态的文本内容。"""
   838	        return self._loading_state.text
   839	
   840	    def loading_aux_text(self) -> str:
   841	        """spec §6.7 / §7.1：返回容器缓冲期 aux 文案，供 render_panels
   842	        拼接到 spinner phrase。无容器时返回 ""。"""
   843	        return self._loading_aux_text
   844	
   845	    def hidden_thinking_badge_text(self) -> str:
   846	        """Return the loading-line badge shown while hidden thinking streams."""
   847	        return self._hidden_thinking_badge_text
   848	
   849	    def append_subtitle(
   850	        self,
   851	        text: str,
   852	        *,
   853	        severity: Literal["info", "warning", "error"] = "info",
   854	    ) -> None:
   855	        """Append a ⎿ subtitle entry, visually attached to the preceding entry."""
   856	        normalized = text.strip()
   857	        if not normalized:
   858	            return
   859	        self._append_history_entry(
   860	            HistoryEntry(entry_type="file_ref", text=normalized, severity=severity)
   861	        )
   862	
   863	    def append_system_message(
   864	        self,
   865	        content: str,
   866	        *,
   867	        severity: Literal["info", "warning", "error"] = "info",
   868	    ) -> None:
   869	        normalized = content.strip()
   870	        if not normalized:
   871	            return
   872	        if self._should_drop_duplicate_system_message(
   873	            content=normalized,
   874	            severity=severity,
   875	        ):
   876	            return
   877	        self._flush_assistant_segment()
   878	        self._append_history_entry(
   879	            HistoryEntry(entry_type="system", text=normalized, severity=severity)
   880	        )
   881	
   882	    def enqueue_log(
   883	        self,
   884	        *,
   885	        severity: Literal["warning", "error"],
   886	        message: str,
   887	        log_subtype: LogSubtype = "background",
   888	    ) -> None:
   889	        """线程安全入口：只入队，不调度 prompt_toolkit，不立即写 scrollback。"""
   890	        normalized = message.strip()
   891	        if not normalized:
   892	            return
   893	        with self._pending_logs_lock:
   894	            self._pending_logs.append((severity, normalized, log_subtype))
   895	
   896	    def flush_pending_logs(self) -> None:
   897	        """Drain pending log，按 log_subtype 决定是否写入 scrollback。"""
   898	        with self._pending_logs_lock:
   899	            if not self._pending_logs:
   900	                return
   901	            drained = list(self._pending_logs)
   902	            self._pending_logs.clear()
   903	
   904	        for severity, message, log_subtype in drained:
   905	            if log_subtype == "transient":
   906	                continue
   907	            self._append_history_entry(
   908	                HistoryEntry(
   909	                    entry_type="log",
   910	                    text=message,
   911	                    severity=severity,
   912	                    attached=False,
   913	                    log_subtype=log_subtype,
   914	                )
   915	            )
   916	
   917	    @staticmethod
   918	    def _team_event_key(
   919	        *,
   920	        agent_name: str,
   921	        from_agent: str,
   922	        to_agent: str | None,
   923	        message_type: str,
   924	        timestamp: str,
   925	        content_preview: str,
   926	    ) -> tuple[str, str, str, str, str, str]:
   927	        return (
   928	            str(agent_name or "").strip(),
   929	            str(from_agent or "").strip(),
   930	            str(to_agent or "").strip(),
   931	            str(message_type or "").strip(),
   932	            str(timestamp or "").strip(),
   933	            str(content_preview or "").strip(),
   934	        )
   935	
   936	    def append_team_message_event(
   937	        self,
   938	        *,
   939	        agent_name: str,
   940	        from_agent: str,
   941	        to_agent: str | None,
   942	        message_type: str,
   943	        content_preview: str,
   944	        timestamp: str,
   945	    ) -> bool:
   946	        event_key = self._team_event_key(
   947	            agent_name=agent_name,
   948	            from_agent=from_agent,
   949	            to_agent=to_agent,
   950	            message_type=message_type,
   951	            timestamp=timestamp,
   952	            content_preview=content_preview,
   953	        )
   954	        if event_key in self._recent_team_event_keys:
   955	            logger.debug(
   956	                "[team-diag] renderer_dedupe "
   957	                "agent=%r from=%r to=%r type=%r timestamp=%r preview=%r",
   958	                agent_name,
   959	                from_agent,
   960	                to_agent,
   961	                message_type,
   962	                timestamp,
   963	                content_preview,
   964	            )
   965	            return False
   966	
   967	        self._recent_team_event_keys.append(event_key)
   968	        target = to_agent or "[broadcast]"
   969	        preview_str = f' "{content_preview}"' if content_preview else ""
   970	        text = f"[team] {from_agent} {INJECTED_ARROW} {target}: ({message_type}){preview_str}"
   971	        logger.debug(
   972	            "[team-diag] renderer_append "
   973	            "agent=%r from=%r to=%r type=%r timestamp=%r preview=%r history_len_before=%d",
   974	            agent_name,
   975	            from_agent,
   976	            to_agent,
   977	            message_type,
   978	            timestamp,
   979	            content_preview,
   980	            len(self._history),
   981	        )
   982	        self.append_system_message(text)
   983	        return True
   984	
   985	    def append_elapsed_message(self, content: str) -> None:
   986	        """追加一条灰色无前缀的计时统计行到 history scrollback."""
   987	        normalized = content.strip()
   988	        if not normalized:
   989	            return
   990	        self._flush_assistant_segment()
   991	        self._append_history_entry(HistoryEntry(entry_type="elapsed", text=normalized))
   992	
   993	    def append_assistant_message(self, content: str) -> None:
   994	        normalized = content.strip()
   995	        if not normalized:
   996	            return
   997	        self._flush_assistant_segment()
   998	        self._append_history_entry(HistoryEntry(entry_type="assistant", text=normalized))
   999	
  1000	    def tool_panel_entries(
  1001	        self, *, max_lines: int | None = None
  1002	    ) -> list[tuple[int, str | list[tuple[str, str]]]]:
  1003	        """Return panel entries for running tools.
  1004	
  1005	        Each entry is a tuple: (indent_level, content).
  1006	        content is either a plain str or a list of (style, text) prompt_toolkit fragments.
  1007	        indent_level == 0 means a primary tool line; >0 means nested status.
  1008	        indent_level < 0 means a meta line (no dot prefix).
  1009	        """
  1010	        limit = max_lines if max_lines is not None else self._tool_panel_max_lines
  1011	        normalized_limit = max(1, int(limit))
  1012	        if not self._running_tools:
  1013	            return []
  1014	
  1015	        now = time.monotonic()
  1016	        entries: list[tuple[int, str]] = []
  1017	        tool_items = list(self._running_tools.items())
  1018	        for idx, (tool_call_id, state) in enumerate(tool_items):
  1019	            elapsed = _format_duration(now - state.started_at_monotonic)
  1020	            lines_to_add = 1
  1021	            if state.is_task:
  1022	                tokens_suffix = (
  1023	                    f" {BULLET_OPERATOR} {_format_tokens(state.progress_tokens)}"
  1024	                    if state.progress_tokens > 0
  1025	                    else ""
  1026	                )
  1027	                # 主标题行 + 嵌套工具（最多 3 个）+ 状态行（如果有状态）
  1028	                lines_to_add = 1 + min(len(state.nested_tools), 3)
  1029	                if state.show_init:
  1030	                    lines_to_add += 1
  1031	            else:
  1032	                lines_to_add = 1
  1033	
  1034	            if len(entries) + lines_to_add > normalized_limit:
  1035	                remaining_tools = len(tool_items) - idx
  1036	                entries.append((-1, f"{ELLIPSIS} (+{remaining_tools})"))
  1037	                break
  1038	
  1039	            if state.is_task:
  1040	                tokens_suffix = (
  1041	                    f" {BULLET_OPERATOR} {_format_tokens(state.progress_tokens)}"
  1042	                    if state.progress_tokens > 0
  1043	                    else ""
  1044	                )
  1045	                # 格式：SubagentName(描述) ∙ model(dim) ∙ elapsed ∙ tools ∙ tokens
  1046	                subagent_name = state.subagent_name or "Agent"
  1047	                description = state.subagent_description or state.title
  1048	                title = f"{subagent_name}({description})"
  1049	                tool_count = len(state.nested_tools)
  1050	                tool_count_suffix = f" {BULLET_OPERATOR} +{tool_count} tool uses" if tool_count > 0 else ""
  1051	                rest = f" {BULLET_OPERATOR} {elapsed}{tool_count_suffix}{tokens_suffix}"
  1052	                if state.subagent_model_name:
  1053	                    # Return styled fragments: model_name in dim
  1054	                    frags: list[tuple[str, str]] = [
  1055	                        ("", title),
  1056	                        ("class:dim", f" {BULLET_OPERATOR} {state.subagent_model_name}"),
  1057	                        ("", rest),
  1058	                    ]
  1059	                    entries.append((0, frags))
  1060	                else:
  1061	                    entries.append((0, f"{title}{rest}"))
  1062	
  1063	                # 嵌套工具调用（最多显示最近 3 个）
  1064	                for child_id, child_tool in state.nested_tools[-3:]:
  1065	                    child_display = resolve_display_tool_name(child_tool.tool_name, {})
  1066	                    signature = _tool_signature(child_display, child_tool.args_summary)
  1067	                    if child_tool.status == "running":
  1068	                        entries.append((1, f"{BOTTOM_LEFT_CROP} {signature}"))
  1069	                    else:
  1070	                        icon = CHECK_MARK if child_tool.status == "completed" else CROSS_MARK
  1071	                        entries.append((1, f"{BOTTOM_LEFT_CROP} {icon} {signature}"))
  1072	
  1073	                if state.show_init:
  1074	                    entries.append((1, f"{BOTTOM_LEFT_CROP} init"))
  1075	            else:
  1076	                display_name = state.display_tool_name or state.tool_name
  1077	                signature = _tool_signature(display_name, state.args_summary)
  1078	                entries.append((0, f"{signature} {BULLET_OPERATOR} {elapsed}"))
  1079	
  1080	        return entries[:normalized_limit]
  1081	
  1082	    def task_panel_lines(self, *, max_lines: int | None = None) -> list[str]:
  1083	        lines = self.full_task_panel_lines()
  1084	        if not lines:
  1085	            return []
  1086	
  1087	        limit = max_lines if max_lines is not None else self._task_panel_max_lines
  1088	        normalized_limit = max(1, int(limit))
  1089	        if len(lines) <= normalized_limit:
  1090	            return lines
  1091	
  1092	        clipped = lines[: normalized_limit - 1]
  1093	        clipped.append(f"{ELLIPSIS} (+{len(lines) - (normalized_limit - 1)})")
  1094	        return clipped
  1095	
  1096	    def full_task_panel_lines(self) -> list[str]:
  1097	        tasks = list(self._current_tasks)
  1098	        if not tasks:
  1099	            return []
  1100	
  1101	        header = str(self._current_task_title or "").strip()
  1102	        if not header:
  1103	            header = _task_stats_header(tasks)
  1104	        lines: list[str] = [header]
  1105	        for task in sorted(tasks, key=_task_sort_key):
  1106	            lines.append(_format_task_row(task))
  1107	        return lines
  1108	
  1109	    def _flush_assistant_segment(self) -> None:
  1110	        if not self._assistant_buffer:
  1111	            return
  1112	        self._append_history_entry(
  1113	            HistoryEntry(entry_type="assistant", text=self._assistant_buffer)
  1114	        )
  1115	        self._assistant_buffer = ""
  1116	        self.flush_pending_logs()
  1117	
  1118	    def _append_assistant_text(self, text: str) -> None:
  1119	        self._assistant_buffer += text
  1120	
  1121	    def _append_tool_call(self, tool_name: str, args: dict[str, Any], tool_call_id: str) -> None:
  1122	        self._running_tools[tool_call_id] = self._make_running_tool(tool_name, args)
  1123	
  1124	    def append_static_tool_result(
  1125	        self,
  1126	        signature: str,
  1127	        is_error: bool = False,
  1128	        diff_lines: list[str] | None = None,
  1129	        model_name: str = "",
  1130	        subtitle: str | None = None,
  1131	    ) -> None:
  1132	        """Append a tool result to history as a static entry (no timer).
  1133	
  1134	        Args:
  1135	            signature: 工具签名，例如 "Read(path=xxx)"
  1136	            is_error: 是否为错误结果
  1137	            diff_lines: optional diff lines for Edit
  1138	            model_name: model name for Agent tools (rendered dim)
  1139	            subtitle: optional subtitle rendered as `⎿ ...`
  1140	        """
  1141	        sev: Literal["info", "warning", "error"] = "error" if is_error else "info"
  1142	        if not is_error and diff_lines and len(diff_lines) > 0:
  1143	            text_obj = Text(signature)
  1144	            if model_name:
  1145	                text_obj.append(f" {BULLET_OPERATOR} {model_name}", style="dim")
  1146	            text_obj.append("\n")
  1147	            text_obj.append(render_diff_text(diff_lines))
  1148	            self._append_history_entry(
  1149	                HistoryEntry(entry_type="tool_result", text=text_obj, severity="info", subtitle=subtitle)
  1150	            )
  1151	            return
  1152	        if model_name:
  1153	            text_obj = Text(signature)
  1154	            text_obj.append(f" {BULLET_OPERATOR} {model_name}", style="dim")
  1155	            self._append_history_entry(
  1156	                HistoryEntry(entry_type="tool_result", text=text_obj, severity=sev, subtitle=subtitle)
  1157	            )
  1158	            return
  1159	        self._append_history_entry(
  1160	            HistoryEntry(
  1161	                entry_type="tool_result",
  1162	                text=signature,
  1163	                severity=sev,
  1164	                subtitle=subtitle,
  1165	            )
  1166	        )
  1167	
  1168	    def _make_running_tool(self, tool_name: str, args: dict[str, Any]) -> _RunningTool:
  1169	        """Create a _RunningTool from tool name and args dict."""
  1170	        title = tool_name
  1171	        is_task = tool_name.lower() == "agent"
  1172	        summary = summarize_tool_args(tool_name, args, self._project_root).strip()
  1173	        display_name = resolve_display_tool_name(tool_name, args)
  1174	        subagent_name = ""
  1175	        subagent_description = ""
  1176	        if is_task:
  1177	            title = _extract_task_title(args)
  1178	            summary = ""
  1179	            subagent_name = str(args.get("subagent_type", "")).strip() or "Agent"
  1180	            subagent_description = str(args.get("description", "")).strip()
  1181	
  1182	        return _RunningTool(
  1183	            tool_name=tool_name,
  1184	            display_tool_name=display_name,
  1185	            title=title,
  1186	            started_at_monotonic=time.monotonic(),
  1187	            is_task=is_task,
  1188	            args_summary=summary,
  1189	            show_init=is_task,
  1190	            subagent_name=subagent_name,
  1191	            subagent_description=subagent_description,
  1192	        )
  1193	
  1194	    def _append_tool_result(
  1195	        self,
  1196	        tool_name: str,
  1197	        tool_call_id: str,
  1198	        is_error: bool,
  1199	        result: Any,
  1200	        metadata: dict[str, Any] | None = None,
  1201	        output: Any = None,
  1202	        args: dict[str, Any] | None = None,
  1203	    ) -> None:
  1204	        sev: Literal["info", "warning", "error"] = "error" if is_error else "info"
  1205	        state = self._running_tools.pop(tool_call_id, None)
  1206	        args = dict(args or {})
  1207	        if state is None:
  1208	            display_name = resolve_display_tool_name(tool_name, args)
  1209	            rec = self._make_tool_result_record(
  1210	                tool_call_id=tool_call_id,
  1211	                tool_name=tool_name,
  1212	                display_name=display_name,
  1213	                started_at_monotonic=time.monotonic(),
  1214	                args=args,
  1215	                is_error=is_error,
  1216	                result=result,
  1217	                metadata=metadata,
  1218	                output=output,
  1219	            )
  1220	            text, subtitle = scrollback_preview(rec)
  1221	            self._append_history_entry(
  1222	                HistoryEntry(entry_type="tool_result", text=text, severity=sev, subtitle=subtitle)
  1223	            )
  1224	            return
  1225	
  1226	        if state.is_task:
  1227	            subagent_name = state.subagent_name or "Agent"
  1228	            description = state.subagent_description or state.title
  1229	            model_name = ""
  1230	            if metadata and isinstance(metadata, dict):
  1231	                model_name = metadata.get("model_name", "")
  1232	
  1233	            tool_count = len(state.nested_tools)
  1234	            display_name = state.display_tool_name or state.tool_name
  1235	            if model_name:
  1236	                metadata = dict(metadata or {})
  1237	                metadata["model_name"] = model_name
  1238	        else:
  1239	            display_name = state.display_tool_name or state.tool_name
  1240	            subagent_name = ""
  1241	            description = ""
  1242	            tool_count = 0
  1243	
  1244	        rec = self._make_tool_result_record(
  1245	            tool_call_id=tool_call_id,
  1246	            tool_name=tool_name,
  1247	            display_name=display_name,
  1248	            started_at_monotonic=state.started_at_monotonic,
  1249	            args=args,
  1250	            is_error=is_error,
  1251	            result=result,
  1252	            metadata=metadata,
  1253	            output=output,
  1254	            subagent_name=subagent_name,
  1255	            subagent_description=description,
  1256	            nested_tool_count=tool_count,
  1257	        )
  1258	        text, subtitle = scrollback_preview(rec)
  1259	        self._append_history_entry(
  1260	            HistoryEntry(entry_type="tool_result", text=text, severity=sev, subtitle=subtitle)
  1261	        )
  1262	
  1263	    def _rebuild_loading_line(self) -> None:
  1264	        self._loading_state = LoadingState.idle()
  1265	
  1266	    def _update_tasks(self, tasks: list[dict[str, Any]], *, list_id: str) -> None:
  1267	        """更新当前共享任务列表状态。"""
  1268	        normalized = list(tasks) if tasks else []
  1269	        if not normalized:
  1270	            self._current_tasks = []
  1271	            self._current_task_title = None
  1272	            self._task_started_at_monotonic = None
  1273	            return
  1274	
  1275	        all_completed = all(str(item.get("status", "")).strip().lower() == "completed" for item in normalized)
  1276	        # 标题：显示 ID 最小的 in_progress task 的 subject，否则统计摘要
  1277	        in_progress_tasks = [
  1278	            t for t in normalized
  1279	            if str(t.get("status", "")).strip().lower() == "in_progress"
  1280	        ]
  1281	        if in_progress_tasks:
  1282	            in_progress_tasks.sort(key=lambda t: int(t.get("id", 0)))
  1283	            header = str(in_progress_tasks[0].get("subject", "")).strip() or "Tasks"
  1284	        else:
  1285	            header = _task_stats_header(normalized)
  1286	        if not all_completed:
  1287	            if not self._current_tasks:
  1288	                self._task_started_at_monotonic = time.monotonic()
  1289	            self._current_task_title = header
  1290	            self._current_tasks = normalized
  1291	            return
  1292	
  1293	        # All completed: hide panel and write a summary entry once.
  1294	        # 守卫：_current_tasks 已空说明完成总结已写过，跳过重复写入
  1295	        if not self._current_tasks:
  1296	            return
  1297	        started = self._task_started_at_monotonic
  1298	        elapsed_suffix = ""
  1299	        if started is not None:
  1300	            elapsed_suffix = f" {BULLET_OPERATOR} {_format_duration(time.monotonic() - started)}"
  1301	        total = len(normalized)
  1302	        self._append_history_entry(
  1303	            HistoryEntry(
  1304	                entry_type="tool_result",
  1305	                text=f"tasks {total}/{total} completed{elapsed_suffix}",
  1306	                severity="info",
  1307	            )
  1308	        )
  1309	        self._current_tasks = []
  1310	        self._current_task_title = None
  1311	        self._task_started_at_monotonic = None
  1312	
  1313	    def task_renderable(self) -> RenderableType | None:
  1314	        """将当前任务工作集渲染为 Rich 组件。
  1315	
  1316	        Returns:
  1317	            Rich RenderableType 或 None（如果没有 task）
  1318	        """
  1319	        if not self._current_tasks:
  1320	            return None
  1321	
  1322	        tasks = list(sorted(self._current_tasks, key=_task_sort_key))
  1323	        total = len(tasks)
  1324	        completed = sum(1 for task in tasks if task.get("status") == "completed")
  1325	
  1326	        # 构建 Rich Text 组件
  1327	        result = Text()
  1328	
  1329	        # 标题
  1330	        result.append(f"{self._current_task_title or 'Tasks'} ({completed}/{total} completed)\n")
  1331	
  1332	        for task in tasks:
  1333	            line = _format_task_row(task)
  1334	            if line.startswith(f"{CHECK_MARK} "):
  1335	                result.append(f"  {CHECK_MARK} ")
  1336	                result.append(line[2:], style="green strike")
  1337	            elif line.startswith(f"{TASK_IN_PROGRESS} "):
  1338	                result.append(f"  {TASK_IN_PROGRESS} ")
  1339	                result.append(line[2:], style="#F97316")
  1340	            elif line.startswith(f"{TASK_BLOCKED} "):
  1341	                # blocked pending: display as TASK_PENDING glyph + dim
  1342	                result.append(f"  {TASK_PENDING} ")
  1343	                result.append(line[2:], style="dim")
  1344	            elif line.startswith(f"{TASK_PENDING} "):
  1345	                result.append(f"  {TASK_PENDING} ")
  1346	                result.append(line[2:])
  1347	            else:
  1348	                result.append(f"  {line}")
  1349	            result.append("\n")
  1350	
  1351	        # 移除末尾的换行
  1352	        if result.plain.endswith("\n"):
  1353	            result = result[:-1]
  1354	
  1355	        return result
  1356	
  1357	    def task_lines(self) -> list[str]:
  1358	        """返回当前任务面板行列表（用于测试）。"""
  1359	        return self.task_panel_lines(max_lines=6)
  1360	
  1361	    def todo_panel_lines(self, *, max_lines: int | None = None) -> list[str]:
  1362	        return self.task_panel_lines(max_lines=max_lines)
  1363	
  1364	    def todo_all_lines(self) -> list[str]:
  1365	        return self.full_task_panel_lines()
  1366	
  1367	    def todo_renderable(self) -> RenderableType | None:
  1368	        return self.task_renderable()
  1369	
  1370	    def todo_lines(self) -> list[str]:
  1371	        return self.task_lines()
  1372	
  1373	    def handle_event(self, event: Any) -> tuple[bool, list[dict[str, Any]] | None]:
  1374	        if not isinstance(
  1375	            event,
  1376	            (TextEvent, TextDeltaEvent, ThinkingDeltaEvent, ToolCallStartEvent),
  1377	        ):
  1378	            self._flush_assistant_segment()
  1379	
  1380	        match event:
  1381	            case SessionInitEvent(session_id=_):
  1382	                pass
  1383	            case TextDeltaEvent(delta=delta, message_id=message_id):
  1384	                if delta:
  1385	                    self._commit_active_tool_fold()
  1386	                    normalized_message_id = str(message_id or "").strip()
  1387	                    if normalized_message_id and normalized_message_id != self._active_text_message_id:
  1388	                        self._active_text_message_id = normalized_message_id
  1389	                        self._text_delta_started = False
  1390	                    # spec §6.1：text delta → 累到 _text_pending → 按 \n 切完整行入队
  1391	                    self._consume_text_delta(delta)
  1392	                    self._turn_received_text_delta = True
  1393	            case ThinkingDeltaEvent(delta=delta, message_id=_):
  1394	                if delta:
  1395	                    # spec §6.1：thinking delta → 按 \n 切完整行入队，残段留 batch
  1396	                    self._turn_received_thinking_delta = True
  1397	                    self._consume_thinking_delta(delta)
  1398	            case ToolCallStartEvent(tool_call_id=tool_call_id, tool=tool_name):
  1399	                # tool 调用前先把 _text_pending / _thinking_batch 落到 scrollback，
  1400	                # 保证说明文字出现在 tool UI 之前（无 \n 结尾的模型也适用）。
  1401	                self._force_flush_all()
  1402	                normalized_id = str(tool_call_id or "").strip()
  1403	                if normalized_id and normalized_id not in self._pending_tool_starts:
  1404	                    self._pending_tool_starts.add(normalized_id)
  1405	            case ThinkingEvent(content=thinking):
  1406	                if self._turn_received_thinking_delta:
  1407	                    pass
  1408	                elif self._show_thinking_cb():
  1409	                    self._append_history_entry(
  1410	                        HistoryEntry(entry_type="thinking", text=thinking)
  1411	                    )
  1412	            case CompactionStartedEvent(
  1413	                current_tokens=_,
  1414	                threshold=_,
  1415	                trigger=_,
  1416	            ):
  1417	                self._is_auto_compacting = True
  1418	            case CompactionResultEvent(
  1419	                current_tokens=tokens,
  1420	                threshold=threshold,
  1421	                trigger=trigger,
  1422	                attempted=attempted,
  1423	                compacted=compacted,
  1424	                reason=reason,
  1425	                tokens_before=_,
  1426	                tokens_after=tokens_after,
  1427	            ):
  1428	                self.clear_auto_compacting()
  1429	                pct = int(tokens / threshold * 100) if threshold > 0 else 0
  1430	                # precheck 的 tokens 是 estimate_precheck() 推算值；
  1431	                # check 的 tokens 是上一次 API 返回的真实 context_usage。
  1432	                trigger_label = "est." if trigger == "precheck" else "usage"
  1433	                if compacted:
  1434	                    reduction_pct = (
  1435	                        max(0, int(((tokens - tokens_after) / tokens) * 100))
  1436	                        if tokens > 0
  1437	                        else 0
  1438	                    )
  1439	                    self.append_system_message(
  1440	                        (
  1441	                            f"Auto-compact done ({trigger}): "
  1442	                            f"{trigger_label} {tokens:,} / threshold {threshold:,} ({pct}%)\n"
  1443	                            f"  {INJECTED_ARROW} context reduced to ~{tokens_after:,} tokens "
  1444	                            f"(-{reduction_pct}%)"
  1445	                        ),
  1446	                        severity="info",
  1447	                    )
  1448	                elif attempted:
  1449	                    is_recoverable_deferred = str(reason).startswith("partial_compact_no_op:")
  1450	                    label = (
  1451	                        "Auto-compact deferred"
  1452	                        if is_recoverable_deferred
  1453	                        else "Auto-compact attempt failed"
  1454	                    )
  1455	                    self.append_system_message(
  1456	                        (
  1457	                            f"{label} ({trigger}): "
  1458	                            f"{trigger_label} {tokens:,} / threshold {threshold:,} ({pct}%), "
  1459	                            f"reason={reason}"
  1460	                        ),
  1461	                        severity="warning",
  1462	                    )
  1463	                else:
  1464	                    self.append_system_message(
  1465	                        (
  1466	                            f"Auto-compact skipped ({trigger}): "
  1467	                            f"{trigger_label} {tokens:,} / threshold {threshold:,} ({pct}%), "
  1468	                            f"reason={reason}"
  1469	                        ),
  1470	                        severity="warning",
  1471	                    )
  1472	            case ToolCallEvent(tool=tool_name, args=arguments, tool_call_id=tool_call_id):
  1473	                args_dict = arguments if isinstance(arguments, dict) else {"_raw": str(arguments)}
  1474	                # Store args for ToolResult phase lookup.
  1475	                self._tool_call_args[tool_call_id] = args_dict
  1476	                if is_foldable_tool(tool_name):
  1477	                    self._active_tool_fold.add_call(
  1478	                        tool_call_id=tool_call_id,
  1479	                        tool_name=tool_name,
  1480	                        args=args_dict,
  1481	                        started_at_monotonic=time.monotonic(),
  1482	                    )
  1483	                    self._rebuild_loading_line()
  1484	                    return (False, None)
  1485	                self._commit_active_tool_fold()
  1486	                if not should_show_tool_in_scrollback(tool_name, args_dict):
  1487	                    self._rebuild_loading_line()
  1488	                    return (False, None)
  1489	                self._append_tool_call(tool_name, args_dict, tool_call_id)
  1490	            case ToolResultEvent(tool=tool_name, result=result, tool_call_id=tool_call_id, is_error=is_error, metadata=metadata, output=output):
  1491	                stored_args = self._tool_call_args.pop(tool_call_id, {})
  1492	                if self._active_tool_fold.has_call(tool_call_id):
  1493	                    display_name = resolve_display_tool_name(tool_name, stored_args)
  1494	                    rec = self._make_tool_result_record(
  1495	                        tool_call_id=tool_call_id,
  1496	                        tool_name=tool_name,
  1497	                        display_name=display_name,
  1498	                        started_at_monotonic=time.monotonic(),
  1499	                        args=stored_args,
  1500	                        is_error=is_error,
  1501	                        result=result,
  1502	                        metadata=metadata,
  1503	                        output=output,
  1504	                    )
  1505	                    error_summary = None
  1506	                    if is_error:
  1507	                        error_summary = str(result).strip().splitlines()[0] if str(result).strip() else None
  1508	                    self._active_tool_fold.mark_result(
  1509	                        tool_call_id,
  1510	                        is_error=is_error,
  1511	                        result_record_sequence=rec.sequence,
  1512	                        error_summary=error_summary,
  1513	                    )
  1514	                    self._rebuild_loading_line()
  1515	                    return (False, None)
  1516	                if not should_show_tool_in_scrollback(tool_name, stored_args, is_result=True, is_error=is_error):
  1517	                    self._running_tools.pop(tool_call_id, None)
  1518	                    self._rebuild_loading_line()
  1519	                    return (False, None)
  1520	                self._append_tool_result(
  1521	                    tool_name=tool_name,
  1522	                    tool_call_id=tool_call_id,
  1523	                    is_error=is_error,
  1524	                    result=result,
  1525	                    metadata=metadata,
  1526	                    output=output,
  1527	                    args=stored_args,
  1528	                )
  1529	            case UsageDeltaEvent(
  1530	                source=_,
  1531	                model=_,
  1532	                level=_,
  1533	                delta_prompt_tokens=_,
  1534	                delta_prompt_cached_tokens=_,
  1535	                delta_completion_tokens=_,
  1536	                delta_total_tokens=_,
  1537	            ):
  1538	                pass
  1539	            case SubagentStartEvent(tool_call_id=_, subagent_name=_, description=_):
  1540	                pass
  1541	            case SubagentProgressEvent(
  1542	                tool_call_id=tool_call_id,
  1543	                subagent_name=subagent_name,
  1544	                description=description,
  1545	                status=status,
  1546	                elapsed_ms=elapsed_ms,
  1547	                tokens=tokens,
  1548	                model_name=model_name,
  1549	            ):
  1550	                state = self._running_tools.get(tool_call_id)
  1551	                if state is not None:
  1552	                    if tokens is not None:
  1553	                        state.progress_tokens = max(int(tokens), 0)
  1554	                    if elapsed_ms is not None:
  1555	                        normalized = max(float(elapsed_ms), 0.0)
  1556	                        state.started_at_monotonic = time.monotonic() - (normalized / 1000)
  1557	                    # Subagent-specific status for task tool panel.
  1558	                    state.subagent_name = str(subagent_name or "").strip()
  1559	                    state.subagent_status = str(status or "").strip()
  1560	                    state.subagent_description = str(description or "").strip()
  1561	                    if model_name:
  1562	                        state.subagent_model_name = str(model_name)
  1563	                    # 首个有效子事件后移除 init 占位。
  1564	                    progress_status = str(status or "").strip().lower()
  1565	                    has_activity = bool(tokens and int(tokens) > 0) or bool(
  1566	                        elapsed_ms and float(elapsed_ms) > 0.0
  1567	                    )
  1568	                    if has_activity or progress_status in {"completed", "error", "timeout", "cancelled"}:
  1569	                        state.show_init = False
  1570	            case SubagentStopEvent(tool_call_id=_, subagent_name=_, status=_, duration_ms=_, error=_):
  1571	                pass
  1572	            case SubagentToolCallEvent(
  1573	                parent_tool_call_id=parent_tool_call_id,
  1574	                subagent_name=_,
  1575	                tool=tool,
  1576	                args=args,
  1577	                tool_call_id=tool_call_id,
  1578	            ):
  1579	                # 将嵌套工具调用添加到父 Agent 的 nested_tools
  1580	                parent_state = self._running_tools.get(parent_tool_call_id)
  1581	                if parent_state is not None:
  1582	                    args_summary = summarize_tool_args(tool, args, self._project_root).strip()
  1583	                    nested_tool = _SubagentTool(
  1584	                        tool_name=tool,
  1585	                        args_summary=args_summary,
  1586	                        started_at_monotonic=time.monotonic(),
  1587	                        status="running",
  1588	                    )
  1589	                    parent_state.nested_tools.append((tool_call_id, nested_tool))
  1590	                    parent_state.show_init = False
  1591	            case SubagentToolResultEvent(
  1592	                parent_tool_call_id=parent_tool_call_id,
  1593	                subagent_name=_,
  1594	                tool=_,
  1595	                tool_call_id=tool_call_id,
  1596	                is_error=is_error,
  1597	                duration_ms=duration_ms,
  1598	            ):
  1599	                # 更新嵌套工具的状态
  1600	                parent_state = self._running_tools.get(parent_tool_call_id)
  1601	                if parent_state is not None:
  1602	                    for nested_id, nested_tool in parent_state.nested_tools:
  1603	                        if nested_id == tool_call_id:
  1604	                            nested_tool.status = "error" if is_error else "completed"
  1605	                            nested_tool.duration_ms = duration_ms
  1606	                            parent_state.show_init = False
  1607	                            break
  1608	            case TaskUpdatedEvent(list_id=list_id, tasks=tasks):
  1609	                self._update_tasks(tasks, list_id=list_id)
  1610	            case UserQuestionEvent(questions=questions, tool_call_id=_):
  1611	                # 不再把问题写入 scrollback：交由 AskUserQuestionUI 弹层展示，
  1612	                # 答完后由 _submit_question_reply 写一条 tool_result + ⎿ subtitle 汇总。
  1613	                self._rebuild_loading_line()
  1614	                return (True, questions)
  1615	            case TextEvent(content=text):
  1616	                if self._turn_received_text_delta and not getattr(event, "force_display", False):
  1617	                    pass
  1618	                elif text:
  1619	                    self._commit_active_tool_fold()
  1620	                    self._append_assistant_text(text)
  1621	            case StepCompleteEvent(step_id=step_id, status=_, duration_ms=_):
  1622	                # Cancellation/error paths may emit StepCompleteEvent without ToolResultEvent.
  1623	                # Ensure tool panel state is cleaned up by step id.
  1624	                self._running_tools.pop(step_id, None)
  1625	            case StopEvent(reason=reason):
  1626	                self._flush_assistant_segment()
  1627	                self._commit_active_tool_fold()
  1628	                self._pending_tool_starts.clear()
  1629	                # Safety net: if any running tool rows remain, stop event means this turn is ending.
  1630	                self._running_tools.clear()
  1631	                self._hidden_thinking_badge_text = ""
  1632	                self._rebuild_loading_line()
  1633	                if reason == "waiting_for_input":
  1634	                    return (True, None)
  1635	                if reason == "waiting_for_plan_approval":
  1636	                    return (False, None)
  1637	                if reason == "interrupted" and "error" not in event.metadata:
  1638	                    self._append_history_entry(
  1639	                        HistoryEntry(entry_type="system", text="Current task interrupted.", severity="warning")
  1640	                    )
  1641	            case PlanApprovalRequiredEvent(
  1642	                plan_path=plan_path,
  1643	                summary=summary,
  1644	                execution_prompt=_,
  1645	                plan_markdown=plan_markdown,
  1646	            ):
  1647	                # 在 scrollback 渲染计划内容，让用户审阅后再决策。
  1648	                # 优先使用事件携带正文，避免依赖本地二次读文件。
  1649	                plan_content = str(plan_markdown or "")
  1650	                if not plan_content:
  1651	                    try:
  1652	                        plan_content = Path(plan_path).read_text(encoding="utf-8")
  1653	                    except Exception:
  1654	                        logger.warning("ExitPlanMode: Failed to read plan file %s", plan_path)
  1655	                if plan_content:
  1656	                    self._append_history_entry(
  1657	                        HistoryEntry(
  1658	                            entry_type="system",
  1659	                            text=f"{HEAVY_HORIZONTAL * 3} Here is the plan, please review and approve or reject {HEAVY_HORIZONTAL * 3}",
  1660	                        )
  1661	                    )
  1662	                    self._append_history_entry(HistoryEntry(entry_type="assistant", text=plan_content))
  1663	
  1664	                text = f"Plan ready for review: {plan_path}"
  1665	                if summary:
  1666	                    text = f"{text} | {summary}"
  1667	                self._append_history_entry(
  1668	                    HistoryEntry(entry_type="system", text=text)
  1669	                )
  1670	            case TeamMessageEvent(
  1671	                agent_name=agent_name,
  1672	                from_agent=from_agent,
  1673	                to_agent=to_agent,
  1674	                message_type=message_type,
  1675	                content_preview=content_preview,
  1676	                timestamp=timestamp,
  1677	            ):
  1678	                self.append_team_message_event(
  1679	                    agent_name=agent_name,
  1680	                    from_agent=from_agent,
  1681	                    to_agent=to_agent,
  1682	                    message_type=message_type,
  1683	                    content_preview=content_preview,
  1684	                    timestamp=timestamp,
  1685	                )
  1686	            case _:
  1687	                logger.debug("Unhandled event type: %s", type(event).__name__)
  1688	
  1689	        self._rebuild_loading_line()
  1690	        return (False, None)