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

Lines 1-1598 of 1598

     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(self, content: str) -> None:
   633	        normalized = content.strip()
   634	        if not normalized:
   635	            return
   636	        self._flush_assistant_segment()
   637	        self._append_history_entry(HistoryEntry(entry_type="user", text=normalized))
   638	        self._maybe_append_file_ref_hint(normalized)
   639	        self.flush_pending_logs()
   640	
   641	    def _maybe_append_file_ref_hint(self, text: str) -> None:
   642	        """Append dim ⎿ hints for valid @path references."""
   643	        if self._project_root is None:
   644	            return
   645	        for match in FILE_REF_PATTERN.finditer(text):
   646	            raw_path = match.group(1)
   647	            candidate = self._project_root / raw_path
   648	            try:
   649	                if candidate.is_dir():
   650	                    self._append_history_entry(
   651	                        HistoryEntry(entry_type="file_ref", text=f"Listed directory {raw_path}")
   652	                    )
   653	                    continue
   654	                if candidate.is_file():
   655	                    hint = f"Read {raw_path}"
   656	                    if candidate.stat().st_size <= _FILE_REF_MAX_COUNT_BYTES:
   657	                        try:
   658	                            with open(candidate, "rb") as fh:
   659	                                line_count = sum(1 for _ in fh)
   660	                            hint += f"  ({line_count} lines)"
   661	                        except OSError:
   662	                            pass
   663	                    self._append_history_entry(HistoryEntry(entry_type="file_ref", text=hint))
   664	                    continue
   665	            except OSError:
   666	                continue
   667	
   668	    def close(self) -> None:
   669	        return
   670	
   671	    def finalize_turn(self) -> None:
   672	        # spec §6.4：turn 结束 force flush 所有 pending（held / container /
   673	        # text_pending / thinking）保证 scrollback 看到完整内容
   674	        self._force_flush_all()
   675	        self._flush_assistant_segment()
   676	        self.flush_pending_logs()
   677	        self._pending_tool_starts.clear()
   678	        self._rebuild_loading_line()
   679	
   680	    def tick_progress(self) -> None:
   681	        self._rebuild_loading_line()
   682	
   683	    def refresh_loading_animation(self) -> None:
   684	        self._rebuild_loading_line()
   685	
   686	    def interrupt_turn(self) -> None:
   687	        if self._running_tools:
   688	            self._append_history_entry(
   689	                HistoryEntry(
   690	                    entry_type="system",
   691	                    text=f"Current task interrupted ({len(self._running_tools)} running tools)",
   692	                    severity="warning",
   693	                )
   694	            )
   695	        self._running_tools.clear()
   696	        self._flush_assistant_segment()
   697	        self._pending_tool_starts.clear()
   698	        # spec §6.4：用户中断 → 5 个新字段全部丢弃不入队
   699	        self._drop_all_pending()
   700	        self._rebuild_loading_line()
   701	
   702	    def history_entries(self) -> list[HistoryEntry]:
   703	        return list(self._history)
   704	
   705	    def reset_history_view(self) -> None:
   706	        """重置 history 视图状态（用于会话切换后的重新加载）。"""
   707	        self._history = []
   708	        self._recent_team_event_keys.clear()
   709	        self._running_tools.clear()
   710	        self._tool_call_args.clear()
   711	        self._assistant_buffer = ""
   712	        self._pending_tool_starts.clear()
   713	        self._turn_received_text_delta = False
   714	        self._turn_received_thinking_delta = False
   715	        self._text_delta_started = False
   716	        self._active_text_message_id = None
   717	        # spec §6.4：清空 5 个新字段（与 start_turn 同语义）
   718	        self._text_pending = ""
   719	        self._held_pipe_line = None
   720	        self._container = None
   721	        self._thinking_batch = ""
   722	        self._loading_aux_text = ""
   723	        self._loading_state = LoadingState.idle()
   724	        self._current_tasks = []
   725	        self._current_task_title = None
   726	        self._task_started_at_monotonic = None
   727	        with self._pending_logs_lock:
   728	            self._pending_logs.clear()
   729	
   730	    def has_running_tools(self) -> bool:
   731	        return bool(self._running_tools)
   732	
   733	    def has_running_subagents(self) -> bool:
   734	        """Return whether any running tool is an Agent(subagent) tool."""
   735	        return any(state.is_task for state in self._running_tools.values())
   736	
   737	    def has_active_tasks(self) -> bool:
   738	        return bool(self._current_tasks)
   739	
   740	    def has_active_todos(self) -> bool:
   741	        return self.has_active_tasks()
   742	
   743	    def has_in_progress_tasks(self) -> bool:
   744	        return any(
   745	            str(t.get("status", "")).strip().lower() == "in_progress"
   746	            for t in self._current_tasks
   747	        )
   748	
   749	    def compute_required_tool_panel_lines(self) -> int:
   750	        """计算显示所有 running tools 所需的最小行数。"""
   751	        if not self._running_tools:
   752	            return 0
   753	        total_lines = 0
   754	        for tool_call_id, state in self._running_tools.items():
   755	            if state.is_task:
   756	                total_lines += 1  # 主标题行
   757	                # 嵌套工具（最多 3 个）
   758	                total_lines += min(len(state.nested_tools), 3)
   759	                # init 行（仅在创建后、首个有效子事件前显示）
   760	                if state.show_init:
   761	                    total_lines += 1
   762	            else:
   763	                total_lines += 1
   764	        return total_lines
   765	
   766	    def loading_state(self) -> LoadingState:
   767	        """返回语义化的 loading 状态，用于 UI 层决定渲染策略。"""
   768	        return self._loading_state
   769	
   770	    def loading_line(self) -> str:
   771	        """兼容旧接口，返回 loading 状态的文本内容。"""
   772	        return self._loading_state.text
   773	
   774	    def loading_aux_text(self) -> str:
   775	        """spec §6.7 / §7.1：返回容器缓冲期 aux 文案，供 render_panels
   776	        拼接到 spinner phrase。无容器时返回 ""。"""
   777	        return self._loading_aux_text
   778	
   779	    def append_subtitle(
   780	        self,
   781	        text: str,
   782	        *,
   783	        severity: Literal["info", "warning", "error"] = "info",
   784	    ) -> None:
   785	        """Append a ⎿ subtitle entry, visually attached to the preceding entry."""
   786	        normalized = text.strip()
   787	        if not normalized:
   788	            return
   789	        self._append_history_entry(
   790	            HistoryEntry(entry_type="file_ref", text=normalized, severity=severity)
   791	        )
   792	
   793	    def append_system_message(
   794	        self,
   795	        content: str,
   796	        *,
   797	        severity: Literal["info", "warning", "error"] = "info",
   798	    ) -> None:
   799	        normalized = content.strip()
   800	        if not normalized:
   801	            return
   802	        if self._should_drop_duplicate_system_message(
   803	            content=normalized,
   804	            severity=severity,
   805	        ):
   806	            return
   807	        self._flush_assistant_segment()
   808	        self._append_history_entry(
   809	            HistoryEntry(entry_type="system", text=normalized, severity=severity)
   810	        )
   811	
   812	    def enqueue_log(
   813	        self,
   814	        *,
   815	        severity: Literal["warning", "error"],
   816	        message: str,
   817	        log_subtype: LogSubtype = "background",
   818	    ) -> None:
   819	        """线程安全入口：只入队，不调度 prompt_toolkit，不立即写 scrollback。"""
   820	        normalized = message.strip()
   821	        if not normalized:
   822	            return
   823	        with self._pending_logs_lock:
   824	            self._pending_logs.append((severity, normalized, log_subtype))
   825	
   826	    def flush_pending_logs(self) -> None:
   827	        """Drain pending log，按 log_subtype 决定是否写入 scrollback。"""
   828	        with self._pending_logs_lock:
   829	            if not self._pending_logs:
   830	                return
   831	            drained = list(self._pending_logs)
   832	            self._pending_logs.clear()
   833	
   834	        for severity, message, log_subtype in drained:
   835	            if log_subtype == "transient":
   836	                continue
   837	            self._append_history_entry(
   838	                HistoryEntry(
   839	                    entry_type="log",
   840	                    text=message,
   841	                    severity=severity,
   842	                    attached=False,
   843	                    log_subtype=log_subtype,
   844	                )
   845	            )
   846	
   847	    @staticmethod
   848	    def _team_event_key(
   849	        *,
   850	        agent_name: str,
   851	        from_agent: str,
   852	        to_agent: str | None,
   853	        message_type: str,
   854	        timestamp: str,
   855	        content_preview: str,
   856	    ) -> tuple[str, str, str, str, str, str]:
   857	        return (
   858	            str(agent_name or "").strip(),
   859	            str(from_agent or "").strip(),
   860	            str(to_agent or "").strip(),
   861	            str(message_type or "").strip(),
   862	            str(timestamp or "").strip(),
   863	            str(content_preview or "").strip(),
   864	        )
   865	
   866	    def append_team_message_event(
   867	        self,
   868	        *,
   869	        agent_name: str,
   870	        from_agent: str,
   871	        to_agent: str | None,
   872	        message_type: str,
   873	        content_preview: str,
   874	        timestamp: str,
   875	    ) -> bool:
   876	        event_key = self._team_event_key(
   877	            agent_name=agent_name,
   878	            from_agent=from_agent,
   879	            to_agent=to_agent,
   880	            message_type=message_type,
   881	            timestamp=timestamp,
   882	            content_preview=content_preview,
   883	        )
   884	        if event_key in self._recent_team_event_keys:
   885	            logger.debug(
   886	                "[team-diag] renderer_dedupe "
   887	                "agent=%r from=%r to=%r type=%r timestamp=%r preview=%r",
   888	                agent_name,
   889	                from_agent,
   890	                to_agent,
   891	                message_type,
   892	                timestamp,
   893	                content_preview,
   894	            )
   895	            return False
   896	
   897	        self._recent_team_event_keys.append(event_key)
   898	        target = to_agent or "[broadcast]"
   899	        preview_str = f' "{content_preview}"' if content_preview else ""
   900	        text = f"[team] {from_agent} {INJECTED_ARROW} {target}: ({message_type}){preview_str}"
   901	        logger.debug(
   902	            "[team-diag] renderer_append "
   903	            "agent=%r from=%r to=%r type=%r timestamp=%r preview=%r history_len_before=%d",
   904	            agent_name,
   905	            from_agent,
   906	            to_agent,
   907	            message_type,
   908	            timestamp,
   909	            content_preview,
   910	            len(self._history),
   911	        )
   912	        self.append_system_message(text)
   913	        return True
   914	
   915	    def append_elapsed_message(self, content: str) -> None:
   916	        """追加一条灰色无前缀的计时统计行到 history scrollback."""
   917	        normalized = content.strip()
   918	        if not normalized:
   919	            return
   920	        self._flush_assistant_segment()
   921	        self._append_history_entry(HistoryEntry(entry_type="elapsed", text=normalized))
   922	
   923	    def append_assistant_message(self, content: str) -> None:
   924	        normalized = content.strip()
   925	        if not normalized:
   926	            return
   927	        self._flush_assistant_segment()
   928	        self._append_history_entry(HistoryEntry(entry_type="assistant", text=normalized))
   929	
   930	    def tool_panel_entries(
   931	        self, *, max_lines: int | None = None
   932	    ) -> list[tuple[int, str | list[tuple[str, str]]]]:
   933	        """Return panel entries for running tools.
   934	
   935	        Each entry is a tuple: (indent_level, content).
   936	        content is either a plain str or a list of (style, text) prompt_toolkit fragments.
   937	        indent_level == 0 means a primary tool line; >0 means nested status.
   938	        indent_level < 0 means a meta line (no dot prefix).
   939	        """
   940	        limit = max_lines if max_lines is not None else self._tool_panel_max_lines
   941	        normalized_limit = max(1, int(limit))
   942	        if not self._running_tools:
   943	            return []
   944	
   945	        now = time.monotonic()
   946	        entries: list[tuple[int, str]] = []
   947	        tool_items = list(self._running_tools.items())
   948	        for idx, (tool_call_id, state) in enumerate(tool_items):
   949	            elapsed = _format_duration(now - state.started_at_monotonic)
   950	            lines_to_add = 1
   951	            if state.is_task:
   952	                tokens_suffix = (
   953	                    f" {BULLET_OPERATOR} {_format_tokens(state.progress_tokens)}"
   954	                    if state.progress_tokens > 0
   955	                    else ""
   956	                )
   957	                # 主标题行 + 嵌套工具（最多 3 个）+ 状态行（如果有状态）
   958	                lines_to_add = 1 + min(len(state.nested_tools), 3)
   959	                if state.show_init:
   960	                    lines_to_add += 1
   961	            else:
   962	                lines_to_add = 1
   963	
   964	            if len(entries) + lines_to_add > normalized_limit:
   965	                remaining_tools = len(tool_items) - idx
   966	                entries.append((-1, f"{ELLIPSIS} (+{remaining_tools})"))
   967	                break
   968	
   969	            if state.is_task:
   970	                tokens_suffix = (
   971	                    f" {BULLET_OPERATOR} {_format_tokens(state.progress_tokens)}"
   972	                    if state.progress_tokens > 0
   973	                    else ""
   974	                )
   975	                # 格式：SubagentName(描述) ∙ model(dim) ∙ elapsed ∙ tools ∙ tokens
   976	                subagent_name = state.subagent_name or "Agent"
   977	                description = state.subagent_description or state.title
   978	                title = f"{subagent_name}({description})"
   979	                tool_count = len(state.nested_tools)
   980	                tool_count_suffix = f" {BULLET_OPERATOR} +{tool_count} tool uses" if tool_count > 0 else ""
   981	                rest = f" {BULLET_OPERATOR} {elapsed}{tool_count_suffix}{tokens_suffix}"
   982	                if state.subagent_model_name:
   983	                    # Return styled fragments: model_name in dim
   984	                    frags: list[tuple[str, str]] = [
   985	                        ("", title),
   986	                        ("class:dim", f" {BULLET_OPERATOR} {state.subagent_model_name}"),
   987	                        ("", rest),
   988	                    ]
   989	                    entries.append((0, frags))
   990	                else:
   991	                    entries.append((0, f"{title}{rest}"))
   992	
   993	                # 嵌套工具调用（最多显示最近 3 个）
   994	                for child_id, child_tool in state.nested_tools[-3:]:
   995	                    child_display = resolve_display_tool_name(child_tool.tool_name, {})
   996	                    signature = _tool_signature(child_display, child_tool.args_summary)
   997	                    if child_tool.status == "running":
   998	                        entries.append((1, f"{BOTTOM_LEFT_CROP} {signature}"))
   999	                    else:
  1000	                        icon = CHECK_MARK if child_tool.status == "completed" else CROSS_MARK
  1001	                        entries.append((1, f"{BOTTOM_LEFT_CROP} {icon} {signature}"))
  1002	
  1003	                if state.show_init:
  1004	                    entries.append((1, f"{BOTTOM_LEFT_CROP} init"))
  1005	            else:
  1006	                display_name = state.display_tool_name or state.tool_name
  1007	                signature = _tool_signature(display_name, state.args_summary)
  1008	                entries.append((0, f"{signature} {BULLET_OPERATOR} {elapsed}"))
  1009	
  1010	        return entries[:normalized_limit]
  1011	
  1012	    def task_panel_lines(self, *, max_lines: int | None = None) -> list[str]:
  1013	        lines = self.full_task_panel_lines()
  1014	        if not lines:
  1015	            return []
  1016	
  1017	        limit = max_lines if max_lines is not None else self._task_panel_max_lines
  1018	        normalized_limit = max(1, int(limit))
  1019	        if len(lines) <= normalized_limit:
  1020	            return lines
  1021	
  1022	        clipped = lines[: normalized_limit - 1]
  1023	        clipped.append(f"{ELLIPSIS} (+{len(lines) - (normalized_limit - 1)})")
  1024	        return clipped
  1025	
  1026	    def full_task_panel_lines(self) -> list[str]:
  1027	        tasks = list(self._current_tasks)
  1028	        if not tasks:
  1029	            return []
  1030	
  1031	        header = str(self._current_task_title or "").strip()
  1032	        if not header:
  1033	            header = _task_stats_header(tasks)
  1034	        lines: list[str] = [header]
  1035	        for task in sorted(tasks, key=_task_sort_key):
  1036	            lines.append(_format_task_row(task))
  1037	        return lines
  1038	
  1039	    def _flush_assistant_segment(self) -> None:
  1040	        if not self._assistant_buffer:
  1041	            return
  1042	        self._append_history_entry(
  1043	            HistoryEntry(entry_type="assistant", text=self._assistant_buffer)
  1044	        )
  1045	        self._assistant_buffer = ""
  1046	        self.flush_pending_logs()
  1047	
  1048	    def _append_assistant_text(self, text: str) -> None:
  1049	        self._assistant_buffer += text
  1050	
  1051	    def _append_tool_call(self, tool_name: str, args: dict[str, Any], tool_call_id: str) -> None:
  1052	        self._running_tools[tool_call_id] = self._make_running_tool(tool_name, args)
  1053	
  1054	    def append_static_tool_result(
  1055	        self,
  1056	        signature: str,
  1057	        is_error: bool = False,
  1058	        diff_lines: list[str] | None = None,
  1059	        model_name: str = "",
  1060	        subtitle: str | None = None,
  1061	    ) -> None:
  1062	        """Append a tool result to history as a static entry (no timer).
  1063	
  1064	        Args:
  1065	            signature: 工具签名，例如 "Read(path=xxx)"
  1066	            is_error: 是否为错误结果
  1067	            diff_lines: optional diff lines for Edit
  1068	            model_name: model name for Agent tools (rendered dim)
  1069	            subtitle: optional subtitle rendered as `⎿ ...`
  1070	        """
  1071	        sev: Literal["info", "warning", "error"] = "error" if is_error else "info"
  1072	        if not is_error and diff_lines and len(diff_lines) > 0:
  1073	            text_obj = Text(signature)
  1074	            if model_name:
  1075	                text_obj.append(f" {BULLET_OPERATOR} {model_name}", style="dim")
  1076	            text_obj.append("\n")
  1077	            text_obj.append(render_diff_text(diff_lines))
  1078	            self._append_history_entry(
  1079	                HistoryEntry(entry_type="tool_result", text=text_obj, severity="info", subtitle=subtitle)
  1080	            )
  1081	            return
  1082	        if model_name:
  1083	            text_obj = Text(signature)
  1084	            text_obj.append(f" {BULLET_OPERATOR} {model_name}", style="dim")
  1085	            self._append_history_entry(
  1086	                HistoryEntry(entry_type="tool_result", text=text_obj, severity=sev, subtitle=subtitle)
  1087	            )
  1088	            return
  1089	        self._append_history_entry(
  1090	            HistoryEntry(
  1091	                entry_type="tool_result",
  1092	                text=signature,
  1093	                severity=sev,
  1094	                subtitle=subtitle,
  1095	            )
  1096	        )
  1097	
  1098	    def _make_running_tool(self, tool_name: str, args: dict[str, Any]) -> _RunningTool:
  1099	        """Create a _RunningTool from tool name and args dict."""
  1100	        title = tool_name
  1101	        is_task = tool_name.lower() == "agent"
  1102	        summary = summarize_tool_args(tool_name, args, self._project_root).strip()
  1103	        display_name = resolve_display_tool_name(tool_name, args)
  1104	        subagent_name = ""
  1105	        subagent_description = ""
  1106	        if is_task:
  1107	            title = _extract_task_title(args)
  1108	            summary = ""
  1109	            subagent_name = str(args.get("subagent_type", "")).strip() or "Agent"
  1110	            subagent_description = str(args.get("description", "")).strip()
  1111	
  1112	        return _RunningTool(
  1113	            tool_name=tool_name,
  1114	            display_tool_name=display_name,
  1115	            title=title,
  1116	            started_at_monotonic=time.monotonic(),
  1117	            is_task=is_task,
  1118	            args_summary=summary,
  1119	            show_init=is_task,
  1120	            subagent_name=subagent_name,
  1121	            subagent_description=subagent_description,
  1122	        )
  1123	
  1124	    def _append_tool_result(
  1125	        self,
  1126	        tool_name: str,
  1127	        tool_call_id: str,
  1128	        is_error: bool,
  1129	        result: Any,
  1130	        metadata: dict[str, Any] | None = None,
  1131	        output: Any = None,
  1132	        args: dict[str, Any] | None = None,
  1133	    ) -> None:
  1134	        sev: Literal["info", "warning", "error"] = "error" if is_error else "info"
  1135	        state = self._running_tools.pop(tool_call_id, None)
  1136	        args = dict(args or {})
  1137	        if state is None:
  1138	            display_name = resolve_display_tool_name(tool_name, args)
  1139	            rec = self._make_tool_result_record(
  1140	                tool_call_id=tool_call_id,
  1141	                tool_name=tool_name,
  1142	                display_name=display_name,
  1143	                started_at_monotonic=time.monotonic(),
  1144	                args=args,
  1145	                is_error=is_error,
  1146	                result=result,
  1147	                metadata=metadata,
  1148	                output=output,
  1149	            )
  1150	            text, subtitle = scrollback_preview(rec)
  1151	            self._append_history_entry(
  1152	                HistoryEntry(entry_type="tool_result", text=text, severity=sev, subtitle=subtitle)
  1153	            )
  1154	            return
  1155	
  1156	        if state.is_task:
  1157	            subagent_name = state.subagent_name or "Agent"
  1158	            description = state.subagent_description or state.title
  1159	            if description and description != subagent_name:
  1160	                task_title = f"{subagent_name}({description})"
  1161	            else:
  1162	                task_title = subagent_name
  1163	
  1164	            model_name = ""
  1165	            if metadata and isinstance(metadata, dict):
  1166	                model_name = metadata.get("model_name", "")
  1167	
  1168	            tool_count = len(state.nested_tools)
  1169	            display_name = state.display_tool_name or state.tool_name
  1170	            if model_name:
  1171	                metadata = dict(metadata or {})
  1172	                metadata["model_name"] = model_name
  1173	        else:
  1174	            display_name = state.display_tool_name or state.tool_name
  1175	            subagent_name = ""
  1176	            description = ""
  1177	            tool_count = 0
  1178	
  1179	        rec = self._make_tool_result_record(
  1180	            tool_call_id=tool_call_id,
  1181	            tool_name=tool_name,
  1182	            display_name=display_name,
  1183	            started_at_monotonic=state.started_at_monotonic,
  1184	            args=args,
  1185	            is_error=is_error,
  1186	            result=result,
  1187	            metadata=metadata,
  1188	            output=output,
  1189	            subagent_name=subagent_name,
  1190	            subagent_description=description,
  1191	            nested_tool_count=tool_count,
  1192	        )
  1193	        text, subtitle = scrollback_preview(rec)
  1194	        self._append_history_entry(
  1195	            HistoryEntry(entry_type="tool_result", text=text, severity=sev, subtitle=subtitle)
  1196	        )
  1197	
  1198	    def _rebuild_loading_line(self) -> None:
  1199	        self._loading_state = LoadingState.idle()
  1200	
  1201	    def _append_questions(self, questions: list[dict[str, Any]]) -> None:
  1202	        if not questions:
  1203	            return
  1204	        self._append_history_entry(
  1205	            HistoryEntry(
  1206	                entry_type="tool_result",
  1207	                text=f"Input required: {len(questions)} question(s) pending.",
  1208	            )
  1209	        )
  1210	        for idx, question in enumerate(questions, 1):
  1211	            question_text = str(question.get("question", "")).strip()
  1212	            header = str(question.get("header", f"Question {idx}")).strip()
  1213	            options = question.get("options", [])
  1214	            labels: list[str] = []
  1215	            if isinstance(options, list):
  1216	                for option in options[:3]:
  1217	                    if not isinstance(option, dict):
  1218	                        continue
  1219	                    label = str(option.get("label", "")).strip()
  1220	                    if label:
  1221	                        labels.append(label)
  1222	            choice_preview = f" (Options: {' / '.join(labels)})" if labels else ""
  1223	            self._append_history_entry(
  1224	                HistoryEntry(
  1225	                    entry_type="tool_result",
  1226	                    text=f"  {idx}. {header}: {question_text} {choice_preview}".strip(),
  1227	                )
  1228	            )
  1229	
  1230	    def _update_tasks(self, tasks: list[dict[str, Any]], *, list_id: str) -> None:
  1231	        """更新当前共享任务列表状态。"""
  1232	        normalized = list(tasks) if tasks else []
  1233	        if not normalized:
  1234	            self._current_tasks = []
  1235	            self._current_task_title = None
  1236	            self._task_started_at_monotonic = None
  1237	            return
  1238	
  1239	        all_completed = all(str(item.get("status", "")).strip().lower() == "completed" for item in normalized)
  1240	        # 标题：显示 ID 最小的 in_progress task 的 subject，否则统计摘要
  1241	        in_progress_tasks = [
  1242	            t for t in normalized
  1243	            if str(t.get("status", "")).strip().lower() == "in_progress"
  1244	        ]
  1245	        if in_progress_tasks:
  1246	            in_progress_tasks.sort(key=lambda t: int(t.get("id", 0)))
  1247	            header = str(in_progress_tasks[0].get("subject", "")).strip() or "Tasks"
  1248	        else:
  1249	            header = _task_stats_header(normalized)
  1250	        if not all_completed:
  1251	            if not self._current_tasks:
  1252	                self._task_started_at_monotonic = time.monotonic()
  1253	            self._current_task_title = header
  1254	            self._current_tasks = normalized
  1255	            return
  1256	
  1257	        # All completed: hide panel and write a summary entry once.
  1258	        # 守卫：_current_tasks 已空说明完成总结已写过，跳过重复写入
  1259	        if not self._current_tasks:
  1260	            return
  1261	        started = self._task_started_at_monotonic
  1262	        elapsed_suffix = ""
  1263	        if started is not None:
  1264	            elapsed_suffix = f" {BULLET_OPERATOR} {_format_duration(time.monotonic() - started)}"
  1265	        total = len(normalized)
  1266	        self._append_history_entry(
  1267	            HistoryEntry(
  1268	                entry_type="tool_result",
  1269	                text=f"tasks {total}/{total} completed{elapsed_suffix}",
  1270	                severity="info",
  1271	            )
  1272	        )
  1273	        self._current_tasks = []
  1274	        self._current_task_title = None
  1275	        self._task_started_at_monotonic = None
  1276	
  1277	    def task_renderable(self) -> RenderableType | None:
  1278	        """将当前任务工作集渲染为 Rich 组件。
  1279	
  1280	        Returns:
  1281	            Rich RenderableType 或 None（如果没有 task）
  1282	        """
  1283	        if not self._current_tasks:
  1284	            return None
  1285	
  1286	        tasks = list(sorted(self._current_tasks, key=_task_sort_key))
  1287	        total = len(tasks)
  1288	        completed = sum(1 for task in tasks if task.get("status") == "completed")
  1289	
  1290	        # 构建 Rich Text 组件
  1291	        result = Text()
  1292	
  1293	        # 标题
  1294	        result.append(f"{self._current_task_title or 'Tasks'} ({completed}/{total} completed)\n")
  1295	
  1296	        for task in tasks:
  1297	            line = _format_task_row(task)
  1298	            if line.startswith(f"{CHECK_MARK} "):
  1299	                result.append(f"  {CHECK_MARK} ")
  1300	                result.append(line[2:], style="green strike")
  1301	            elif line.startswith(f"{TASK_IN_PROGRESS} "):
  1302	                result.append(f"  {TASK_IN_PROGRESS} ")
  1303	                result.append(line[2:], style="#F97316")
  1304	            elif line.startswith(f"{TASK_BLOCKED} "):
  1305	                # blocked pending: display as TASK_PENDING glyph + dim
  1306	                result.append(f"  {TASK_PENDING} ")
  1307	                result.append(line[2:], style="dim")
  1308	            elif line.startswith(f"{TASK_PENDING} "):
  1309	                result.append(f"  {TASK_PENDING} ")
  1310	                result.append(line[2:])
  1311	            else:
  1312	                result.append(f"  {line}")
  1313	            result.append("\n")
  1314	
  1315	        # 移除末尾的换行
  1316	        if result.plain.endswith("\n"):
  1317	            result = result[:-1]
  1318	
  1319	        return result
  1320	
  1321	    def task_lines(self) -> list[str]:
  1322	        """返回当前任务面板行列表（用于测试）。"""
  1323	        return self.task_panel_lines(max_lines=6)
  1324	
  1325	    def todo_panel_lines(self, *, max_lines: int | None = None) -> list[str]:
  1326	        return self.task_panel_lines(max_lines=max_lines)
  1327	
  1328	    def todo_all_lines(self) -> list[str]:
  1329	        return self.full_task_panel_lines()
  1330	
  1331	    def todo_renderable(self) -> RenderableType | None:
  1332	        return self.task_renderable()
  1333	
  1334	    def todo_lines(self) -> list[str]:
  1335	        return self.task_lines()
  1336	
  1337	    def handle_event(self, event: Any) -> tuple[bool, list[dict[str, Any]] | None]:
  1338	        if not isinstance(
  1339	            event,
  1340	            (TextEvent, TextDeltaEvent, ThinkingDeltaEvent, ToolCallStartEvent),
  1341	        ):
  1342	            self._flush_assistant_segment()
  1343	
  1344	        match event:
  1345	            case SessionInitEvent(session_id=_):
  1346	                pass
  1347	            case TextDeltaEvent(delta=delta, message_id=message_id):
  1348	                if delta:
  1349	                    normalized_message_id = str(message_id or "").strip()
  1350	                    if normalized_message_id and normalized_message_id != self._active_text_message_id:
  1351	                        self._active_text_message_id = normalized_message_id
  1352	                        self._text_delta_started = False
  1353	                    # spec §6.1：text delta → 累到 _text_pending → 按 \n 切完整行入队
  1354	                    self._consume_text_delta(delta)
  1355	                    self._turn_received_text_delta = True
  1356	            case ThinkingDeltaEvent(delta=delta, message_id=_):
  1357	                if delta:
  1358	                    # spec §6.1：thinking delta → 按 \n 切完整行入队，残段留 batch
  1359	                    self._turn_received_thinking_delta = True
  1360	                    self._consume_thinking_delta(delta)
  1361	            case ToolCallStartEvent(tool_call_id=tool_call_id, tool=tool_name):
  1362	                # tool 调用前先把 _text_pending / _thinking_batch 落到 scrollback，
  1363	                # 保证说明文字出现在 tool UI 之前（无 \n 结尾的模型也适用）。
  1364	                self._force_flush_all()
  1365	                normalized_id = str(tool_call_id or "").strip()
  1366	                if normalized_id and normalized_id not in self._pending_tool_starts:
  1367	                    self._pending_tool_starts.add(normalized_id)
  1368	            case ThinkingEvent(content=thinking):
  1369	                if self._turn_received_thinking_delta:
  1370	                    pass
  1371	                elif self._show_thinking_cb():
  1372	                    self._append_history_entry(
  1373	                        HistoryEntry(entry_type="thinking", text=thinking)
  1374	                    )
  1375	            case CompactionResultEvent(
  1376	                current_tokens=tokens,
  1377	                threshold=threshold,
  1378	                trigger=trigger,
  1379	                attempted=attempted,
  1380	                compacted=compacted,
  1381	                reason=reason,
  1382	                tokens_before=tokens_before,
  1383	                tokens_after=tokens_after,
  1384	            ):
  1385	                pct = int(tokens / threshold * 100) if threshold > 0 else 0
  1386	                if compacted:
  1387	                    self.append_system_message(
  1388	                        (
  1389	                            f"Context compaction completed ({trigger}): "
  1390	                            f"{tokens_before:,} {INJECTED_ARROW} {tokens_after:,} tokens "
  1391	                            f"(check {tokens:,}/{threshold:,}, {pct}%)"
  1392	                        ),
  1393	                        severity="info",
  1394	                    )
  1395	                elif attempted:
  1396	                    is_recoverable_deferred = str(reason).startswith("partial_compact_no_op:")
  1397	                    label = (
  1398	                        "Context compaction deferred"
  1399	                        if is_recoverable_deferred
  1400	                        else "Context compaction attempt failed"
  1401	                    )
  1402	                    self.append_system_message(
  1403	                        (
  1404	                            f"{label} ({trigger}): "
  1405	                            f"kept {tokens_after:,} tokens "
  1406	                            f"(check {tokens:,}/{threshold:,}, {pct}%, reason={reason})"
  1407	                        ),
  1408	                        severity="warning",
  1409	                    )
  1410	                else:
  1411	                    self.append_system_message(
  1412	                        (
  1413	                            f"Context compaction skipped ({trigger}): "
  1414	                            f"{tokens:,}/{threshold:,} tokens ({pct}%), reason={reason}"
  1415	                        ),
  1416	                        severity="warning",
  1417	                    )
  1418	            case ToolCallEvent(tool=tool_name, args=arguments, tool_call_id=tool_call_id):
  1419	                args_dict = arguments if isinstance(arguments, dict) else {"_raw": str(arguments)}
  1420	                # Store args for ToolResult phase lookup.
  1421	                self._tool_call_args[tool_call_id] = args_dict
  1422	                if not should_show_tool_in_scrollback(tool_name, args_dict):
  1423	                    self._rebuild_loading_line()
  1424	                    return (False, None)
  1425	                self._append_tool_call(tool_name, args_dict, tool_call_id)
  1426	            case ToolResultEvent(tool=tool_name, result=result, tool_call_id=tool_call_id, is_error=is_error, metadata=metadata, output=output):
  1427	                stored_args = self._tool_call_args.pop(tool_call_id, {})
  1428	                if not should_show_tool_in_scrollback(tool_name, stored_args, is_result=True, is_error=is_error):
  1429	                    self._running_tools.pop(tool_call_id, None)
  1430	                    self._rebuild_loading_line()
  1431	                    return (False, None)
  1432	                self._append_tool_result(
  1433	                    tool_name=tool_name,
  1434	                    tool_call_id=tool_call_id,
  1435	                    is_error=is_error,
  1436	                    result=result,
  1437	                    metadata=metadata,
  1438	                    output=output,
  1439	                    args=stored_args,
  1440	                )
  1441	            case UsageDeltaEvent(
  1442	                source=_,
  1443	                model=_,
  1444	                level=_,
  1445	                delta_prompt_tokens=_,
  1446	                delta_prompt_cached_tokens=_,
  1447	                delta_completion_tokens=_,
  1448	                delta_total_tokens=_,
  1449	            ):
  1450	                pass
  1451	            case SubagentStartEvent(tool_call_id=_, subagent_name=_, description=_):
  1452	                pass
  1453	            case SubagentProgressEvent(
  1454	                tool_call_id=tool_call_id,
  1455	                subagent_name=subagent_name,
  1456	                description=description,
  1457	                status=status,
  1458	                elapsed_ms=elapsed_ms,
  1459	                tokens=tokens,
  1460	                model_name=model_name,
  1461	            ):
  1462	                state = self._running_tools.get(tool_call_id)
  1463	                if state is not None:
  1464	                    if tokens is not None:
  1465	                        state.progress_tokens = max(int(tokens), 0)
  1466	                    if elapsed_ms is not None:
  1467	                        normalized = max(float(elapsed_ms), 0.0)
  1468	                        state.started_at_monotonic = time.monotonic() - (normalized / 1000)
  1469	                    # Subagent-specific status for task tool panel.
  1470	                    state.subagent_name = str(subagent_name or "").strip()
  1471	                    state.subagent_status = str(status or "").strip()
  1472	                    state.subagent_description = str(description or "").strip()
  1473	                    if model_name:
  1474	                        state.subagent_model_name = str(model_name)
  1475	                    # 首个有效子事件后移除 init 占位。
  1476	                    progress_status = str(status or "").strip().lower()
  1477	                    has_activity = bool(tokens and int(tokens) > 0) or bool(
  1478	                        elapsed_ms and float(elapsed_ms) > 0.0
  1479	                    )
  1480	                    if has_activity or progress_status in {"completed", "error", "timeout", "cancelled"}:
  1481	                        state.show_init = False
  1482	            case SubagentStopEvent(tool_call_id=_, subagent_name=_, status=_, duration_ms=_, error=_):
  1483	                pass
  1484	            case SubagentToolCallEvent(
  1485	                parent_tool_call_id=parent_tool_call_id,
  1486	                subagent_name=_,
  1487	                tool=tool,
  1488	                args=args,
  1489	                tool_call_id=tool_call_id,
  1490	            ):
  1491	                # 将嵌套工具调用添加到父 Agent 的 nested_tools
  1492	                parent_state = self._running_tools.get(parent_tool_call_id)
  1493	                if parent_state is not None:
  1494	                    args_summary = summarize_tool_args(tool, args, self._project_root).strip()
  1495	                    nested_tool = _SubagentTool(
  1496	                        tool_name=tool,
  1497	                        args_summary=args_summary,
  1498	                        started_at_monotonic=time.monotonic(),
  1499	                        status="running",
  1500	                    )
  1501	                    parent_state.nested_tools.append((tool_call_id, nested_tool))
  1502	                    parent_state.show_init = False
  1503	            case SubagentToolResultEvent(
  1504	                parent_tool_call_id=parent_tool_call_id,
  1505	                subagent_name=_,
  1506	                tool=_,
  1507	                tool_call_id=tool_call_id,
  1508	                is_error=is_error,
  1509	                duration_ms=duration_ms,
  1510	            ):
  1511	                # 更新嵌套工具的状态
  1512	                parent_state = self._running_tools.get(parent_tool_call_id)
  1513	                if parent_state is not None:
  1514	                    for nested_id, nested_tool in parent_state.nested_tools:
  1515	                        if nested_id == tool_call_id:
  1516	                            nested_tool.status = "error" if is_error else "completed"
  1517	                            nested_tool.duration_ms = duration_ms
  1518	                            parent_state.show_init = False
  1519	                            break
  1520	            case TaskUpdatedEvent(list_id=list_id, tasks=tasks):
  1521	                self._update_tasks(tasks, list_id=list_id)
  1522	            case UserQuestionEvent(questions=questions, tool_call_id=_):
  1523	                self._append_questions(questions)
  1524	                self._rebuild_loading_line()
  1525	                return (True, questions)
  1526	            case TextEvent(content=text):
  1527	                if self._turn_received_text_delta:
  1528	                    pass
  1529	                elif text:
  1530	                    self._append_assistant_text(text)
  1531	            case StepCompleteEvent(step_id=step_id, status=_, duration_ms=_):
  1532	                # Cancellation/error paths may emit StepCompleteEvent without ToolResultEvent.
  1533	                # Ensure tool panel state is cleaned up by step id.
  1534	                self._running_tools.pop(step_id, None)
  1535	            case StopEvent(reason=reason):
  1536	                self._flush_assistant_segment()
  1537	                self._pending_tool_starts.clear()
  1538	                # Safety net: if any running tool rows remain, stop event means this turn is ending.
  1539	                self._running_tools.clear()
  1540	                self._rebuild_loading_line()
  1541	                if reason == "waiting_for_input":
  1542	                    return (True, None)
  1543	                if reason == "waiting_for_plan_approval":
  1544	                    return (False, None)
  1545	                if reason == "interrupted" and "error" not in event.metadata:
  1546	                    self._append_history_entry(
  1547	                        HistoryEntry(entry_type="system", text="Current task interrupted.", severity="warning")
  1548	                    )
  1549	            case PlanApprovalRequiredEvent(
  1550	                plan_path=plan_path,
  1551	                summary=summary,
  1552	                execution_prompt=_,
  1553	                plan_markdown=plan_markdown,
  1554	            ):
  1555	                # 在 scrollback 渲染计划内容，让用户审阅后再决策。
  1556	                # 优先使用事件携带正文，避免依赖本地二次读文件。
  1557	                plan_content = str(plan_markdown or "")
  1558	                if not plan_content:
  1559	                    try:
  1560	                        plan_content = Path(plan_path).read_text(encoding="utf-8")
  1561	                    except Exception:
  1562	                        logger.warning("ExitPlanMode: Failed to read plan file %s", plan_path)
  1563	                if plan_content:
  1564	                    self._append_history_entry(
  1565	                        HistoryEntry(
  1566	                            entry_type="system",
  1567	                            text=f"{HEAVY_HORIZONTAL * 3} Here is the plan, please review and approve or reject {HEAVY_HORIZONTAL * 3}",
  1568	                        )
  1569	                    )
  1570	                    self._append_history_entry(HistoryEntry(entry_type="assistant", text=plan_content))
  1571	
  1572	                text = f"Plan ready for review: {plan_path}"
  1573	                if summary:
  1574	                    text = f"{text} | {summary}"
  1575	                self._append_history_entry(
  1576	                    HistoryEntry(entry_type="system", text=text)
  1577	                )
  1578	            case TeamMessageEvent(
  1579	                agent_name=agent_name,
  1580	                from_agent=from_agent,
  1581	                to_agent=to_agent,
  1582	                message_type=message_type,
  1583	                content_preview=content_preview,
  1584	                timestamp=timestamp,
  1585	            ):
  1586	                self.append_team_message_event(
  1587	                    agent_name=agent_name,
  1588	                    from_agent=from_agent,
  1589	                    to_agent=to_agent,
  1590	                    message_type=message_type,
  1591	                    content_preview=content_preview,
  1592	                    timestamp=timestamp,
  1593	                )
  1594	            case _:
  1595	                logger.debug("Unhandled event type: %s", type(event).__name__)
  1596	
  1597	        self._rebuild_loading_line()
  1598	        return (False, None)