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

Lines 1-1810 of 1810

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