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

Lines 1-1829 of 1829

     1	"""Terminal Agent TUI implementation.
     2	
     3	This module was split out from `terminal_agent.app` to keep the entrypoint small.
     4	"""
     5	
     6	from __future__ import annotations
     7	
     8	import asyncio
     9	import logging
    10	import random
    11	import time
    12	from collections.abc import Callable
    13	from contextlib import suppress
    14	from pathlib import Path
    15	from typing import Any
    16	
    17	from prompt_toolkit.application import Application, run_in_terminal
    18	from prompt_toolkit.completion import (
    19	    ThreadedCompleter,
    20	    merge_completers,
    21	)
    22	from prompt_toolkit.filters import Condition, has_completions, has_focus
    23	from prompt_toolkit.history import FileHistory, InMemoryHistory
    24	from prompt_toolkit.layout import FloatContainer, HSplit, Layout, Window
    25	from prompt_toolkit.layout.containers import ConditionalContainer
    26	from prompt_toolkit.layout.controls import FormattedTextControl
    27	from prompt_toolkit.patch_stdout import patch_stdout
    28	from prompt_toolkit.styles import Style as PTStyle
    29	from prompt_toolkit.utils import get_cwidth
    30	from prompt_toolkit.widgets import TextArea
    31	
    32	from comate_agent_sdk.agent import ChatSession
    33	from comate_agent_sdk.agent.events import (
    34	    ExternalTurnScheduledEvent,
    35	    PlanApprovalRequiredEvent,
    36	    QueueDirtyEvent,
    37	    QueueDirtyReason,
    38	    SessionInitEvent,
    39	    StopEvent,
    40	    TeamMessageEvent,
    41	)
    42	from comate_agent_sdk.agent.queue_types import MessageOrigin
    43	
    44	from comate_cli.terminal_agent.animations import (
    45	    DEFAULT_STATUS_PHRASES,
    46	    StreamAnimationController,
    47	    SubmissionAnimator,
    48	)
    49	from comate_cli.terminal_agent.env_utils import read_env_float, read_env_int
    50	from comate_cli.terminal_agent.event_renderer import EventRenderer
    51	from comate_cli.terminal_agent.figures import ELLIPSIS, HEAVY_HORIZONTAL, UP_ARROW
    52	from comate_cli.terminal_agent.mention_completer import LocalFileMentionCompleter
    53	from comate_cli.terminal_agent.path_context_hint import build_path_context_hint
    54	from comate_cli.terminal_agent.custom_slash_commands import (
    55	    CustomSlashCommand,
    56	    discover_custom_slash_commands,
    57	)
    58	from comate_cli.terminal_agent.question_view import AskUserQuestionUI
    59	from comate_cli.terminal_agent.tool_result_store import ToolResultRegistry
    60	from comate_cli.terminal_agent.plugins.marketplace_install_view import MarketplaceInstallView
    61	from comate_cli.terminal_agent.tui_parts.btw_view import BtwView
    62	from comate_cli.terminal_agent.tui_parts.mcp_connecting_view import McpConnectingView
    63	from comate_cli.terminal_agent.plugins.plugin_picker import PluginPickerUI
    64	from comate_cli.terminal_agent.selection_menu import (
    65	    SelectionMenuUI,
    66	)
    67	from comate_cli.terminal_agent.slash_commands import (
    68	    SLASH_COMMAND_SPECS,
    69	    SlashArgumentHintAutoSuggest,
    70	    SlashCommandCompleter,
    71	    SlashCommandSpec,
    72	)
    73	from comate_cli.terminal_agent.status_bar import StatusBar
    74	from comate_cli.terminal_agent.tips import LOADING_TIPS
    75	from comate_cli.terminal_agent.tui_parts import (
    76	    CommandsMixin,
    77	    HistorySyncMixin,
    78	    InputBehaviorMixin,
    79	    KeyBindingsMixin,
    80	    RenderPanelsMixin,
    81	    SlashCommandRegistry,
    82	    UIMode,
    83	)
    84	
    85	logger = logging.getLogger(__name__)
    86	
    87	_LOADING_TIP_DELAY_SECONDS = 2.5
    88	
    89	_TASK_POLL_INTERVAL_S = 2.0
    90	_INPUT_HISTORY_DIR = Path.home() / ".agent" / "history"
    91	_INPUT_HISTORY_MAX_ENTRIES = 200
    92	
    93	
    94	def _get_input_history_path(cwd: str) -> Path:
    95	    """Compute FileHistory path for a given cwd, ensuring parent dir exists."""
    96	    import hashlib
    97	    import re
    98	
    99	    slug = re.sub(r'[^a-zA-Z0-9_.\-]', '_', cwd).lstrip("_")
   100	    if len(slug) > 200:
   101	        hash_suffix = hashlib.sha1(cwd.encode()).hexdigest()[:8]
   102	        slug = f"{slug[:100]}_{hash_suffix}"
   103	    _INPUT_HISTORY_DIR.mkdir(parents=True, exist_ok=True)
   104	    return _INPUT_HISTORY_DIR / f"{slug}.txt"
   105	
   106	
   107	def _truncate_file_history(path: Path, max_entries: int = 200) -> None:
   108	    """Truncate a prompt_toolkit FileHistory file to at most max_entries."""
   109	    if not path.exists():
   110	        return
   111	    raw = path.read_bytes()
   112	    if not raw.strip():
   113	        return
   114	    content = raw.decode("utf-8")
   115	    normalized = content.replace("\r\n", "\n").replace("\r", "\n")
   116	    # FileHistory format: entries are +prefixed line blocks separated by blank lines
   117	    blocks = normalized.split("\n\n")
   118	    # Filter out empty blocks
   119	    blocks = [b for b in blocks if b.strip()]
   120	    if not blocks:
   121	        return
   122	    kept = blocks[-max_entries:]
   123	    rewritten = "\n\n".join(kept) + "\n\n"
   124	    rewritten_bytes = rewritten.encode("utf-8")
   125	    if rewritten_bytes == raw:
   126	        return
   127	    # Canonicalize to UTF-8 + LF so prompt_toolkit can reload history consistently
   128	    # across platforms, including Windows.
   129	    path.write_bytes(rewritten_bytes)
   130	
   131	
   132	class TerminalAgentTUI(
   133	    KeyBindingsMixin,
   134	    InputBehaviorMixin,
   135	    CommandsMixin,
   136	    HistorySyncMixin,
   137	    RenderPanelsMixin,
   138	):
   139	    def __init__(
   140	        self,
   141	        session: ChatSession,
   142	        status_bar: StatusBar,
   143	        renderer: EventRenderer,
   144	    ) -> None:
   145	        self._session = session
   146	        self._status_bar = status_bar
   147	        try:
   148	            self._status_bar.set_mode(self._session.get_mode())
   149	        except Exception:
   150	            pass
   151	        self._renderer = renderer
   152	        self._tool_result_registry = ToolResultRegistry()
   153	        set_tool_result_registry = getattr(self._renderer, "set_tool_result_registry", None)
   154	        if callable(set_tool_result_registry):
   155	            set_tool_result_registry(self._tool_result_registry)
   156	        self._task_poll_next_at = time.monotonic() + _TASK_POLL_INTERVAL_S
   157	        self._task_poll_last_list_id: str | None = None
   158	
   159	        self._bind_team_message_callback(self._session)
   160	
   161	        self._tool_panel_max_lines = read_env_int(
   162	            "AGENT_SDK_TUI_TOOL_PANEL_MAX_LINES",
   163	            4,
   164	        )
   165	        self._todo_panel_max_lines = read_env_int(
   166	            "AGENT_SDK_TUI_TODO_PANEL_MAX_LINES",
   167	            6,
   168	        )
   169	        self._todo_panel_expanded_max_lines = read_env_int(
   170	            "AGENT_SDK_TUI_TODO_PANEL_EXPANDED_MAX_LINES",
   171	            12,
   172	        )
   173	        self._queued_preview_max_chars = read_env_int(
   174	            "AGENT_SDK_TUI_QUEUED_PREVIEW_MAX_CHARS",
   175	            24,
   176	        )
   177	        self._queued_preview_max_rows = read_env_int(
   178	            "AGENT_SDK_TUI_QUEUED_PREVIEW_MAX_ROWS",
   179	            3,
   180	        )
   181	
   182	        self._busy = False
   183	        self._is_compacting = False
   184	        self._compact_task: asyncio.Task[Any] | None = None
   185	        self._compact_cancel_requested = False
   186	        self._pending_exit_after_compact_cancel = False
   187	        self._queued_display_by_message_id: dict[str, str] = {}
   188	        self._waiting_for_input = False
   189	        self._pending_questions: list[dict[str, Any]] | None = None
   190	        self._pending_plan_approval: dict[str, str] | None = None
   191	        self._ui_mode = UIMode.NORMAL
   192	        self._show_thinking = False  # Ctrl+T 开关，默认关闭
   193	        self._renderer._show_thinking_cb = lambda: self._show_thinking
   194	
   195	        self._custom_slash_commands: dict[str, CustomSlashCommand] = {}
   196	        self._skill_slash_command_names: set[str] = set()
   197	        self._slash_registry = SlashCommandRegistry()
   198	        self._build_slash_registry()
   199	        self._slash_completer = SlashCommandCompleter(
   200	            self._slash_registry.command_specs()
   201	        )
   202	        self._slash_argument_hint = SlashArgumentHintAutoSuggest(
   203	            resolve_spec=self._resolve_slash_spec,
   204	        )
   205	        self._mention_completer = LocalFileMentionCompleter(Path.cwd())
   206	        self._input_completer = ThreadedCompleter(
   207	            merge_completers(
   208	                [self._slash_completer, self._mention_completer],
   209	                deduplicate=True,
   210	            )
   211	        )
   212	        self._sync_skill_slash_commands()
   213	        self._loading_frame = 0
   214	        self._fallback_loading_phrase = (
   215	            random.choice(DEFAULT_STATUS_PHRASES)
   216	            if DEFAULT_STATUS_PHRASES
   217	            else f"Flibbertigibbeting{ELLIPSIS}"
   218	        )
   219	        self._fallback_phrase_refresh_at = 0.0
   220	        self._loading_tip_text = ""
   221	        self._loading_tip_show_at_monotonic: float | None = None
   222	
   223	        self._tool_result_flash_seconds = read_env_float(
   224	            "AGENT_SDK_TUI_TOOL_RESULT_FLASH_SECONDS",
   225	            0.55,
   226	        )
   227	        self._tool_result_flash_gen = 0
   228	        self._tool_result_flash_until_monotonic: float | None = None
   229	        self._tool_result_flash_active = False
   230	
   231	        # 初始化提交动画控制器
   232	        self._animator = SubmissionAnimator()
   233	        self._animation_controller = StreamAnimationController(self._animator)
   234	        self._tool_result_animator = SubmissionAnimator()
   235	
   236	        self._closing = False
   237	        self._printed_history_index = 0
   238	        # 跟踪上一次 drain 末尾是否是 assistant entry：streaming 管线把一条逻辑 message
   239	        # 拆成 N 条 entry，跨 drain 时需要用这个 seed 让 history_printer 把 run 续上
   240	        # （否则每次 drain 都重新加一个 `●`）。
   241	        self._last_drained_was_assistant = False
   242	        self._last_drained_thinking_tail_needs_gap = False
   243	        self._render_dirty = True
   244	        self._todo_panel_expanded = False
   245	        self._todo_panel_scroll = 0
   246	        self._last_loading_line = ""
   247	        self._initialized_session_id: str | None = None
   248	
   249	        self._app: Application[None] | None = None
   250	        self._stream_task: asyncio.Task[Any] | None = None
   251	        self._event_pump_task: asyncio.Task[Any] | None = None
   252	        self._ui_tick_task: asyncio.Task[None] | None = None
   253	        self._mcp_init_task: asyncio.Task[None] | None = None
   254	        self._interrupt_requested_at: float | None = None
   255	        self._interrupt_force_window_seconds = 1.5
   256	        self._background_turn_source: str | None = None
   257	
   258	        self._esc_last_pressed_at: float = 0.0
   259	        self._esc_press_count: int = 0
   260	        esc_window_ms = read_env_int("AGENT_SDK_TUI_ESC_CLEAR_WINDOW_MS", 700)
   261	        self._esc_clear_window_seconds = esc_window_ms / 1000.0
   262	
   263	        self._ctrl_c_last_pressed_at: float = 0.0
   264	        self._ctrl_c_press_count: int = 0
   265	        ctrl_c_window_ms = read_env_int("AGENT_SDK_TUI_CTRL_C_EXIT_WINDOW_MS", 700)
   266	        self._ctrl_c_exit_window_seconds = ctrl_c_window_ms / 1000.0
   267	        self._mcp_init_cancel_timeout_s = read_env_float(
   268	            "AGENT_SDK_TUI_MCP_INIT_CANCEL_TIMEOUT_S",
   269	            1.0,
   270	        )
   271	
   272	        self._paste_threshold_chars = read_env_int(
   273	            "AGENT_SDK_TUI_PASTE_PLACEHOLDER_THRESHOLD_CHARS",
   274	            500,
   275	        )
   276	        paste_guard_window_ms = read_env_int(
   277	            "AGENT_SDK_TUI_PASTE_GUARD_WINDOW_MS",
   278	            120,
   279	        )
   280	        self._paste_guard_window_seconds = paste_guard_window_ms / 1000.0
   281	        self._paste_guard_active_until = 0.0
   282	        self._paste_placeholder_text: str | None = None
   283	        self._active_paste_token: str | None = None
   284	        self._paste_payload_by_token: dict[str, str] = {}
   285	        self._paste_token_seq = 0
   286	        self._suppress_input_change_hook = False
   287	        self._last_input_len = 0
   288	        self._last_input_text = ""
   289	
   290	        # Running 计时：记录本次 busy 开始的单调时间，结束后清空
   291	        self._run_start_time: float | None = None
   292	        self._last_turn_user_preview: str | None = None
   293	
   294	        self._input_prompt_text = "> "
   295	        self._input_prompt_width = max(1, get_cwidth(self._input_prompt_text))
   296	        self._queued_input_hint = f"Press {UP_ARROW} to edit"
   297	
   298	        def _input_line_prefix(
   299	            _line_number: int,
   300	            wrap_count: int,
   301	        ) -> list[tuple[str, str]]:
   302	            if wrap_count <= 0:
   303	                fragments: list[tuple[str, str]] = [
   304	                    ("class:input.prompt", self._input_prompt_text)
   305	                ]
   306	                hint_text = self._input_placeholder_hint()
   307	                if hint_text:
   308	                    fragments.append(("class:input.placeholder", f"{hint_text}  "))
   309	                return fragments
   310	            return [("class:input.prompt", " " * self._input_prompt_width)]
   311	
   312	        self._input_area = TextArea(
   313	            text="",
   314	            multiline=True,
   315	            prompt="",
   316	            wrap_lines=True,
   317	            dont_extend_height=True,
   318	            completer=self._input_completer,
   319	            auto_suggest=self._slash_argument_hint,
   320	            complete_while_typing=False,  # 通过 Tab/上下键手动触发补全
   321	            history=self._create_input_history(),
   322	            style="class:input.line",
   323	            get_line_prefix=_input_line_prefix,
   324	        )
   325	        # Fill the entire input area with styled spaces to avoid VT100
   326	        # erase-to-end-of-line resetting the background to the terminal default.
   327	        # (prompt_toolkit renderer resets attributes before erase_end_of_line.)
   328	        self._input_area.window.char = " "
   329	
   330	        @self._input_area.buffer.on_text_changed.add_handler
   331	        def _trigger_completion(_buffer) -> None:
   332	            if self._handle_large_paste(_buffer):
   333	                return
   334	            if self._busy:
   335	                return
   336	            doc = self._input_area.buffer.document
   337	            mention_context = self._mention_completer.extract_context(
   338	                doc.text_before_cursor
   339	            )
   340	            if mention_context is not None:
   341	                self._start_mention_cache_warmup()
   342	            if self._completion_context_active(
   343	                doc.text_before_cursor,
   344	                doc.text_after_cursor,
   345	            ):
   346	                # 输入 / 或 @ 时自动弹出（不会选中第一项）
   347	                self._input_area.buffer.start_completion(select_first=False)
   348	
   349	        self._question_ui = AskUserQuestionUI()
   350	        self._plugin_ui = PluginPickerUI()
   351	        self._install_view = MarketplaceInstallView()
   352	        self._mcp_connecting_view = McpConnectingView()
   353	        self._btw_view = BtwView()
   354	        self._selection_ui = SelectionMenuUI()
   355	        self._todo_control = FormattedTextControl(text=self._todo_text)
   356	        self._tool_fold_control = FormattedTextControl(text=self._tool_fold_text)
   357	        self._loading_control = FormattedTextControl(text=self._loading_text)
   358	        self._status_control = FormattedTextControl(text=self._status_text)
   359	        self._completion_status_control = FormattedTextControl(
   360	            text=self._completion_panel_text
   361	        )
   362	
   363	        self._todo_window = Window(
   364	            content=self._todo_control,
   365	            height=self._todo_height,
   366	            dont_extend_height=True,
   367	            style="class:loading",
   368	        )
   369	        self._todo_container = ConditionalContainer(
   370	            content=self._todo_window,
   371	            filter=Condition(lambda: self._renderer.has_active_todos()),
   372	        )
   373	
   374	        self._loading_window = Window(
   375	            content=self._loading_control,
   376	            height=self._loading_height,
   377	            dont_extend_height=True,
   378	            style="class:loading",
   379	        )
   380	        self._loading_placeholder_window = Window(
   381	            content=FormattedTextControl(text=[("", " ")]),
   382	            height=1,
   383	            dont_extend_height=True,
   384	            style="class:loading",
   385	        )
   386	        # BTW 模式整体隐藏 loading 区域（含 placeholder），避免 dual-loading。
   387	        self._loading_container = ConditionalContainer(
   388	            content=ConditionalContainer(
   389	                content=self._loading_window,
   390	                filter=Condition(lambda: not self._should_hide_loading_panel()),
   391	                alternative_content=self._loading_placeholder_window,
   392	            ),
   393	            filter=Condition(lambda: self._ui_mode != UIMode.BTW),
   394	        )
   395	
   396	        # 折叠的 read/search 工具组：独立于 loading 区显示，保留 loading 动画。
   397	        # BTW 模式同样隐藏，避免双重 loading-like 信号。
   398	        self._tool_fold_window = Window(
   399	            content=self._tool_fold_control,
   400	            height=self._tool_fold_height,
   401	            dont_extend_height=True,
   402	            style="class:loading",
   403	        )
   404	        self._tool_fold_container = ConditionalContainer(
   405	            content=self._tool_fold_window,
   406	            filter=Condition(
   407	                lambda: self._ui_mode != UIMode.BTW and self._has_active_tool_fold()
   408	            ),
   409	        )
   410	
   411	        self._queue_control = FormattedTextControl(text=self._queue_text)
   412	        self._queue_window = Window(
   413	            content=self._queue_control,
   414	            height=self._queue_height,
   415	            dont_extend_height=True,
   416	            style="class:queue",
   417	        )
   418	        self._queue_container = ConditionalContainer(
   419	            content=self._queue_window,
   420	            filter=Condition(self._should_show_queue_panel),
   421	        )
   422	
   423	        self._status_window = Window(
   424	            content=self._status_control,
   425	            height=1,
   426	            dont_extend_height=True,
   427	            style="class:status",
   428	        )
   429	
   430	        self._completion_status_window = Window(
   431	            content=self._completion_status_control,
   432	            height=self._completion_panel_height,
   433	            dont_extend_height=True,
   434	            style="class:completion.status",
   435	        )
   436	
   437	        self._completion_visible = (
   438	            Condition(lambda: self._ui_mode == UIMode.NORMAL)
   439	            & has_focus(self._input_area)
   440	            & has_completions
   441	        )
   442	        # BTW 模式整体隐藏底部 status / completion 状态行。
   443	        self._bottom_container = ConditionalContainer(
   444	            content=ConditionalContainer(
   445	                content=self._completion_status_window,
   446	                filter=self._completion_visible,
   447	                alternative_content=self._status_window,
   448	            ),
   449	            filter=Condition(lambda: self._ui_mode != UIMode.BTW),
   450	        )
   451	
   452	        self._input_container = ConditionalContainer(
   453	            content=self._input_area,
   454	            filter=Condition(lambda: self._ui_mode == UIMode.NORMAL),
   455	        )
   456	        self._question_container = ConditionalContainer(
   457	            content=self._question_ui.container,
   458	            filter=Condition(lambda: self._ui_mode == UIMode.QUESTION),
   459	        )
   460	        self._selection_container = ConditionalContainer(
   461	            content=self._selection_ui.container,
   462	            filter=Condition(lambda: self._ui_mode == UIMode.SELECTION),
   463	        )
   464	        self._plugin_container = ConditionalContainer(
   465	            content=self._plugin_ui.container,
   466	            filter=Condition(lambda: self._ui_mode == UIMode.PLUGIN),
   467	        )
   468	        self._install_view_container = ConditionalContainer(
   469	            content=self._install_view.container,
   470	            filter=Condition(lambda: self._ui_mode == UIMode.MARKETPLACE_INSTALL),
   471	        )
   472	        self._mcp_connecting_container = ConditionalContainer(
   473	            content=self._mcp_connecting_view.container,
   474	            filter=Condition(lambda: self._ui_mode == UIMode.MCP_CONNECTING),
   475	        )
   476	        self._btw_container = ConditionalContainer(
   477	            content=self._btw_view.container,
   478	            filter=Condition(lambda: self._ui_mode == UIMode.BTW),
   479	        )
   480	
   481	        # idle 时 todo/queue 全部隐藏，它们之间的分隔空行也应该一并消失，
   482	        # 否则多余的固定高度会导致 prompt_toolkit 非全屏渲染时高度波动 → scrollback 污染。
   483	        _has_todo_or_queue = Condition(lambda: self._renderer.has_active_todos())
   484	        _has_queue = Condition(lambda: self._should_show_queue_panel())
   485	
   486	        self._main_container = HSplit(
   487	            [
   488	                # 折叠的 read/search 工具组显示在最上方，loading 区上方的空行
   489	                # 同时作为 fold 与 loading 之间的隔离行，避免视觉粘连。
   490	                self._tool_fold_container,
   491	                # loading 区上方的空行隔离带：把 scrollback 中正在刷入的
   492	                # thinking / assistant message 与底部 loading 区拉开一行。
   493	                # 与 loading_container 共享 "非 BTW 模式" 显隐条件，避免 BTW
   494	                # 模式下底部 UI 整体抬升一行。
   495	                ConditionalContainer(
   496	                    content=Window(height=1, style="class:loading"),
   497	                    filter=Condition(lambda: self._ui_mode != UIMode.BTW),
   498	                ),
   499	                self._loading_container,
   500	                ConditionalContainer(
   501	                    content=Window(height=1, style="class:loading"),
   502	                    filter=Condition(
   503	                        lambda: self._renderer.has_running_tools()
   504	                        and self._renderer.has_active_todos()
   505	                    ),
   506	                ),
   507	                self._todo_container,
   508	                ConditionalContainer(
   509	                    content=Window(height=1, style="class:loading"),
   510	                    filter=_has_todo_or_queue,
   511	                ),
   512	                ConditionalContainer(
   513	                    content=Window(height=1, style="class:input.separator"),
   514	                    filter=_has_queue,
   515	                ),
   516	                self._queue_container,
   517	                Window(height=1, style="class:loading"),
   518	                Window(height=1, char=HEAVY_HORIZONTAL, style="class:input.separator"),
   519	                self._input_container,
   520	                self._question_container,
   521	                self._selection_container,
   522	                self._plugin_container,
   523	                self._install_view_container,
   524	                self._mcp_connecting_container,
   525	                self._btw_container,
   526	                Window(height=1, char=HEAVY_HORIZONTAL, style="class:input.separator"),
   527	                self._bottom_container,
   528	            ]
   529	        )
   530	
   531	        self._root = FloatContainer(
   532	            content=self._main_container,
   533	            floats=[],
   534	        )
   535	
   536	        self._layout = Layout(self._root, focused_element=self._input_area.window)
   537	        self._bindings = self._build_key_bindings()
   538	        self._style = PTStyle.from_dict(
   539	            {
   540	                "": "bg:default #e5e9f0",
   541	                "history": "bg:default #d8dee9",
   542	                "input.pad": "fg:default bg:default",
   543	                "input.separator": "fg:#3d4450 bg:default",
   544	                "input.prompt": "bg:default #f2f4f8",
   545	                "input.line": "bg:default #f2f4f8",
   546	                "input-line": "bg:default #f2f4f8",
   547	                "status": "bg:default #c3ccd8",
   548	                "status.mode.act": "bg:default #60a5fa bold",
   549	                "status.mode.plan": "bg:default #7AC9CA bold",
   550	                "status.hint": "bg:default #6B7280",
   551	                "status.transient": "bg:default italic fg:ansiyellow",
   552	                "status.transient.error": "bg:default bold fg:ansired",
   553	                "status.transient.warning": "bg:default italic fg:ansiyellow",
   554	                "input.placeholder": "bg:default #9CA3AF",
   555	                "auto-suggestion": "bg:default #94a3b8",
   556	                "queue": "bg:default #d8dee9",
   557	                "queue.item": "bg:default #cbd5e1",
   558	                "git-diff.added": "#4ade80",
   559	                "git-diff.removed": "#f87171",
   560	                "question.tabs": "bg:default #c7d2fe",
   561	                "question.tabs.nav": "bg:default #93c5fd",
   562	                "question.tab": "bg:default #cbd5e1",
   563	                "question.tab.submit": "bg:default #86efac bold",
   564	                "question.tab.active": "bg:default #5e81ac bold",
   565	                "question.divider": "fg:#4b5563",
   566	                "question.body": "bg:default #d8dee9",
   567	                "question.title": "fg:#f8fafc bold",
   568	                "question.hint": "fg:#94a3b8",
   569	                "question.option": "fg:#dbeafe",
   570	                "question.option.cursor": "bg:default #5e81ac bold",
   571	                "question.option.selected": "fg:#93c5fd bold",
   572	                "question.option.description": "fg:#9ca3af",
   573	                "question.custom_input": "bg:default #f8fafc",
   574	                "question.custom_input.border": "fg:#334155",
   575	                "question.preview.title": "fg:#e2e8f0 bold",
   576	                "question.preview.question": "fg:#bfdbfe",
   577	                "question.preview.answer": "fg:#f1f5f9",
   578	                "plugin.name": "bold",
   579	                "plugin.name.cursor": "bold fg:ansibrightcyan",
   580	                "plugin.marketplace": "fg:ansigray italic",
   581	                "plugin.separator": "fg:ansigray",
   582	                "plugin.divider": "fg:ansigray",
   583	                "plugin.checkbox": "",
   584	                "plugin.checkbox.selected": "fg:ansiyellow",
   585	                "plugin.checkbox.installed": "fg:ansigreen",
   586	                "plugin.description": "fg:#9ca3af",
   587	                "plugin.scroll-hint": "fg:ansigray",
   588	                "plugin.hint": "fg:ansigray",
   589	                "plugin.empty": "fg:ansigray",
   590	                "detail.empty": "fg:ansigray",
   591	                "detail.meta": "fg:ansigray",
   592	                "detail.description": "fg:ansigray",
   593	                "detail.warning": "fg:ansiyellow bold",
   594	                "plugin-search-input": "bg:default",
   595	                "plugin-search-placeholder": "#555555 italic",
   596	                "plugin-search-frame frame.border": "#aaaaaa",
   597	                "plugin-search-icon": "fg:ansicyan",
   598	                "error": "fg:ansired",
   599	                "warning": "fg:ansiyellow",
   600	                "dim": "fg:ansigray",
   601	                "cursor": "fg:ansibrightcyan",
   602	                "selected": "fg:ansiyellow",
   603	                "installed": "fg:ansigreen",
   604	                "disabled": "fg:ansigray",
   605	                "tab-active": "bold underline",
   606	                "tab-inactive": "",
   607	                "tab-badge": "fg:ansired bold",
   608	                "selection.title": "bg:default #c3ccd8 bold",
   609	                "selection.divider": "fg:#4b5563",
   610	                "selection.body": "bg:default #d8dee9",
   611	                "selection.option": "fg:#dbeafe",
   612	                "selection.option.selected": "bg:default #5e81ac bold",
   613	                "selection.description": "fg:#9ca3af",
   614	                "selection.description.selected": "fg:#93c5fd",
   615	                "selection.hint": "fg:#94a3b8",
   616	                "completion.status": "bg:default #9ca3af",
   617	                "completion.status.item": "bg:default #9ca3af",
   618	                "completion.status.current": "bg:default #5e81ac bold",
   619	                "completion.status.meta": "bg:default #6b7280",
   620	                "completion.status.more": "bg:default #6b7280",
   621	                "loading": "fg:default bg:default",
   622	                "loading.dim": "fg:#94A3B8",
   623	            }
   624	        )
   625	
   626	        self._app = Application(
   627	            layout=self._layout,
   628	            key_bindings=self._bindings,
   629	            style=self._style,
   630	            full_screen=False,
   631	            mouse_support=False,
   632	        )
   633	
   634	    def _bind_team_message_callback(self, session: ChatSession) -> None:
   635	        # Team inbox 消息直连 scrollback（绕开 session_event_queue，实现实时显示）
   636	        renderer = self._renderer
   637	
   638	        def _on_team_message(event: TeamMessageEvent) -> None:
   639	            logger.debug(
   640	                "[team-diag] tui_append "
   641	                "session_id=%r recv_agent=%r from=%r to=%r type=%r timestamp=%r "
   642	                "preview=%r history_len_before=%d",
   643	                session.session_id,
   644	                event.agent_name,
   645	                event.from_agent,
   646	                event.to_agent,
   647	                event.message_type,
   648	                event.timestamp,
   649	                event.content_preview,
   650	                len(self._renderer.history_entries()),
   651	            )
   652	
   653	            def _write() -> None:
   654	                renderer.append_team_message_event(
   655	                    agent_name=str(event.agent_name or ""),
   656	                    from_agent=str(event.from_agent or ""),
   657	                    to_agent=event.to_agent,
   658	                    message_type=str(event.message_type or ""),
   659	                    content_preview=str(event.content_preview or ""),
   660	                    timestamp=str(event.timestamp or ""),
   661	                )
   662	
   663	            try:
   664	                from prompt_toolkit.application import get_app
   665	                from prompt_toolkit.application.dummy import DummyApplication
   666	
   667	                app = get_app()
   668	                if isinstance(app, DummyApplication):
   669	                    _write()
   670	                else:
   671	                    run_in_terminal(_write, in_executor=False)
   672	                return
   673	            except Exception:
   674	                pass
   675	            _write()
   676	
   677	        session._agent._team.team_message_event_callback = _on_team_message
   678	
   679	    @staticmethod
   680	    def _unbind_team_message_callback(session: ChatSession) -> None:
   681	        session._agent._team.team_message_event_callback = None
   682	
   683	    def _should_show_queue_panel(self) -> bool:
   684	        return self._ui_mode == UIMode.NORMAL and bool(self._queued_human_messages())
   685	
   686	    def _build_slash_registry(self) -> None:
   687	        handlers: dict[str, Callable[[str], Any]] = {
   688	            "help": self._slash_help,
   689	            "model": self._slash_model,
   690	            "session": self._slash_session,
   691	            "usage": self._slash_usage,
   692	            "context": self._slash_context,
   693	            "skills": self._slash_skills,
   694	            "mcp": self._slash_mcp,
   695	            "compact": self._slash_compact,
   696	            "rewind": self._slash_rewind,
   697	            "clear": self._slash_clear,
   698	            "exit": self._slash_exit,
   699	            "plugin": self._slash_plugin,
   700	            "reload-plugins": self._slash_reload_plugins,
   701	            "btw": self._slash_btw,
   702	        }
   703	        allow_when_busy = {
   704	            "help",
   705	            "session",
   706	            "usage",
   707	            "context",
   708	            "skills",
   709	            "mcp",
   710	            "exit",
   711	            "plugin",
   712	            "reload-plugins",
   713	            "btw",
   714	        }
   715	
   716	        for spec in SLASH_COMMAND_SPECS:
   717	            handler = handlers.get(spec.name)
   718	            if handler is None:
   719	                logger.warning(f"missing slash command handler: {spec.name}")
   720	                continue
   721	            if spec.name in allow_when_busy and spec.execution_kind != "local":
   722	                raise ValueError(
   723	                    f"slash command /{spec.name} cannot be allow_when_busy when "
   724	                    f"execution_kind={spec.execution_kind}"
   725	                )
   726	            self._slash_registry.register(
   727	                spec=spec,
   728	                handler=handler,
   729	                allow_when_busy=spec.name in allow_when_busy,
   730	                source="builtin",
   731	                argument_hint=spec.argument_hint,
   732	            )
   733	        self._load_custom_slash_commands()
   734	
   735	    def _load_custom_slash_commands(self) -> None:
   736	        builtin_names: set[str] = set()
   737	        for spec in SLASH_COMMAND_SPECS:
   738	            builtin_names.add(spec.name)
   739	            builtin_names.update(spec.aliases)
   740	
   741	        session_cwd = getattr(self._session, "_cwd", None)
   742	        project_root = Path(session_cwd).expanduser().resolve() if session_cwd else Path.cwd().expanduser().resolve()
   743	        result = discover_custom_slash_commands(
   744	            project_root=project_root,
   745	            builtin_names=builtin_names,
   746	        )
   747	        for warning in result.warnings:
   748	            self._renderer.append_system_message(warning, severity="warning")
   749	
   750	        self._custom_slash_commands = {command.name: command for command in result.commands}
   751	        for command in result.commands:
   752	            spec = SlashCommandSpec(
   753	                name=command.name,
   754	                description=command.description_with_scope(),
   755	                execution_kind="hybrid",
   756	                argument_hint=command.argument_hint,
   757	            )
   758	
   759	            async def _handler(args: str, *, command_name: str = command.name) -> None:
   760	                await self._slash_custom(command_name=command_name, args=args)
   761	
   762	            self._slash_registry.register(
   763	                spec=spec,
   764	                handler=_handler,
   765	                allow_when_busy=False,
   766	                source="custom",
   767	                argument_hint=command.argument_hint,
   768	            )
   769	
   770	    def _sync_skill_slash_commands(self) -> None:
   771	        """同步当前 runtime skills 到 slash command registry。"""
   772	        for name in list(getattr(self, "_skill_slash_command_names", set())):
   773	            self._slash_registry.unregister(name)
   774	        self._skill_slash_command_names = set()
   775	
   776	        agent = getattr(self._session, "_agent", None)
   777	        runtime_state = getattr(agent, "_runtime_state", None)
   778	        skills = list(getattr(runtime_state, "skills", []) or [])
   779	        seen_names: set[str] = set()
   780	
   781	        for skill in skills:
   782	            if not bool(getattr(skill, "user_invocable", True)):
   783	                continue
   784	
   785	            name = str(getattr(skill, "name", "")).strip()
   786	            if not name or name in seen_names:
   787	                continue
   788	            seen_names.add(name)
   789	
   790	            if self._slash_registry.resolve(name) is not None:
   791	                logger.warning(
   792	                    "Skill '%s' conflicts with an existing slash command, skipping",
   793	                    name,
   794	                )
   795	                continue
   796	
   797	            spec = SlashCommandSpec(
   798	                name=name,
   799	                description=str(getattr(skill, "description", "") or "").strip(),
   800	                execution_kind="hybrid",
   801	                argument_hint=getattr(skill, "argument_hint", None),
   802	            )
   803	
   804	            async def _handler(args: str, *, skill_name: str = name) -> None:
   805	                await self._slash_skill(skill_name=skill_name, args=args)
   806	
   807	            self._slash_registry.register(
   808	                spec=spec,
   809	                handler=_handler,
   810	                allow_when_busy=False,
   811	                source="skill",
   812	                argument_hint=spec.argument_hint,
   813	            )
   814	            self._skill_slash_command_names.add(name)
   815	
   816	        slash_completer = getattr(self, "_slash_completer", None)
   817	        if slash_completer is not None:
   818	            slash_completer.update_commands(self._slash_registry.command_specs())
   819	
   820	    def _resolve_slash_spec(self, name: str) -> SlashCommandSpec | None:
   821	        entry = self._slash_registry.resolve(name)
   822	        if entry is None:
   823	            return None
   824	        return entry.spec
   825	
   826	    def _create_input_history(self) -> FileHistory | InMemoryHistory:
   827	        """Create persistent FileHistory based on cwd, with fallback to InMemoryHistory."""
   828	        try:
   829	            session_cwd = getattr(self._session, "_cwd", None)
   830	            cwd = str(Path(session_cwd).expanduser().resolve()) if session_cwd else str(Path.cwd().resolve())
   831	            history_path = _get_input_history_path(cwd)
   832	        except Exception:
   833	            logger.debug("Failed to create FileHistory, falling back to InMemoryHistory", exc_info=True)
   834	            return InMemoryHistory()
   835	        try:
   836	            _truncate_file_history(history_path, _INPUT_HISTORY_MAX_ENTRIES)
   837	        except Exception:
   838	            logger.warning(
   839	                "Failed to normalize input history file %s; continuing with FileHistory",
   840	                history_path,
   841	                exc_info=True,
   842	            )
   843	        return FileHistory(str(history_path))
   844	
   845	    def _input_placeholder_hint(self) -> str | None:
   846	        if self._ui_mode != UIMode.NORMAL:
   847	            return None
   848	        queued_messages = self._queued_human_messages()
   849	        if not queued_messages:
   850	            return None
   851	        input_text = str(getattr(getattr(self, "_input_area", None), "text", ""))
   852	        if input_text.strip():
   853	            return None
   854	        if len(queued_messages) >= 2:
   855	            return self._queued_input_hint
   856	
   857	        preview = self._queued_message_preview(queued_messages[0])
   858	        max_chars = max(8, int(self._queued_preview_max_chars))
   859	        if len(preview) > max_chars:
   860	            preview = f"{preview[: max_chars - 3]}..."
   861	        return f"queued message: {preview}  |  {self._queued_input_hint}"
   862	
   863	    # 极客风运行完成词语，随机选一个拼成 history 记录
   864	    _RUN_ELAPSED_VERBS: tuple[str, ...] = (
   865	        "Executed",
   866	        "Compiled",
   867	        "Dispatched",
   868	        "Processed",
   869	        "Computed",
   870	        "Resolved",
   871	        "Terminated",
   872	        "Worked",
   873	        "Committed",
   874	        "Deployed",
   875	        "Brewed",
   876	        "Flushed",
   877	        "Finalized",
   878	        "Crunched",
   879	        "Propagated",
   880	        "Churned",
   881	        "Synchronized",
   882	        "Yielded",
   883	        "Emitted",
   884	    )
   885	
   886	    @staticmethod
   887	    def _format_run_elapsed(seconds: float) -> str:
   888	        """将秒数格式化为人类可读的时间字符串."""
   889	        if seconds < 10.0:
   890	            return f"{seconds:.1f}s"
   891	        if seconds < 60.0:
   892	            return f"{int(seconds)}s"
   893	        minutes = int(seconds) // 60
   894	        secs = int(seconds) % 60
   895	        return f"{minutes}m {secs}s"
   896	
   897	    def _append_run_elapsed_to_history(self, *, stop_reason: str | None = None) -> None:
   898	        """将本次 running 耗时以极客风格写入 history scrollback."""
   899	        if self._run_start_time is None:
   900	            return
   901	        elapsed = time.monotonic() - self._run_start_time
   902	        duration_str = self._format_run_elapsed(elapsed)
   903	        if str(stop_reason or "").strip().lower() == "interrupted":
   904	            self._renderer.append_elapsed_message(f"Interrupted after {duration_str}")
   905	            return
   906	
   907	        verb = random.choice(self._RUN_ELAPSED_VERBS)
   908	        self._renderer.append_elapsed_message(f"✻ {verb} in {duration_str}")
   909	
   910	    @staticmethod
   911	    def _is_lightweight_external_turn(
   912	        event: ExternalTurnScheduledEvent,
   913	    ) -> bool:
   914	        return (
   915	            str(event.source or "").strip() == "team_inbox"
   916	            and str(event.dispatch_reason or "").strip() == "idle_auto"
   917	        )
   918	
   919	    def _set_busy(self, value: bool) -> None:
   920	        was_busy = self._busy
   921	        self._busy = value
   922	        if value and not was_busy:
   923	            self._loading_tip_text = random.choice(LOADING_TIPS) if LOADING_TIPS else ""
   924	            self._loading_tip_show_at_monotonic = (
   925	                time.monotonic() + _LOADING_TIP_DELAY_SECONDS
   926	            )
   927	        elif not value:
   928	            self._loading_tip_text = ""
   929	            self._loading_tip_show_at_monotonic = None
   930	        self._render_dirty = True
   931	        self._invalidate()
   932	
   933	    async def _handle_error(self, exc: Exception) -> None:
   934	        """Unified error cleanup: format → display → stop animation → reset state."""
   935	        from comate_cli.terminal_agent.error_display import format_error
   936	
   937	        message, transient_summary, severity = format_error(exc)
   938	
   939	        self._renderer.append_system_message(message, severity=severity)
   940	        self._renderer.interrupt_turn()
   941	        await self._animation_controller.shutdown()
   942	        self._status_bar.show_transient(transient_summary, severity=severity)
   943	        self._last_turn_user_preview = None
   944	        self._append_run_elapsed_to_history(stop_reason="error")
   945	        self._run_start_time = None
   946	        self._interrupt_requested_at = None
   947	        self._set_busy(False)
   948	        await self._status_bar.refresh()
   949	        self._renderer.clear_auto_compacting()
   950	        self._refresh_layers()
   951	
   952	    async def _submit_user_message(
   953	        self,
   954	        text: str,
   955	        *,
   956	        display_text: str | None = None,
   957	        display_header: str | None = None,
   958	        display_subtitle: str | None = None,
   959	    ) -> None:
   960	        if self._busy:
   961	            self._renderer.append_system_message(
   962	                "A task is currently running. Please wait.", severity="error"
   963	            )
   964	            return
   965	
   966	        if self._ui_mode == UIMode.QUESTION:
   967	            self._exit_question_mode()
   968	
   969	        self._session.run_controller.clear()
   970	        self._interrupt_requested_at = None
   971	
   972	        self._set_busy(True)
   973	        self._run_start_time = time.monotonic()
   974	        self._waiting_for_input = False
   975	        self._pending_questions = None
   976	        # 本轮默认 loading 文案：避免出现 Working… 这种突兀 fallback
   977	        self._fallback_loading_phrase = (
   978	            random.choice(DEFAULT_STATUS_PHRASES)
   979	            if DEFAULT_STATUS_PHRASES
   980	            else f"Thinking{ELLIPSIS}"
   981	        )
   982	        self._fallback_phrase_refresh_at = time.monotonic() + 3.0  # 每 3 秒允许换一次
   983	
   984	        self._renderer.start_turn()
   985	        self._renderer.seed_user_message(
   986	            display_text if display_text is not None else text,
   987	            display_header=display_header,
   988	            display_subtitle=display_subtitle,
   989	        )
   990	        self._last_turn_user_preview = display_text if display_text is not None else text
   991	        self._refresh_layers()
   992	
   993	        # 启动提交动画
   994	        await self._animation_controller.start()
   995	
   996	        # @path context hint injection
   997	        try:
   998	            hint = build_path_context_hint(text, Path.cwd())
   999	            if hint is not None:
  1000	                self._session._agent._context.add_system_reminder_message(hint)
  1001	        except Exception:
  1002	            logger.debug("path context hint injection failed", exc_info=True)
  1003	
  1004	        try:
  1005	            await self._session.send(text)
  1006	        except Exception as exc:
  1007	            logger.exception("send failed")
  1008	            await self._handle_error(exc)
  1009	
  1010	    async def _consume_event_stream(self) -> None:
  1011	        waiting_for_input = False
  1012	        questions: list[dict[str, Any]] | None = None
  1013	        plan_approval: dict[str, str] | None = None
  1014	        stop_reason: str | None = None
  1015	        try:
  1016	            async for event in self._session.events():
  1017	                if self._closing:
  1018	                    break
  1019	                stop_error: Exception | None = None
  1020	                self._maybe_cancel_tool_result_flash(event)
  1021	                await self._animation_controller.on_event(event)
  1022	                if isinstance(event, SessionInitEvent):
  1023	                    self._initialized_session_id = str(event.session_id)
  1024	                if isinstance(event, QueueDirtyEvent):
  1025	                    if event.reason in {
  1026	                        QueueDirtyReason.CANCELLED,
  1027	                        QueueDirtyReason.CLEARED,
  1028	                    }:
  1029	                        for message_id in event.message_ids:
  1030	                            self._queued_display_by_message_id.pop(
  1031	                                str(message_id),
  1032	                                None,
  1033	                            )
  1034	                    if event.reason is QueueDirtyReason.CONSUMED:
  1035	                        consumed_human_displays: list[str] = []
  1036	                        for message_id, origin in zip(event.message_ids, event.origins):
  1037	                            if origin is not MessageOrigin.HUMAN:
  1038	                                continue
  1039	                            display_text = self._queued_display_by_message_id.pop(
  1040	                                str(message_id),
  1041	                                None,
  1042	                            )
  1043	                            if display_text:
  1044	                                consumed_human_displays.append(display_text)
  1045	                        if consumed_human_displays and not self._busy:
  1046	                            self._session.run_controller.clear()
  1047	                            self._interrupt_requested_at = None
  1048	                            self._waiting_for_input = False
  1049	                            self._pending_questions = None
  1050	                            self._pending_plan_approval = None
  1051	                            self._set_busy(True)
  1052	                            self._run_start_time = time.monotonic()
  1053	                            self._fallback_loading_phrase = (
  1054	                                random.choice(DEFAULT_STATUS_PHRASES)
  1055	                                if DEFAULT_STATUS_PHRASES
  1056	                                else f"Thinking{ELLIPSIS}"
  1057	                            )
  1058	                            self._fallback_phrase_refresh_at = time.monotonic() + 3.0
  1059	                            self._renderer.start_turn()
  1060	                            await self._animation_controller.start()
  1061	                            self._last_turn_user_preview = consumed_human_displays[-1]
  1062	                        for display_text in consumed_human_displays:
  1063	                            self._renderer.seed_user_message(display_text)
  1064	                    self._refresh_layers()
  1065	                    self._invalidate()
  1066	                if isinstance(event, ExternalTurnScheduledEvent) and not self._busy:
  1067	                    self._session.run_controller.clear()
  1068	                    self._interrupt_requested_at = None
  1069	                    self._waiting_for_input = False
  1070	                    self._pending_questions = None
  1071	                    self._pending_plan_approval = None
  1072	                    if self._is_lightweight_external_turn(event):
  1073	                        self._background_turn_source = str(event.source or "").strip()
  1074	                    else:
  1075	                        self._set_busy(True)
  1076	                        self._run_start_time = time.monotonic()
  1077	                        self._renderer.start_turn()
  1078	                        await self._animation_controller.start()
  1079	                    self._refresh_layers()
  1080	                if isinstance(event, PlanApprovalRequiredEvent):
  1081	                    plan_approval = {
  1082	                        "plan_path": str(event.plan_path),
  1083	                        "summary": str(event.summary),
  1084	                        "execution_prompt": str(event.execution_prompt),
  1085	                        "context_utilization_pct": event.context_utilization_pct,
  1086	                    }
  1087	                if isinstance(event, StopEvent):
  1088	                    stop_reason = str(event.reason or "")
  1089	                    raw_stop_error = event.metadata.get("error")
  1090	                    if raw_stop_error is not None:
  1091	                        stop_error = (
  1092	                            raw_stop_error
  1093	                            if isinstance(raw_stop_error, Exception)
  1094	                            else Exception(str(raw_stop_error))
  1095	                        )
  1096	                        stop_reason = "error"
  1097	                    if event.reason == "shutdown_request":
  1098	                        shutdown_recipients = event.metadata.get("shutdown_recipients", [])
  1099	                        if shutdown_recipients:
  1100	                            from comate_agent_sdk.agent.runner_engine.query_stream import (
  1101	                                _send_team_shutdown_response,
  1102	                            )
  1103	                            _send_team_shutdown_response(
  1104	                                self._session.runtime, recipients=shutdown_recipients
  1105	                            )
  1106	                is_waiting, new_questions = self._renderer.handle_event(event)
  1107	                self._maybe_flash_tool_result(event)
  1108	                if is_waiting:
  1109	                    waiting_for_input = True
  1110	                    if new_questions is not None:
  1111	                        questions = new_questions
  1112	                if stop_error is not None:
  1113	                    from comate_cli.terminal_agent.error_display import format_error
  1114	
  1115	                    message, transient_summary, severity = format_error(stop_error)
  1116	                    self._renderer.append_system_message(message, severity=severity)
  1117	                    self._status_bar.show_transient(
  1118	                        transient_summary,
  1119	                        severity=severity,
  1120	                    )
  1121	                self._refresh_layers()
  1122	                if isinstance(event, StopEvent):
  1123	                    background_turn = self._background_turn_source is not None
  1124	                    # spec §6.4：finalize_turn 内会调 _force_flush_all 把
  1125	                    # held / container / text_pending / thinking 全部入队，
  1126	                    # 后续 _drain_history_async（同 frame）写 scrollback。
  1127	                    self._renderer.finalize_turn()
  1128	                    if not background_turn:
  1129	                        await self._animation_controller.shutdown()
  1130	                    self._last_turn_user_preview = None
  1131	                    self._interrupt_requested_at = None
  1132	                    if not background_turn:
  1133	                        self._append_run_elapsed_to_history(stop_reason=stop_reason)
  1134	                        self._run_start_time = None
  1135	                        self._set_busy(False)
  1136	                        await self._status_bar.refresh()
  1137	                    self._background_turn_source = None
  1138	
  1139	                    if waiting_for_input:
  1140	                        self._waiting_for_input = True
  1141	                        self._pending_questions = questions
  1142	                        self._pending_plan_approval = None
  1143	                        if questions:
  1144	                            self._enter_question_mode(questions)
  1145	                        else:
  1146	                            self._renderer.append_system_message(
  1147	                                "请输入对上述问题的回答后回车提交。"
  1148	                            )
  1149	                    elif plan_approval is not None:
  1150	                        self._waiting_for_input = False
  1151	                        self._pending_questions = None
  1152	                        self._pending_plan_approval = plan_approval
  1153	                        self._exit_question_mode()
  1154	                        self._open_plan_approval_menu(plan_approval)
  1155	                    else:
  1156	                        self._waiting_for_input = False
  1157	                        self._pending_questions = None
  1158	                        self._pending_plan_approval = None
  1159	                        self._exit_question_mode()
  1160	
  1161	                    self._refresh_layers()
  1162	
  1163	                    waiting_for_input = False
  1164	                    questions = None
  1165	                    plan_approval = None
  1166	                    stop_reason = None
  1167	        except asyncio.CancelledError:
  1168	            raise
  1169	        except Exception as exc:
  1170	            logger.exception("session event pump failed")
  1171	            await self._handle_error(exc)
  1172	
  1173	    def _open_plan_approval_menu(self, approval: dict[str, str]) -> None:
  1174	        plan_path = str(approval.get("plan_path", "")).strip()
  1175	        summary = str(approval.get("summary", "")).strip()
  1176	        execution_prompt = str(approval.get("execution_prompt", "")).strip()
  1177	        context_utilization_pct = int(approval.get("context_utilization_pct", 0))
  1178	        clear_label = (
  1179	            f"Yes, clear context ({context_utilization_pct}% used) and execute"
  1180	        )
  1181	
  1182	        options = [
  1183	             {
  1184	                "value": "approve_clear_execute",
  1185	                "label": clear_label,
  1186	                "description": "Clear conversation history, then switch to Act Mode and execute.",
  1187	            },
  1188	            {
  1189	                "value": "approve_execute",
  1190	                "label": "Approve and execute",
  1191	                "description": "Switch to Act Mode and execute immediately.",
  1192	            },
  1193	           
  1194	            {
  1195	                "value": "reject_continue",
  1196	                "label": "Reject and continue planning",
  1197	                "description": "Stay in Plan Mode and continue refining the plan.",
  1198	            },
  1199	        ]
  1200	
  1201	        title = "Plan approval required"
  1202	        if summary:
  1203	            title = f"Plan approval: {summary}"
  1204	
  1205	        def on_confirm(value: str) -> None:
  1206	            if value == "approve_execute":
  1207	                self._schedule_background(
  1208	                    self._approve_plan_and_execute(
  1209	                        plan_path=plan_path,
  1210	                        execution_prompt=execution_prompt,
  1211	                        clear_context=False,
  1212	                    )
  1213	                )
  1214	                return
  1215	            if value == "approve_clear_execute":
  1216	                self._schedule_background(
  1217	                    self._approve_plan_and_execute(
  1218	                        plan_path=plan_path,
  1219	                        execution_prompt=execution_prompt,
  1220	                        clear_context=True,
  1221	                    )
  1222	                )
  1223	                return
  1224	            if value == "reject_continue":
  1225	                self._reject_plan_and_continue(plan_path=plan_path)
  1226	
  1227	        def on_cancel() -> None:
  1228	            self._reject_plan_and_continue(plan_path=plan_path)
  1229	
  1230	        ok = self._selection_ui.set_options(
  1231	            title=title,
  1232	            options=options,
  1233	            on_confirm=on_confirm,
  1234	            on_cancel=on_cancel,
  1235	        )
  1236	        if not ok:
  1237	            self._renderer.append_system_message(
  1238	                "No plan approval options available.",
  1239	                severity="error",
  1240	            )
  1241	            return
  1242	        self._selection_ui.refresh()
  1243	        self._ui_mode = UIMode.SELECTION
  1244	        self._sync_focus_for_mode()
  1245	        self._invalidate()
  1246	
  1247	    async def _approve_plan_and_execute(
  1248	        self,
  1249	        *,
  1250	        plan_path: str,
  1251	        execution_prompt: str,
  1252	        clear_context: bool = False,
  1253	    ) -> None:
  1254	        prompt = self._session.approve_plan(clear_context=clear_context)
  1255	        if execution_prompt.strip():
  1256	            prompt = execution_prompt.strip()
  1257	        self._pending_plan_approval = None
  1258	        try:
  1259	            self._status_bar.set_mode(self._session.get_mode())
  1260	        except Exception:
  1261	            pass
  1262	        self._renderer.append_system_message(f"Plan approved: {plan_path}")
  1263	        self._refresh_layers()
  1264	        await self._submit_user_message(prompt, display_text="Execute approved plan")
  1265	
  1266	    def _reject_plan_and_continue(self, *, plan_path: str) -> None:
  1267	        self._session.reject_plan()
  1268	        self._pending_plan_approval = None
  1269	        try:
  1270	            self._status_bar.set_mode(self._session.get_mode())
  1271	        except Exception:
  1272	            pass
  1273	        self._renderer.append_system_message(f"Plan rejected: {plan_path}")
  1274	        self._refresh_layers()
  1275	
  1276	    def _maybe_cancel_tool_result_flash(self, event: object) -> None:
  1277	        if not self._tool_result_flash_active:
  1278	            return
  1279	
  1280	        from comate_agent_sdk.agent.events import StopEvent, TextEvent
  1281	
  1282	        if isinstance(event, TextEvent) or isinstance(event, StopEvent):
  1283	            self._schedule_background(self._stop_tool_result_flash())
  1284	
  1285	    def _maybe_flash_tool_result(self, event: object) -> None:
  1286	        from comate_agent_sdk.agent.events import ToolResultEvent
  1287	
  1288	        if not isinstance(event, ToolResultEvent):
  1289	            return
  1290	        tool_name = str(event.tool or "").strip()
  1291	        if tool_name.lower() == "todowrite":
  1292	            return
  1293	        if self._renderer.has_running_tools():
  1294	            return
  1295	        self._trigger_tool_result_flash(
  1296	            tool_name=tool_name or "Tool",
  1297	            is_error=bool(event.is_error),
  1298	        )
  1299	
  1300	    def _trigger_tool_result_flash(self, *, tool_name: str, is_error: bool) -> None:
  1301	        del is_error, tool_name
  1302	        phrase = (
  1303	            random.choice(DEFAULT_STATUS_PHRASES)
  1304	            if DEFAULT_STATUS_PHRASES
  1305	            else f"Embellishing{ELLIPSIS}"
  1306	        )
  1307	        duration_seconds = max(0.15, float(self._tool_result_flash_seconds))
  1308	        self._tool_result_flash_gen += 1
  1309	        self._tool_result_flash_active = True
  1310	        self._tool_result_flash_until_monotonic = time.monotonic() + duration_seconds
  1311	        gen = self._tool_result_flash_gen
  1312	        self._schedule_background(self._start_tool_result_flash(gen=gen, hint=phrase))
  1313	
  1314	    async def _start_tool_result_flash(self, *, gen: int, hint: str) -> None:
  1315	        if gen != self._tool_result_flash_gen:
  1316	            return
  1317	        await self._tool_result_animator.start()
  1318	        if gen != self._tool_result_flash_gen:
  1319	            return
  1320	        self._tool_result_animator.set_status_hint(hint)
  1321	        self._render_dirty = True
  1322	        self._invalidate()
  1323	
  1324	    async def _stop_tool_result_flash(self) -> None:
  1325	        if not self._tool_result_flash_active:
  1326	            return
  1327	        self._tool_result_flash_gen += 1
  1328	        self._tool_result_flash_active = False
  1329	        self._tool_result_flash_until_monotonic = None
  1330	        self._tool_result_animator.set_status_hint(None)
  1331	        await self._tool_result_animator.stop()
  1332	        self._render_dirty = True
  1333	        self._invalidate()
  1334	
  1335	    def _schedule_background(self, coroutine: Any) -> None:
  1336	        task = asyncio.create_task(coroutine)
  1337	
  1338	        def _done(done_task: asyncio.Task[Any]) -> None:
  1339	            try:
  1340	                done_task.result()
  1341	            except asyncio.CancelledError:
  1342	                return
  1343	            except Exception:
  1344	                logger.exception("background task failed")
  1345	
  1346	        task.add_done_callback(_done)
  1347	
  1348	    def _start_mention_cache_warmup(self) -> None:
  1349	        mention_completer = getattr(self, "_mention_completer", None)
  1350	        start_warmup = getattr(mention_completer, "start_deep_cache_warmup", None)
  1351	        if not callable(start_warmup):
  1352	            return
  1353	        start_warmup(on_complete=self._on_mention_cache_warmed)
  1354	
  1355	    def _on_mention_cache_warmed(self) -> None:
  1356	        app = self._app
  1357	        if app is None or self._closing:
  1358	            return
  1359	        loop = getattr(app, "loop", None)
  1360	        if loop is None or loop.is_closed():
  1361	            return
  1362	        loop.call_soon_threadsafe(self._restart_active_mention_completion)
  1363	        app.invalidate()
  1364	
  1365	    def _restart_active_mention_completion(self) -> None:
  1366	        if self._closing or self._busy or self._ui_mode != UIMode.NORMAL:
  1367	            return
  1368	        input_area = getattr(self, "_input_area", None)
  1369	        if input_area is None:
  1370	            return
  1371	        buffer = input_area.buffer
  1372	        doc = buffer.document
  1373	        if not self._completion_context_active(
  1374	            doc.text_before_cursor,
  1375	            doc.text_after_cursor,
  1376	        ):
  1377	            return
  1378	        complete_state = buffer.complete_state
  1379	        if complete_state is not None:
  1380	            self._invalidate()
  1381	            return
  1382	        buffer.start_completion(select_first=False)
  1383	        self._invalidate()
  1384	
  1385	    def _refresh_layers(self) -> None:
  1386	        self._sync_focus_for_mode()
  1387	        self._render_dirty = True
  1388	
  1389	    def _fetch_tasks_from_store(self) -> tuple[list[dict], str] | None:
  1390	        """从 TaskStore 读取最新 task 列表（纯 I/O，线程安全）。
  1391	
  1392	        返回 (task_dicts, list_id) 或 None。
  1393	        不修改任何共享状态——状态更新由调用方在主线程完成。
  1394	        """
  1395	        try:
  1396	            agent = getattr(self._session, "_agent", None)
  1397	            if agent is None:
  1398	                return None
  1399	            store = getattr(agent, "_task_store", None)
  1400	            if store is None:
  1401	                return None
  1402	            tasks = store.list_tasks()
  1403	            if not tasks:
  1404	                return None
  1405	            # 转换 TaskItem 为 dict（与 chat_session.py 格式一致）
  1406	            task_dicts: list[dict] = []
  1407	            for task in tasks:
  1408	                payload = task.model_dump(mode="json")
  1409	                payload["open_blocked_by"] = store.get_open_blocked_by(task.id)
  1410	                task_dicts.append(payload)
  1411	            list_id = store.list_id_value()
  1412	            return task_dicts, list_id
  1413	        except Exception:
  1414	            logger.debug("task store fetch failed", exc_info=True)
  1415	            return None
  1416	
  1417	    async def _ui_tick(self) -> None:
  1418	        try:
  1419	            while not self._closing:
  1420	                self._renderer.tick_progress()
  1421	                self._loading_frame += 1
  1422	                # busy 时每隔几秒换一个短语，让 loading 更“活”
  1423	                if self._busy and time.monotonic() >= self._fallback_phrase_refresh_at:
  1424	                    self._fallback_loading_phrase = (
  1425	                        random.choice(DEFAULT_STATUS_PHRASES)
  1426	                        if DEFAULT_STATUS_PHRASES
  1427	                        else f"Flibbertigibbeting{ELLIPSIS}"
  1428	                    )
  1429	                    self._fallback_phrase_refresh_at = time.monotonic() + 3.0
  1430	                    self._render_dirty = True
  1431	
  1432	                # spec §3.2：drain 复用主循环 4-6 fps 节奏；line-level commit
  1433	                # 5-30 entries/sec，每次 drain 处理 ~5 entries 批次（远低于上限）。
  1434	                await self._drain_history_async()
  1435	
  1436	                # 检查动画器是否需要刷新
  1437	                if self._animator.consume_dirty():
  1438	                    self._render_dirty = True
  1439	                if self._tool_result_animator.consume_dirty():
  1440	                    self._render_dirty = True
  1441	
  1442	                if self._tool_result_flash_active:
  1443	                    until = self._tool_result_flash_until_monotonic
  1444	                    if until is None or time.monotonic() >= until:
  1445	                        self._schedule_background(self._stop_tool_result_flash())
  1446	                    else:
  1447	                        self._render_dirty = True
  1448	
  1449	                # 瞬态消息过期检查
  1450	                if self._status_bar.clear_transient_if_expired():
  1451	                    self._render_dirty = True
  1452	                elif self._status_bar.has_transient:
  1453	                    self._render_dirty = True
  1454	
  1455	                loading_line = self._renderer.loading_line().strip()
  1456	                loading_changed = loading_line != self._last_loading_line
  1457	                if loading_changed:
  1458	                    self._last_loading_line = loading_line
  1459	                    self._render_dirty = True
  1460	
  1461	                if self._renderer.has_running_tools():
  1462	                    # Keep repainting for breathing animation.
  1463	                    self._render_dirty = True
  1464	                elif self._busy:
  1465	                    self._render_dirty = True
  1466	
  1467	                if self._render_dirty:
  1468	                    self._invalidate()
  1469	                    self._render_dirty = False
  1470	
  1471	                # 动态帧率：busy/动画时降为 6fps（缓解 scrollback 污染），idle 时 4fps
  1472	                fast = (
  1473	                    self._busy
  1474	                    or self._animator.is_active
  1475	                    or self._renderer.has_running_tools()
  1476	                )
  1477	                sleep_s = 1 / 6 if fast else 1 / 4
  1478	
  1479	                # 定时轮询 TaskStore 刷新 task 面板
  1480	                now_poll = time.monotonic()
  1481	                if now_poll >= self._task_poll_next_at:
  1482	                    self._task_poll_next_at = now_poll + _TASK_POLL_INTERVAL_S
  1483	                    poll_result = await asyncio.to_thread(self._fetch_tasks_from_store)
  1484	                    if poll_result is not None:
  1485	                        task_dicts, list_id = poll_result
  1486	                        if list_id != self._task_poll_last_list_id:
  1487	                            self._task_poll_last_list_id = list_id
  1488	                            self._renderer._update_tasks(task_dicts, list_id=list_id)
  1489	                            self._render_dirty = True
  1490	
  1491	                await asyncio.sleep(sleep_s)
  1492	        except asyncio.CancelledError:
  1493	            return
  1494	
  1495	    def _invalidate(self) -> None:
  1496	        if self._app is None:
  1497	            return
  1498	        self._app.invalidate()
  1499	
  1500	    @property
  1501	    def session(self) -> ChatSession:
  1502	        return self._session
  1503	
  1504	    @property
  1505	    def initialized_session_id(self) -> str | None:
  1506	        return self._initialized_session_id
  1507	
  1508	    def _request_exit(self) -> None:
  1509	        if self._is_compacting and self._compact_task is not None:
  1510	            self._pending_exit_after_compact_cancel = True
  1511	            if not self._compact_cancel_requested:
  1512	                self._compact_cancel_requested = True
  1513	                self._renderer.append_system_message(
  1514	                    "Cancelling /compact. The app will exit after rollback finishes.",
  1515	                    severity="warning",
  1516	                )
  1517	                self._compact_task.cancel()
  1518	            else:
  1519	                self._renderer.append_system_message(
  1520	                    "/compact cancellation already requested. Waiting for rollback to finish.",
  1521	                    severity="warning",
  1522	                )
  1523	            self._refresh_layers()
  1524	            return
  1525	        self._exit_app()
  1526	
  1527	    def _exit_app(self) -> None:
  1528	        self._closing = True
  1529	        self._session.run_controller.clear()
  1530	        if self._event_pump_task is not None:
  1531	            self._event_pump_task.cancel()
  1532	        if self._app is not None:
  1533	            self._app.exit(result=None)
  1534	
  1535	    async def _cancel_task_with_timeout(
  1536	        self,
  1537	        task: asyncio.Task[Any],
  1538	        *,
  1539	        timeout_s: float,
  1540	        task_name: str,
  1541	    ) -> None:
  1542	        task.cancel()
  1543	        done, _pending = await asyncio.wait({task}, timeout=timeout_s)
  1544	        if not done:
  1545	            logger.error(f"{task_name} did not exit within {timeout_s:.1f}s after cancellation")
  1546	            return
  1547	        try:
  1548	            await task
  1549	        except asyncio.CancelledError:
  1550	            return
  1551	        except Exception as exc:
  1552	            logger.warning(f"{task_name} failed after cancellation: {exc}", exc_info=True)
  1553	
  1554	    async def _replace_active_session(self, new_session_id: str) -> None:
  1555	        old_session = self._session
  1556	        event_pump_task = self._event_pump_task
  1557	        if event_pump_task is not None:
  1558	            await self._cancel_task_with_timeout(
  1559	                event_pump_task,
  1560	                timeout_s=1.0,
  1561	                task_name="terminal-session-event-pump",
  1562	            )
  1563	            self._event_pump_task = None
  1564	
  1565	        old_session.run_controller.clear()
  1566	        self._unbind_team_message_callback(old_session)
  1567	        await old_session.close()
  1568	
  1569	        new_session = ChatSession.resume(
  1570	            old_session._template,
  1571	            session_id=new_session_id,
  1572	            cwd=old_session._cwd,
  1573	        )
  1574	        self._session = new_session
  1575	        self._status_bar.set_session(new_session)
  1576	        self._bind_team_message_callback(new_session)
  1577	
  1578	        self._queued_display_by_message_id.clear()
  1579	        self._waiting_for_input = False
  1580	        self._pending_questions = None
  1581	        self._pending_plan_approval = None
  1582	        self._task_poll_next_at = time.monotonic() + _TASK_POLL_INTERVAL_S
  1583	        self._task_poll_last_list_id = None
  1584	        self._background_turn_source = None
  1585	        self._last_turn_user_preview = None
  1586	        self._initialized_session_id = None
  1587	        self._tool_result_registry.clear()
  1588	        self._sync_skill_slash_commands()
  1589	
  1590	        if self._app is not None and not self._closing:
  1591	            self._event_pump_task = asyncio.create_task(
  1592	                self._consume_event_stream(),
  1593	                name="terminal-session-event-pump",
  1594	            )
  1595	        await self._replay_scrollback_after_rewind()
  1596	        await self._status_bar.refresh()
  1597	
  1598	    async def run(
  1599	        self,
  1600	        *,
  1601	        mcp_init: Callable[[], Any] | None = None,
  1602	    ) -> None:
  1603	        if self._app is None:
  1604	            return
  1605	
  1606	        self._start_mention_cache_warmup()
  1607	        self._refresh_layers()
  1608	
  1609	        try:
  1610	            await self._init_plugins()
  1611	        except Exception:
  1612	            logger.exception("Plugin init failed at startup; continuing without plugins")
  1613	
  1614	        if mcp_init is not None:
  1615	            async def _do_init() -> None:
  1616	                try:
  1617	                    await mcp_init()
  1618	                except asyncio.CancelledError:
  1619	                    raise
  1620	                except Exception as exc:
  1621	                    logger.debug(
  1622	                        f"MCP init failed in TUI bootstrap: {exc}",
  1623	                        exc_info=True,
  1624	                    )
  1625	                finally:
  1626	                    self._refresh_layers()
  1627	
  1628	            self._mcp_init_task = asyncio.create_task(
  1629	                _do_init(),
  1630	                name="terminal-mcp-init",
  1631	            )
  1632	
  1633	        self._ui_tick_task = asyncio.create_task(
  1634	            self._ui_tick(),
  1635	            name="terminal-ui-tick",
  1636	        )
  1637	        if hasattr(self, "_session") and self._session is not None:
  1638	            self._event_pump_task = asyncio.create_task(
  1639	                self._consume_event_stream(),
  1640	                name="terminal-session-event-pump",
  1641	            )
  1642	        try:
  1643	            # Ensure scrollback prints don't mix with the inline (full_screen=False) UI.
  1644	            with patch_stdout(raw=True):
  1645	                await self._app.run_async()
  1646	        finally:
  1647	            self._closing = True
  1648	            if self._mcp_init_task is not None:
  1649	                await self._cancel_task_with_timeout(
  1650	                    self._mcp_init_task,
  1651	                    timeout_s=self._mcp_init_cancel_timeout_s,
  1652	                    task_name="terminal-mcp-init",
  1653	                )
  1654	                self._mcp_init_task = None
  1655	            if self._ui_tick_task is not None:
  1656	                self._ui_tick_task.cancel()
  1657	                with suppress(asyncio.CancelledError):
  1658	                    await self._ui_tick_task
  1659	            event_pump_task = getattr(self, "_event_pump_task", None)
  1660	            if event_pump_task is not None:
  1661	                event_pump_task.cancel()
  1662	                with suppress(asyncio.CancelledError):
  1663	                    await event_pump_task
  1664	            self._renderer.close()
  1665	
  1666	    async def _init_plugins(self) -> None:
  1667	        """Load and inject plugin resources at startup."""
  1668	        from comate_agent_sdk.plugins import create_plugin_registry, PluginLoader
  1669	
  1670	        loader = PluginLoader()
  1671	        registry = create_plugin_registry()
  1672	        # Ensure plugin data directory exists
  1673	        data_dir = registry.base_dir / "data"
  1674	        data_dir.mkdir(parents=True, exist_ok=True)
  1675	        self._loaded_plugins, self._plugin_errors = loader.load_all(
  1676	            registry=registry,
  1677	            project_path=Path.cwd(),
  1678	        )
  1679	        self._plugin_command_names: set[str] = set()
  1680	        self._inject_plugin_resources(self._loaded_plugins)
  1681	        logger.info(
  1682	            "Loaded %d plugins (%d errors)",
  1683	            len(self._loaded_plugins),
  1684	            len(self._plugin_errors),
  1685	        )
  1686	
  1687	    def _inject_plugin_resources(self, plugins: list) -> None:
  1688	        """Inject plugin skills, commands, and MCP servers into the session."""
  1689	        from types import MappingProxyType
  1690	
  1691	        from comate_agent_sdk.subagent.agent_tool import rebuild_agent_tool
  1692	        from comate_agent_sdk.skill.execution import rebuild_skill_tool
  1693	
  1694	        agent = self._session._agent
  1695	        for plugin in plugins:
  1696	            # Skills → runtime_state.skills (mutable list)
  1697	            if plugin.skills:
  1698	                if agent._runtime_state.skills is None:
  1699	                    agent._runtime_state.skills = []
  1700	                agent._runtime_state.skills.extend(plugin.skills)
  1701	
  1702	            # Commands → SlashCommandRegistry with namespace prefix
  1703	            for cmd in plugin.commands:
  1704	                if cmd.name in getattr(self, "_skill_slash_command_names", set()):
  1705	                    self._slash_registry.unregister(cmd.name)
  1706	                    self._skill_slash_command_names.discard(cmd.name)
  1707	                spec = SlashCommandSpec(
  1708	                    name=cmd.name,  # already namespaced: "plugin-name:cmd-name"
  1709	                    description=cmd.description,
  1710	                    execution_kind="hybrid",
  1711	                    argument_hint=cmd.argument_hint,
  1712	                )
  1713	                custom_cmd = CustomSlashCommand(
  1714	                    name=cmd.name,
  1715	                    description=cmd.description,
  1716	                    template=cmd.template,
  1717	                    source_scope="plugin",
  1718	                    namespace=plugin.namespace,
  1719	                    source_path=cmd.source_path,
  1720	                    argument_hint=cmd.argument_hint,
  1721	                )
  1722	                try:
  1723	                    self._slash_registry.register(
  1724	                        spec=spec,
  1725	                        handler=lambda args, _cmd=custom_cmd: self._slash_custom(
  1726	                            command_name=_cmd.name, args=args
  1727	                        ),
  1728	                        source="custom",
  1729	                    )
  1730	                    self._custom_slash_commands[cmd.name] = custom_cmd
  1731	                    self._plugin_command_names.add(cmd.name)
  1732	                except ValueError:
  1733	                    logger.warning(
  1734	                        "Plugin command '%s' conflicts with existing command, skipping",
  1735	                        cmd.name,
  1736	                    )
  1737	
  1738	            # Agents → runtime_state.agents (mutable list)
  1739	            if plugin.agents:
  1740	                if agent._runtime_state.agents is None:
  1741	                    agent._runtime_state.agents = []
  1742	                agent._runtime_state.agents.extend(plugin.agents)
  1743	
  1744	            # MCP servers → merge into agent config (frozen) and invalidate
  1745	            # plugin.mcp_servers keys are already namespaced as "{namespace}:{key}"
  1746	            if plugin.mcp_servers:
  1747	                existing = dict(agent.config.mcp_servers) if agent.config.mcp_servers else {}
  1748	                for server_name, config in plugin.mcp_servers.items():
  1749	                    existing[server_name] = config
  1750	                object.__setattr__(agent.config, "mcp_servers", MappingProxyType(existing))
  1751	
  1752	        # If any plugin had MCP servers, trigger reload
  1753	        if any(p.mcp_servers for p in plugins):
  1754	            agent.invalidate_mcp_tools(reason="plugin_inject")
  1755	
  1756	        # Rebuild Skill tool so LLM sees new plugin skills in description
  1757	        if any(p.skills for p in plugins):
  1758	            rebuild_skill_tool(agent)
  1759	
  1760	        # Rebuild Agent tool so LLM sees new plugin agents in description
  1761	        if any(p.agents for p in plugins):
  1762	            rebuild_agent_tool(agent)
  1763	
  1764	        if any(p.skills for p in plugins):
  1765	            self._sync_skill_slash_commands()
  1766	        # Refresh tab-completion so new plugin commands appear
  1767	        elif any(p.commands for p in plugins):
  1768	            self._slash_completer.update_commands(self._slash_registry.command_specs())
  1769	
  1770	    def _clear_plugin_resources(self) -> None:
  1771	        """Remove all previously injected plugin resources."""
  1772	        from types import MappingProxyType
  1773	        from comate_agent_sdk.subagent.agent_tool import rebuild_agent_tool
  1774	        from comate_agent_sdk.skill.execution import rebuild_skill_tool
  1775	
  1776	        agent = self._session._agent
  1777	
  1778	        # Remove plugin skills (by namespace prefix)
  1779	        loaded = getattr(self, "_loaded_plugins", [])
  1780	
  1781	        # 提前计算 namespaces 和变更检测（在过滤之前，否则 had_* 永远为 False）
  1782	        namespaces = {p.namespace for p in loaded} if loaded else set()
  1783	
  1784	        had_skills = bool(namespaces and agent._runtime_state.skills and any(
  1785	            s.name.startswith(f"{ns}:") for s in agent._runtime_state.skills for ns in namespaces
  1786	        ))
  1787	        had_agents = bool(namespaces and agent._runtime_state.agents and any(
  1788	            a.name.startswith(f"{ns}:") for a in agent._runtime_state.agents for ns in namespaces
  1789	        ))
  1790	
  1791	        # Remove plugin skills (by namespace prefix) — namespaces 已在上方定义
  1792	        if namespaces and agent._runtime_state.skills:
  1793	            agent._runtime_state.skills = [
  1794	                s for s in agent._runtime_state.skills
  1795	                if not any(s.name.startswith(f"{ns}:") for ns in namespaces)
  1796	            ]
  1797	
  1798	        # Remove plugin commands from registry and custom commands dict
  1799	        had_commands = bool(getattr(self, "_plugin_command_names", set()))
  1800	        for cmd_name in getattr(self, "_plugin_command_names", set()):
  1801	            self._slash_registry.unregister(cmd_name)
  1802	            self._custom_slash_commands.pop(cmd_name, None)
  1803	        self._plugin_command_names = set()
  1804	
  1805	        # Remove plugin MCP servers from frozen config
  1806	        has_mcp = any(p.mcp_servers for p in loaded)
  1807	        if has_mcp and agent.config.mcp_servers:
  1808	            mcp = dict(agent.config.mcp_servers)
  1809	            for p in loaded:
  1810	                for server_name in p.mcp_servers:
  1811	                    mcp.pop(server_name, None)
  1812	            frozen = MappingProxyType(mcp) if mcp else None
  1813	            object.__setattr__(agent.config, "mcp_servers", frozen)
  1814	            agent.invalidate_mcp_tools(reason="plugin_clear")
  1815	
  1816	        # Remove plugin agents (by namespace prefix)
  1817	        if namespaces and agent._runtime_state.agents:
  1818	            agent._runtime_state.agents = [
  1819	                a for a in agent._runtime_state.agents
  1820	                if not any(a.name.startswith(f"{ns}:") for ns in namespaces)
  1821	            ]
  1822	
  1823	        # Rebuild tools if plugin resources were removed
  1824	        if had_skills:
  1825	            rebuild_skill_tool(agent)
  1826	        if had_agents:
  1827	            rebuild_agent_tool(agent)
  1828	        if had_skills or had_commands:
  1829	            self._sync_skill_slash_commands()