# Read: /home/andy/works/agent-sdk/packages/comate_cli/comate_cli/terminal_agent/event_renderer.py

Lines 1-1587 of 1587

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