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

Lines 1-1741 of 1741

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