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

Lines 1-1762 of 1762

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