92df3e1844
* feat(cli): migrate command handling to CommandManager and enhance UI interactions * feat(cli): add /clear and /help commands to enhance user experience * feat(cli): implement /new and /resume commands with interactive session management * Refactor MCP and Skills Command Handling - Moved the interactive picker style to a centralized widget for consistency across MCP and Skills commands. - Updated the MCP command to remove the old command dispatch logic, delegating to the new InstallMCPCommand. - Enhanced the Skills command to utilize a new interactive picker for skill selection, improving user experience. - Implemented cancellation handling in the picker to differentiate between user cancellations and empty selections. - Added comprehensive tests for the new command structures and picker functionalities to ensure reliability. * refactor(cli): streamline CommandManager dispatch and remove deprecated command set * refactor(cli): enhance error handling and state management in ChannelCommand and RichCLICommandUI * refactor(cli): update lifecycle callback terminology and improve async prompt handling in RichCLICommandUI * refactor(cli): enhance SlashCommandCompleter to dynamically fetch workspace directory for autocompletion * refactor(cli): unify quit handling in RichCLICommandUI with shared _stop helper * refactor(cli): remove hardcoded slash commands and utilize command manager for dynamic completion * refactor(cli): update MCP and skills command files for improved clarity and organization * refactor(mcp_ui): remove unnecessary newline in _show_mcp_config function
257 lines
10 KiB
Python
257 lines
10 KiB
Python
"""CommandUI Protocol adapter for the Rich CLI surface.
|
|
|
|
Lifecycle methods (``request_quit``, ``force_quit``, ``clear_chat``,
|
|
``start_new_session``, ``handle_session_resume``, ``update_status_after_compact``)
|
|
are callback-driven: when their corresponding ``on_*`` constructor kwarg
|
|
is ``None``, the method is a silent no-op, mirroring
|
|
``ChannelCommandUI``'s fallback pattern. Callers that need a specific
|
|
side-effect (REPL quit flag flip, status-bar refresh, …) wire the
|
|
callback at construction time; non-interactive surfaces (tests,
|
|
alternate REPLs) can leave callbacks unset without crashing.
|
|
|
|
``wait_for_*`` methods return ``None`` on cancel / fallback and are
|
|
always safe to ``await``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any
|
|
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
|
|
from ..commands.base import CommandUI
|
|
|
|
|
|
class RichCLICommandUI(CommandUI):
|
|
"""CommandUI implementation that prints to a Rich ``Console``.
|
|
|
|
Commands that affect CLI-closure state (session lifecycle, exit flag,
|
|
status-bar snapshot) go through optional callbacks wired by the REPL.
|
|
This mirrors ``ChannelCommandUI``'s injection pattern and keeps
|
|
``interactive.py``'s ``state`` dict as the single source of truth.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
console: Console,
|
|
*,
|
|
on_request_quit: Callable[[], None] | None = None,
|
|
on_force_quit: Callable[[], None] | None = None,
|
|
on_clear_chat: Callable[[], None] | None = None,
|
|
on_status_after_compact: Callable[[int], None] | None = None,
|
|
on_start_new_session: Callable[[], None] | None = None,
|
|
on_handle_session_resume: (
|
|
Callable[[str, str | None], Awaitable[None]] | None
|
|
) = None,
|
|
) -> None:
|
|
self.console = console
|
|
self._on_request_quit = on_request_quit
|
|
self._on_force_quit = on_force_quit
|
|
self._on_clear_chat = on_clear_chat
|
|
self._on_status_after_compact = on_status_after_compact
|
|
self._on_start_new_session = on_start_new_session
|
|
self._on_handle_session_resume = on_handle_session_resume
|
|
# Bound ``console.status(...)`` context manager used by
|
|
# /compact's start/stop indicator pair.
|
|
self._compact_status_ctx: Any = None
|
|
|
|
# ── Core I/O ─────────────────────────────────────────────
|
|
|
|
@property
|
|
def supports_interactive(self) -> bool:
|
|
return True
|
|
|
|
def append_system(self, text: str, style: str = "dim") -> None:
|
|
self.console.print(text, style=style)
|
|
|
|
def mount_renderable(self, renderable: Any) -> None:
|
|
self.console.print(renderable)
|
|
|
|
async def flush(self) -> None:
|
|
# Rich console flushes synchronously; nothing to await.
|
|
return
|
|
|
|
# ── /model interactive picker fallback ──────────────────
|
|
|
|
async def wait_for_model_pick(
|
|
self,
|
|
entries: list[tuple[str, str, str]],
|
|
current_model: str | None,
|
|
current_provider: str | None,
|
|
) -> tuple[str, str] | None:
|
|
"""Print the model table and return ``None``; user re-runs with
|
|
``/model <name>`` since the CLI has no interactive picker."""
|
|
table = Table(
|
|
title="Available Models",
|
|
show_header=True,
|
|
header_style="bold cyan",
|
|
)
|
|
table.add_column("Name", style="bold")
|
|
table.add_column("Provider", style="dim")
|
|
for name, _mid, prov in entries:
|
|
marker = " *" if name == current_model and prov == current_provider else ""
|
|
table.add_row(f"{name}{marker}", prov)
|
|
self.console.print(table)
|
|
self.console.print(
|
|
"[dim]Usage: /model <name> [provider] [--save] — "
|
|
"provider is optional, auto-detected from model name[/dim]"
|
|
)
|
|
return None
|
|
|
|
def update_status_after_model_change(
|
|
self, new_model: str, new_provider: str | None = None
|
|
) -> None:
|
|
"""No-op; the CLI REPL refreshes status itself after detecting an
|
|
``ctx.agent`` change post-``cmd_manager.execute``."""
|
|
return
|
|
|
|
# ── Interactive pickers ────────────────────────────────
|
|
|
|
async def wait_for_thread_pick(
|
|
self, threads: list[dict], current_thread: str, title: str
|
|
) -> str | None:
|
|
"""Interactive workspace-grouped thread picker using ``questionary``.
|
|
|
|
Ported from the pre-migration ``_cmd_resume`` implementation.
|
|
Returns the selected ``thread_id`` string, or ``None`` on cancel.
|
|
Callers (``ResumeCommand``/``DeleteCommand``) pre-check for
|
|
empty thread lists before invoking this method.
|
|
"""
|
|
import questionary # type: ignore[import-untyped]
|
|
from prompt_toolkit.layout.dimension import ( # type: ignore[import-untyped]
|
|
Dimension,
|
|
)
|
|
from questionary.prompts.common import ( # type: ignore[import-untyped]
|
|
InquirerControl,
|
|
)
|
|
|
|
from ..sessions import _format_relative_time
|
|
from .widgets.thread_selector import PICKER_STYLE, _build_items
|
|
|
|
choices: list[Any] = []
|
|
for item in _build_items(threads):
|
|
if item["type"] == "header":
|
|
choices.append(questionary.Separator(f"── \U0001f4c2 {item['label']}"))
|
|
elif item["type"] == "subheader":
|
|
choices.append(questionary.Separator(f" {item['label']}"))
|
|
else:
|
|
t = item["thread"]
|
|
tid = t["thread_id"]
|
|
preview = t.get("preview", "") or ""
|
|
msgs = t.get("message_count", 0)
|
|
model = t.get("model", "") or ""
|
|
when = _format_relative_time(t.get("updated_at"))
|
|
indent = " " if item.get("indented") else " "
|
|
marker = " *" if tid == current_thread else ""
|
|
parts = [f"{indent}{tid}{marker}"]
|
|
if preview:
|
|
parts.append(preview[:40] + "…" if len(preview) > 40 else preview)
|
|
parts.append(f"({msgs} msgs)")
|
|
if model:
|
|
parts.append(model)
|
|
if when:
|
|
parts.append(when)
|
|
label = " ".join(parts)
|
|
choices.append(questionary.Choice(title=label, value=tid))
|
|
|
|
prompt = questionary.select(title, choices=choices, style=PICKER_STYLE)
|
|
# Limit visible list to 10 rows with scrolling. Touches
|
|
# questionary/prompt-toolkit private internals so guard against
|
|
# library-shape changes — picker stays functional at default
|
|
# height even if the cap fails.
|
|
try:
|
|
for window in prompt.application.layout.find_all_windows():
|
|
if isinstance(window.content, InquirerControl):
|
|
window.height = Dimension(max=10)
|
|
break
|
|
except Exception:
|
|
pass
|
|
# ``ask_async`` (questionary >= 2.0.1) avoids blocking the
|
|
# asyncio event loop while the user interacts with the picker.
|
|
return await prompt.ask_async()
|
|
|
|
# ── Lifecycle callbacks ───────────────────────────────
|
|
|
|
def clear_chat(self) -> None:
|
|
if self._on_clear_chat is not None:
|
|
self._on_clear_chat()
|
|
else:
|
|
self.console.clear()
|
|
|
|
def request_quit(self) -> None:
|
|
if self._on_request_quit is not None:
|
|
self._on_request_quit()
|
|
|
|
def force_quit(self) -> None:
|
|
if self._on_force_quit is not None:
|
|
self._on_force_quit()
|
|
|
|
def start_new_session(self) -> None:
|
|
if self._on_start_new_session is not None:
|
|
self._on_start_new_session()
|
|
|
|
async def handle_session_resume(
|
|
self, thread_id: str, workspace_dir: str | None = None
|
|
) -> None:
|
|
if self._on_handle_session_resume is not None:
|
|
await self._on_handle_session_resume(thread_id, workspace_dir)
|
|
|
|
# /compact indicator pair — duck-typed by ``CompactCommand`` via
|
|
# ``getattr``, not declared on the ``CommandUI`` Protocol.
|
|
def start_compacting_indicator(self) -> None:
|
|
# Idempotent: close any lingering context before starting a new
|
|
# one so a double-call (e.g. two overlapping /compact attempts
|
|
# via the message queue) can't leak a Rich Live handle.
|
|
if self._compact_status_ctx is not None:
|
|
try:
|
|
self._compact_status_ctx.__exit__(None, None, None)
|
|
except Exception:
|
|
pass
|
|
self._compact_status_ctx = None
|
|
status = self.console.status("[cyan]Compacting conversation...[/cyan]")
|
|
status.__enter__()
|
|
self._compact_status_ctx = status
|
|
|
|
def stop_compacting_indicator(self) -> None:
|
|
ctx = self._compact_status_ctx
|
|
self._compact_status_ctx = None
|
|
if ctx is not None:
|
|
try:
|
|
ctx.__exit__(None, None, None)
|
|
except Exception:
|
|
pass
|
|
|
|
def update_status_after_compact(self, input_tokens: int) -> None:
|
|
if self._on_status_after_compact is not None:
|
|
self._on_status_after_compact(input_tokens)
|
|
|
|
# ── Skill / MCP browse (delegated to worker threads) ──
|
|
|
|
async def wait_for_skill_browse(
|
|
self, index: list[dict], installed_names: set[str], pre_filter_tag: str
|
|
) -> list[str] | None:
|
|
"""Delegate to the extracted questionary picker on a worker
|
|
thread — questionary blocks the event loop so the call must
|
|
not happen on the main asyncio thread."""
|
|
import asyncio
|
|
|
|
from .skills_cmd import _pick_skills_interactive
|
|
|
|
return await asyncio.to_thread(
|
|
_pick_skills_interactive, index, installed_names, pre_filter_tag
|
|
)
|
|
|
|
async def wait_for_mcp_browse(
|
|
self, servers: list, installed_names: set[str], pre_filter_tag: str
|
|
) -> list | None:
|
|
"""Delegate to the MCP browse picker on a worker thread."""
|
|
import asyncio
|
|
|
|
from .mcp_install_cmd import _browse_and_select
|
|
|
|
return await asyncio.to_thread(
|
|
_browse_and_select, servers, installed_names, pre_filter_tag
|
|
)
|