v2.7.4 (#579)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai> Co-authored-by: Peter Evers <pevers90@gmail.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
90763daf81
commit
e9a9217cc8
113 changed files with 7202 additions and 541 deletions
|
|
@ -9,8 +9,6 @@ import subprocess
|
|||
import pyperclip
|
||||
from textual.app import App
|
||||
|
||||
_PREVIEW_MAX_LENGTH = 40
|
||||
|
||||
|
||||
def _copy_osc52(text: str) -> None:
|
||||
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||
|
|
@ -117,13 +115,6 @@ def _copy_to_clipboard(text: str) -> None:
|
|||
raise RuntimeError("All clipboard strategies failed")
|
||||
|
||||
|
||||
def _shorten_preview(texts: list[str]) -> str:
|
||||
dense_text = "⏎".join(texts).replace("\n", "⏎")
|
||||
if len(dense_text) > _PREVIEW_MAX_LENGTH:
|
||||
return f"{dense_text[: _PREVIEW_MAX_LENGTH - 1]}…"
|
||||
return dense_text
|
||||
|
||||
|
||||
def _get_selected_texts(app: App) -> list[str]:
|
||||
selected_texts = []
|
||||
|
||||
|
|
@ -156,7 +147,7 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None
|
|||
_copy_to_clipboard(combined_text)
|
||||
if show_toast:
|
||||
app.notify(
|
||||
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
|
||||
"Selection copied to clipboard",
|
||||
severity="information",
|
||||
timeout=2,
|
||||
markup=False,
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class CommandRegistry:
|
|||
),
|
||||
"reload": Command(
|
||||
aliases=frozenset(["/reload"]),
|
||||
description="Reload configuration from disk",
|
||||
description="Reload configuration, agent instructions, and skills from disk",
|
||||
handler="_reload_config",
|
||||
),
|
||||
"clear": Command(
|
||||
|
|
@ -49,6 +49,11 @@ class CommandRegistry:
|
|||
description="Show path to current interaction log file",
|
||||
handler="_show_log_path",
|
||||
),
|
||||
"debug": Command(
|
||||
aliases=frozenset(["/debug"]),
|
||||
description="Toggle debug console",
|
||||
handler="action_toggle_debug_console",
|
||||
),
|
||||
"compact": Command(
|
||||
aliases=frozenset(["/compact"]),
|
||||
description="Compact conversation history by summarizing",
|
||||
|
|
@ -85,6 +90,11 @@ class CommandRegistry:
|
|||
description="Browse and resume past sessions",
|
||||
handler="_show_session_picker",
|
||||
),
|
||||
"mcp": Command(
|
||||
aliases=frozenset(["/mcp"]),
|
||||
description="Display available MCP servers. Pass the name of a server to list its tools",
|
||||
handler="_show_mcp",
|
||||
),
|
||||
"voice": Command(
|
||||
aliases=frozenset(["/voice"]),
|
||||
description="Configure voice settings",
|
||||
|
|
@ -120,13 +130,23 @@ class CommandRegistry:
|
|||
for alias in cmd.aliases:
|
||||
self._alias_map[alias] = cmd_name
|
||||
|
||||
def find_command(self, user_input: str) -> Command | None:
|
||||
cmd_name = self.get_command_name(user_input)
|
||||
return self.commands.get(cmd_name) if cmd_name else None
|
||||
|
||||
def get_command_name(self, user_input: str) -> str | None:
|
||||
return self._alias_map.get(user_input.lower().strip())
|
||||
|
||||
def parse_command(self, user_input: str) -> tuple[str, Command, str] | None:
|
||||
parts = user_input.strip().split(None, 1)
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
cmd_word = parts[0]
|
||||
cmd_args = parts[1] if len(parts) > 1 else ""
|
||||
cmd_name = self.get_command_name(cmd_word)
|
||||
if cmd_name is None:
|
||||
return None
|
||||
|
||||
command = self.commands[cmd_name]
|
||||
return cmd_name, command, cmd_args
|
||||
|
||||
def get_help_text(self) -> str:
|
||||
lines: list[str] = [
|
||||
"### Keyboard Shortcuts",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class HistoryManager:
|
|||
|
||||
def add(self, text: str) -> None:
|
||||
text = text.strip()
|
||||
if not text or text.startswith("/"):
|
||||
if not text:
|
||||
return
|
||||
|
||||
if self._entries and self._entries[-1] == text:
|
||||
|
|
|
|||
81
vibe/cli/profiler.py
Normal file
81
vibe/cli/profiler.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""General-purpose profiler for measuring any section of the application.
|
||||
|
||||
Wraps pyinstrument (dev-only dependency). Silently no-ops when not installed.
|
||||
Activated by the VIBE_PROFILE=1 environment variable.
|
||||
|
||||
Usage:
|
||||
from vibe.cli import profiler
|
||||
|
||||
profiler.start("startup")
|
||||
# ... code to profile ...
|
||||
profiler.stop_and_print()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyinstrument import Profiler
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _State:
|
||||
profiler: Profiler | None = None
|
||||
label: str = "default"
|
||||
|
||||
|
||||
_state = _State()
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Return True when profiling is activated via environment variable."""
|
||||
return bool(os.environ.get("VIBE_PROFILE"))
|
||||
|
||||
|
||||
def start(label: str = "default") -> None:
|
||||
"""Start profiling. The label is used to name the output file.
|
||||
|
||||
No-op if pyinstrument is missing or env var unset.
|
||||
"""
|
||||
if not is_enabled():
|
||||
return
|
||||
try:
|
||||
from pyinstrument import Profiler
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
if _state.profiler is not None:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"Profiler already running; stop it before starting a new one.", stacklevel=2
|
||||
)
|
||||
return
|
||||
|
||||
_state.label = label
|
||||
_state.profiler = Profiler()
|
||||
_state.profiler.start()
|
||||
|
||||
|
||||
def stop_and_print() -> None:
|
||||
"""Stop profiling, write an HTML report, and print a text summary to stderr."""
|
||||
if _state.profiler is None:
|
||||
return
|
||||
_state.profiler.stop()
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
output_path = Path(f"{_state.label}-profile.html")
|
||||
output_path.write_text(_state.profiler.output_html(), encoding="utf-8")
|
||||
print(
|
||||
f"\n[profiler:{_state.label}] Saved HTML profile to {output_path.resolve()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(_state.profiler.output_text(color=True), file=sys.stderr)
|
||||
|
||||
_state.profiler = None
|
||||
_state.label = "default"
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import aclosing
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
import gc
|
||||
|
|
@ -56,9 +57,11 @@ from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
|||
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
||||
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
|
||||
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
|
||||
from vibe.cli.textual_ui.widgets.debug_console import DebugConsole
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
|
||||
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
|
||||
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
|
||||
from vibe.cli.textual_ui.widgets.mcp_app import MCPApp
|
||||
from vibe.cli.textual_ui.widgets.messages import (
|
||||
BashOutputMessage,
|
||||
ErrorMessage,
|
||||
|
|
@ -112,6 +115,7 @@ from vibe.core.audio_recorder import AudioRecorder
|
|||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
|
||||
from vibe.core.log_reader import LogReader
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.rewind import RewindError
|
||||
|
|
@ -171,6 +175,7 @@ class BottomApp(StrEnum):
|
|||
Approval = auto()
|
||||
Config = auto()
|
||||
Input = auto()
|
||||
MCP = auto()
|
||||
ModelPicker = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
|
|
@ -184,7 +189,7 @@ class ChatScroll(VerticalScroll):
|
|||
|
||||
@property
|
||||
def is_at_bottom(self) -> bool:
|
||||
return self.scroll_target_y >= (self.max_scroll_y - 3)
|
||||
return self.scroll_target_y >= self.max_scroll_y
|
||||
|
||||
_reanchor_pending: bool = False
|
||||
_scrolling_down: bool = False
|
||||
|
|
@ -281,6 +286,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
Binding(
|
||||
"shift+down", "scroll_chat_down", "Scroll Down", show=False, priority=True
|
||||
),
|
||||
Binding("ctrl+backslash", "toggle_debug_console", "Debug Console", show=False),
|
||||
Binding("alt+up", "rewind_prev", "Rewind Previous", show=False, priority=True),
|
||||
Binding("ctrl+p", "rewind_prev", "Rewind Previous", show=False, priority=True),
|
||||
Binding("alt+down", "rewind_next", "Rewind Next", show=False, priority=True),
|
||||
|
|
@ -354,6 +360,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._cached_messages_area: Widget | None = None
|
||||
self._cached_chat: ChatScroll | None = None
|
||||
self._cached_loading_area: Widget | None = None
|
||||
self._log_reader = LogReader()
|
||||
self._debug_console: DebugConsole | None = None
|
||||
self._switch_agent_generation = 0
|
||||
self._plan_info: PlanInfo | None = None
|
||||
self._narrator_manager: NarratorManagerPort = (
|
||||
|
|
@ -663,6 +671,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_mcpapp_mcpclosed(self, _message: MCPApp.MCPClosed) -> None:
|
||||
await self._mount_and_scroll(UserCommandMessage("MCP servers closed."))
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_proxy_setup_app_proxy_setup_closed(
|
||||
self, message: ProxySetupApp.ProxySetupClosed
|
||||
) -> None:
|
||||
|
|
@ -700,17 +712,17 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await widget.remove()
|
||||
|
||||
async def _handle_command(self, user_input: str) -> bool:
|
||||
if command := self.commands.find_command(user_input):
|
||||
if cmd_name := self.commands.get_command_name(user_input):
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(
|
||||
cmd_name, "builtin"
|
||||
)
|
||||
if resolved := self.commands.parse_command(user_input):
|
||||
cmd_name, command, cmd_args = resolved
|
||||
self.agent_loop.telemetry_client.send_slash_command_used(
|
||||
cmd_name, "builtin"
|
||||
)
|
||||
await self._mount_and_scroll(UserMessage(user_input))
|
||||
handler = getattr(self, command.handler)
|
||||
if asyncio.iscoroutinefunction(handler):
|
||||
await handler()
|
||||
await handler(cmd_args=cmd_args)
|
||||
else:
|
||||
handler()
|
||||
handler(cmd_args=cmd_args)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -781,17 +793,103 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._mount_and_scroll(
|
||||
BashOutputMessage(command, str(Path.cwd()), output, exit_code)
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
await self.agent_loop.inject_user_context(
|
||||
self._format_manual_command_context(
|
||||
command=command,
|
||||
cwd=str(Path.cwd()),
|
||||
exit_code=exit_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
stdout = (
|
||||
error.stdout.decode("utf-8", errors="replace")
|
||||
if isinstance(error.stdout, bytes)
|
||||
else (error.stdout or "")
|
||||
)
|
||||
stderr = (
|
||||
error.stderr.decode("utf-8", errors="replace")
|
||||
if isinstance(error.stderr, bytes)
|
||||
else (error.stderr or "")
|
||||
)
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
"Command timed out after 30 seconds",
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
await self.agent_loop.inject_user_context(
|
||||
self._format_manual_command_context(
|
||||
command=command,
|
||||
cwd=str(Path.cwd()),
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
status="timed out after 30 seconds",
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(f"Command failed: {e}", collapsed=self._tools_collapsed)
|
||||
)
|
||||
await self.agent_loop.inject_user_context(
|
||||
self._format_manual_command_context(
|
||||
command=command,
|
||||
cwd=str(Path.cwd()),
|
||||
status=f"failed before completion: {e}",
|
||||
)
|
||||
)
|
||||
|
||||
def _get_bash_max_output_bytes(self) -> int:
|
||||
from vibe.core.tools.builtins.bash import BashToolConfig
|
||||
|
||||
config = self.agent_loop.tool_manager.get_tool_config("bash")
|
||||
if isinstance(config, BashToolConfig):
|
||||
return config.max_output_bytes
|
||||
return BashToolConfig().max_output_bytes
|
||||
|
||||
@staticmethod
|
||||
def _cap_output(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit] + "\n... [truncated]"
|
||||
|
||||
def _format_manual_command_context(
|
||||
self,
|
||||
*,
|
||||
command: str,
|
||||
cwd: str,
|
||||
stdout: str = "",
|
||||
stderr: str = "",
|
||||
exit_code: int | None = None,
|
||||
status: str | None = None,
|
||||
) -> str:
|
||||
limit = self._get_bash_max_output_bytes()
|
||||
stdout = self._cap_output(stdout, limit)
|
||||
stderr = self._cap_output(stderr, limit)
|
||||
|
||||
sections = [
|
||||
"Manual `!` command result from the user. Use this as context only.",
|
||||
f"Command: `{command}`",
|
||||
f"Working directory: `{cwd}`",
|
||||
]
|
||||
|
||||
if status is not None:
|
||||
sections.append(f"Status: {status}")
|
||||
|
||||
if exit_code is not None:
|
||||
sections.append(f"Exit code: {exit_code}")
|
||||
|
||||
if stdout:
|
||||
sections.append(f"Stdout:\n```text\n{stdout.rstrip()}\n```")
|
||||
|
||||
if stderr:
|
||||
sections.append(f"Stderr:\n```text\n{stderr.rstrip()}\n```")
|
||||
|
||||
if not stdout and not stderr:
|
||||
sections.append("Output:\n```text\n(no output)\n```")
|
||||
|
||||
return "\n\n".join(sections)
|
||||
|
||||
async def _handle_user_message(self, message: str) -> None:
|
||||
if self._remote_manager.is_active:
|
||||
|
|
@ -967,20 +1065,21 @@ class VibeApp(App): # noqa: PLR0904
|
|||
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
|
||||
self._narrator_manager.cancel()
|
||||
self._narrator_manager.on_turn_start(rendered_prompt)
|
||||
async for event in self.agent_loop.act(rendered_prompt):
|
||||
self._narrator_manager.on_turn_event(event)
|
||||
if isinstance(event, WaitingForInputEvent):
|
||||
await self._remove_loading_widget()
|
||||
if self._remote_manager.is_active:
|
||||
await self._handle_remote_waiting_input(event)
|
||||
elif self._loading_widget is None and is_progress_event(event):
|
||||
await self._ensure_loading_widget()
|
||||
if self.event_handler:
|
||||
await self.event_handler.handle_event(
|
||||
event,
|
||||
loading_active=self._loading_widget is not None,
|
||||
loading_widget=self._loading_widget,
|
||||
)
|
||||
async with aclosing(self.agent_loop.act(rendered_prompt)) as events:
|
||||
async for event in events:
|
||||
self._narrator_manager.on_turn_event(event)
|
||||
if isinstance(event, WaitingForInputEvent):
|
||||
await self._remove_loading_widget()
|
||||
if self._remote_manager.is_active:
|
||||
await self._handle_remote_waiting_input(event)
|
||||
elif self._loading_widget is None and is_progress_event(event):
|
||||
await self._ensure_loading_widget()
|
||||
if self.event_handler:
|
||||
await self.event_handler.handle_event(
|
||||
event,
|
||||
loading_active=self._loading_widget is not None,
|
||||
loading_widget=self._loading_widget,
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await self._handle_turn_error()
|
||||
|
|
@ -1020,7 +1119,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
|
||||
return "Rate limits exceeded. Please wait a moment before trying again."
|
||||
|
||||
async def _teleport_command(self) -> None:
|
||||
async def _teleport_command(self, **kwargs: Any) -> None:
|
||||
await self._handle_teleport_command(show_message=False)
|
||||
|
||||
async def _handle_teleport_command(
|
||||
|
|
@ -1172,11 +1271,39 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self._interrupt_requested = False
|
||||
|
||||
async def _show_help(self) -> None:
|
||||
async def _show_help(self, **kwargs: Any) -> None:
|
||||
help_text = self.commands.get_help_text()
|
||||
await self._mount_and_scroll(UserCommandMessage(help_text))
|
||||
|
||||
async def _show_status(self) -> None:
|
||||
async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None:
|
||||
mcp_servers = self.config.mcp_servers
|
||||
if not mcp_servers:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("No MCP servers configured.")
|
||||
)
|
||||
return
|
||||
if self._current_bottom_app == BottomApp.MCP:
|
||||
return
|
||||
name = cmd_args.strip()
|
||||
if name and not any(s.name == name for s in mcp_servers):
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
f"Unknown MCP server: {name}. Known servers: "
|
||||
+ ", ".join(s.name for s in mcp_servers),
|
||||
collapsed=self._tools_collapsed,
|
||||
)
|
||||
)
|
||||
return
|
||||
await self._mount_and_scroll(UserCommandMessage("MCP servers opened..."))
|
||||
await self._switch_from_input(
|
||||
MCPApp(
|
||||
mcp_servers=mcp_servers,
|
||||
tool_manager=self.agent_loop.tool_manager,
|
||||
initial_server=name,
|
||||
)
|
||||
)
|
||||
|
||||
async def _show_status(self, **kwargs: Any) -> None:
|
||||
stats = self.agent_loop.stats
|
||||
status_text = f"""## Agent Statistics
|
||||
|
||||
|
|
@ -1189,27 +1316,27 @@ class VibeApp(App): # noqa: PLR0904
|
|||
"""
|
||||
await self._mount_and_scroll(UserCommandMessage(status_text))
|
||||
|
||||
async def _show_config(self) -> None:
|
||||
async def _show_config(self, **kwargs: Any) -> None:
|
||||
"""Switch to the configuration app in the bottom panel."""
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
return
|
||||
await self._switch_to_config_app()
|
||||
|
||||
async def _show_model(self) -> None:
|
||||
async def _show_model(self, **kwargs: Any) -> None:
|
||||
"""Switch to the model picker in the bottom panel."""
|
||||
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||
return
|
||||
await self._switch_to_model_picker_app()
|
||||
|
||||
async def _show_proxy_setup(self) -> None:
|
||||
async def _show_proxy_setup(self, **kwargs: Any) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
await self._switch_to_proxy_setup_app()
|
||||
|
||||
async def _show_data_retention(self) -> None:
|
||||
async def _show_data_retention(self, **kwargs: Any) -> None:
|
||||
await self._mount_and_scroll(UserCommandMessage(DATA_RETENTION_MESSAGE))
|
||||
|
||||
async def _show_session_picker(self) -> None:
|
||||
async def _show_session_picker(self, **kwargs: Any) -> None:
|
||||
cwd = str(Path.cwd())
|
||||
local_sessions = (
|
||||
list_local_resume_sessions(self.config, cwd)
|
||||
|
|
@ -1405,7 +1532,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
def loading_widget(self) -> LoadingWidget | None:
|
||||
return self._loading_widget
|
||||
|
||||
async def _reload_config(self) -> None:
|
||||
async def _reload_config(self, **kwargs: Any) -> None:
|
||||
try:
|
||||
self._reset_ui_state()
|
||||
await self._load_more.hide()
|
||||
|
|
@ -1422,7 +1549,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.mcp_registry,
|
||||
plan_title(self._plan_info),
|
||||
)
|
||||
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded."))
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
"Configuration reloaded (includes agent instructions and skills)."
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
|
|
@ -1430,7 +1561,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
)
|
||||
|
||||
async def _install_lean(self) -> None:
|
||||
async def _install_lean(self, **kwargs: Any) -> None:
|
||||
current = list(self.agent_loop.base_config.installed_agents)
|
||||
if "lean" in current:
|
||||
await self._mount_and_scroll(
|
||||
|
|
@ -1440,7 +1571,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
VibeConfig.save_updates({"installed_agents": sorted([*current, "lean"])})
|
||||
await self._reload_config()
|
||||
|
||||
async def _uninstall_lean(self) -> None:
|
||||
async def _uninstall_lean(self, **kwargs: Any) -> None:
|
||||
current = list(self.agent_loop.base_config.installed_agents)
|
||||
if "lean" not in current:
|
||||
await self._mount_and_scroll(
|
||||
|
|
@ -1452,7 +1583,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
})
|
||||
await self._reload_config()
|
||||
|
||||
async def _clear_history(self) -> None:
|
||||
async def _clear_history(self, **kwargs: Any) -> None:
|
||||
try:
|
||||
self._reset_ui_state()
|
||||
if self._remote_manager.is_active:
|
||||
|
|
@ -1482,7 +1613,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
)
|
||||
|
||||
async def _show_log_path(self) -> None:
|
||||
async def _show_log_path(self, **kwargs: Any) -> None:
|
||||
if not self.agent_loop.session_logger.enabled:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
|
|
@ -1506,7 +1637,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
)
|
||||
|
||||
async def _compact_history(self) -> None:
|
||||
async def _compact_history(self, **kwargs: Any) -> None:
|
||||
if self._agent_running:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
|
|
@ -1570,11 +1701,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return None
|
||||
return short_session_id(self.agent_loop.session_logger.session_id)
|
||||
|
||||
async def _exit_app(self) -> None:
|
||||
async def _exit_app(self, **kwargs: Any) -> None:
|
||||
self._log_reader.shutdown()
|
||||
await self._narrator_manager.close()
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
|
||||
async def _setup_terminal(self) -> None:
|
||||
async def _setup_terminal(self, **kwargs: Any) -> None:
|
||||
result = setup_terminal()
|
||||
|
||||
if result.success:
|
||||
|
|
@ -1612,7 +1744,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
telemetry_client=self.agent_loop.telemetry_client,
|
||||
)
|
||||
|
||||
async def _show_voice_settings(self) -> None:
|
||||
async def _show_voice_settings(self, **kwargs: Any) -> None:
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
return
|
||||
await self._switch_to_voice_app()
|
||||
|
|
@ -1720,6 +1852,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(QuestionApp).focus()
|
||||
case BottomApp.SessionPicker:
|
||||
self.query_one(SessionPickerApp).focus()
|
||||
case BottomApp.MCP:
|
||||
self.query_one(MCPApp).focus()
|
||||
case BottomApp.Rewind:
|
||||
self.query_one(RewindApp).focus()
|
||||
case BottomApp.Voice:
|
||||
|
|
@ -1794,7 +1928,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if isinstance(child, UserMessage) and child.message_index is not None
|
||||
]
|
||||
|
||||
def _start_rewind_mode(self) -> None:
|
||||
def _start_rewind_mode(self, **kwargs: Any) -> None:
|
||||
self.action_rewind_prev()
|
||||
|
||||
def action_rewind_prev(self) -> None:
|
||||
|
|
@ -1987,6 +2121,15 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
|
||||
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
|
||||
|
||||
def _handle_bottom_app_close_escape(
|
||||
self, widget_type: type[MCPApp] | type[ProxySetupApp]
|
||||
) -> None:
|
||||
try:
|
||||
self.query_one(widget_type).action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def action_interrupt(self) -> None: # noqa: PLR0911
|
||||
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
|
||||
self._voice_manager.cancel_recording()
|
||||
|
|
@ -2002,13 +2145,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_voice_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.MCP:
|
||||
self._handle_bottom_app_close_escape(MCPApp)
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
try:
|
||||
proxy_setup_app = self.query_one(ProxySetupApp)
|
||||
proxy_setup_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
self._handle_bottom_app_close_escape(ProxySetupApp)
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Approval:
|
||||
|
|
@ -2170,6 +2312,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self.call_after_refresh(schedule_switch)
|
||||
|
||||
async def action_toggle_debug_console(self) -> None:
|
||||
if self._debug_console is not None:
|
||||
await self._debug_console.remove()
|
||||
self._debug_console = None
|
||||
else:
|
||||
self._debug_console = DebugConsole(log_reader=self._log_reader)
|
||||
await self.mount(self._debug_console)
|
||||
|
||||
def action_clear_quit(self) -> None:
|
||||
input_widgets = self.query(ChatInputContainer)
|
||||
if input_widgets:
|
||||
|
|
@ -2185,6 +2335,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._agent_task.cancel()
|
||||
self._remote_manager.cancel_stream_task()
|
||||
|
||||
self._log_reader.shutdown()
|
||||
self._narrator_manager.cancel()
|
||||
self.exit(result=self._get_session_resume_info())
|
||||
|
||||
|
|
|
|||
|
|
@ -84,10 +84,17 @@ TextArea > .text-area--cursor {
|
|||
}
|
||||
|
||||
#completion-popup {
|
||||
width: 100%;
|
||||
padding: 1;
|
||||
padding-left: 3;
|
||||
overlay: screen;
|
||||
constrain: inside inside;
|
||||
display: none;
|
||||
width: auto;
|
||||
max-width: 80;
|
||||
height: auto;
|
||||
max-height: 12;
|
||||
padding: 0 1;
|
||||
color: ansi_default;
|
||||
background: $surface;
|
||||
border: solid ansi_bright_black;
|
||||
}
|
||||
|
||||
#input-box {
|
||||
|
|
@ -679,7 +686,7 @@ StatusMessage {
|
|||
|
||||
}
|
||||
|
||||
#config-app, #voice-app {
|
||||
#config-app, #voice-app, #mcp-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
|
|
@ -688,7 +695,7 @@ StatusMessage {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
#config-content, #voice-content {
|
||||
#config-content, #voice-content, #mcp-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
|
@ -699,13 +706,13 @@ StatusMessage {
|
|||
color: ansi_blue;
|
||||
}
|
||||
|
||||
#config-options {
|
||||
#config-options, #mcp-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#config-options:focus {
|
||||
#config-options:focus, #mcp-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
|
@ -1089,6 +1096,35 @@ NarratorStatus {
|
|||
margin-top: 1;
|
||||
}
|
||||
|
||||
/* Debug Console */
|
||||
|
||||
#debug-console {
|
||||
dock: right;
|
||||
width: 40%;
|
||||
max-width: 80;
|
||||
min-width: 40;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border-left: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#debug-console-header {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
padding: 0 0 1 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#debug-console-log {
|
||||
width: 100%;
|
||||
height: 1fr;
|
||||
background: transparent;
|
||||
overflow-x: hidden;
|
||||
scrollbar-size: 1 1;
|
||||
}
|
||||
|
||||
#modelpicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
|
|
|||
|
|
@ -33,15 +33,12 @@ class CompletionPopup(Static):
|
|||
text.append(description, style=description_style)
|
||||
|
||||
self.update(text)
|
||||
self.show()
|
||||
self.styles.display = "block"
|
||||
|
||||
def hide(self) -> None:
|
||||
self.update("")
|
||||
self.styles.display = "none"
|
||||
|
||||
def show(self) -> None:
|
||||
self.styles.display = "block"
|
||||
|
||||
def _display_label(self, label: str) -> str:
|
||||
if label.startswith("@"):
|
||||
return label[1:]
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ class ChatInputContainer(Vertical):
|
|||
self,
|
||||
),
|
||||
])
|
||||
self._completion_popup: CompletionPopup | None = None
|
||||
self._body: ChatInputBody | None = None
|
||||
|
||||
def _get_slash_entries(self) -> list[tuple[str, str]]:
|
||||
|
|
@ -86,8 +85,7 @@ class ChatInputContainer(Vertical):
|
|||
return sorted(entries)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
self._completion_popup = CompletionPopup()
|
||||
yield self._completion_popup
|
||||
yield CompletionPopup()
|
||||
|
||||
border_class = self._get_border_class()
|
||||
with Vertical(id=self.ID_INPUT_BOX, classes=border_class) as input_box:
|
||||
|
|
@ -138,12 +136,32 @@ class ChatInputContainer(Vertical):
|
|||
def render_completion_suggestions(
|
||||
self, suggestions: list[tuple[str, str]], selected_index: int
|
||||
) -> None:
|
||||
if self._completion_popup:
|
||||
self._completion_popup.update_suggestions(suggestions, selected_index)
|
||||
try:
|
||||
popup = self.query_one(CompletionPopup)
|
||||
except Exception:
|
||||
return
|
||||
popup.update_suggestions(suggestions, selected_index)
|
||||
self._position_popup(popup, len(suggestions))
|
||||
|
||||
def clear_completion_suggestions(self) -> None:
|
||||
if self._completion_popup:
|
||||
self._completion_popup.hide()
|
||||
try:
|
||||
popup = self.query_one(CompletionPopup)
|
||||
except Exception:
|
||||
return
|
||||
popup.hide()
|
||||
|
||||
def _position_popup(self, popup: CompletionPopup, line_count: int) -> None:
|
||||
widget = self.input_widget
|
||||
if not widget:
|
||||
return
|
||||
cursor = widget.cursor_screen_offset
|
||||
my_region = self.region
|
||||
# Place popup bottom edge just above the cursor row
|
||||
popup_height = line_count + 2 # +2 for solid border
|
||||
popup.styles.offset = (
|
||||
cursor.x - my_region.x,
|
||||
cursor.y - popup_height - my_region.y,
|
||||
)
|
||||
|
||||
def _format_insertion(self, replacement: str, suffix: str) -> str:
|
||||
"""Format the insertion text with appropriate spacing.
|
||||
|
|
|
|||
244
vibe/cli/textual_ui/widgets/debug_console.py
Normal file
244
vibe/cli/textual_ui/widgets/debug_console.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
from collections.abc import Callable
|
||||
|
||||
from rich.markup import escape
|
||||
from rich.text import Text
|
||||
from textual import events
|
||||
from textual.app import ComposeResult
|
||||
from textual.cache import LRUCache
|
||||
from textual.containers import Vertical
|
||||
from textual.geometry import Size
|
||||
from textual.scroll_view import ScrollView
|
||||
from textual.strip import Strip
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.core.log_reader import LogEntry, LogReader
|
||||
from vibe.core.logger import decode_log_message
|
||||
|
||||
LOG_LEVEL_COLORS: dict[str, str] = {
|
||||
"DEBUG": "dim",
|
||||
"INFO": "cyan",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "bold red",
|
||||
}
|
||||
|
||||
DEFAULT_LOG_PAGE_SIZE = 30
|
||||
|
||||
|
||||
class _LogView(ScrollView, can_focus=True):
|
||||
def __init__(
|
||||
self,
|
||||
load_page: Callable[[], None],
|
||||
has_more: Callable[[], bool],
|
||||
*,
|
||||
id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(id=id)
|
||||
self._lines: list[str] = []
|
||||
self._wrap_counts: list[int] = []
|
||||
self._wrap_prefix: list[int] = [0]
|
||||
self._total_visual: int = 0
|
||||
self._cached_width: int = 0
|
||||
self._render_line_cache: LRUCache[int, Strip] = LRUCache(1024)
|
||||
self._load_page = load_page
|
||||
self._has_more = has_more
|
||||
|
||||
def _wrap_markup(self, markup: str) -> int:
|
||||
"""Return the number of visual lines this markup produces at current width."""
|
||||
width = self._cached_width
|
||||
if width <= 0:
|
||||
return 1
|
||||
|
||||
text = Text.from_markup(markup, style=self.rich_style)
|
||||
|
||||
return len(text.wrap(self.app.console, width))
|
||||
|
||||
def _recompute_prefix(self) -> None:
|
||||
self._wrap_prefix = [0]
|
||||
for count in self._wrap_counts:
|
||||
self._wrap_prefix.append(self._wrap_prefix[-1] + count)
|
||||
self._total_visual = self._wrap_prefix[-1]
|
||||
|
||||
def _reflow(self) -> None:
|
||||
"""Re-wrap all lines at current widget width."""
|
||||
width = self.size.width
|
||||
if width <= 0:
|
||||
return
|
||||
self._cached_width = width
|
||||
self._render_line_cache.clear()
|
||||
self._wrap_counts = [self._wrap_markup(m) for m in self._lines]
|
||||
self._recompute_prefix()
|
||||
self.virtual_size = Size(width, self._total_visual)
|
||||
|
||||
def write_line(self, markup: str, scroll_end: bool | None = None) -> None:
|
||||
at_bottom = self.is_vertical_scroll_end
|
||||
width = self._cached_width or self.size.width
|
||||
self._cached_width = width
|
||||
|
||||
self._lines.append(markup)
|
||||
count = self._wrap_markup(markup)
|
||||
self._wrap_counts.append(count)
|
||||
self._wrap_prefix.append(self._wrap_prefix[-1] + count)
|
||||
self._total_visual += count
|
||||
self.virtual_size = Size(width, self._total_visual)
|
||||
|
||||
if scroll_end or (scroll_end is None and at_bottom):
|
||||
self.scroll_end(animate=False, immediate=True, x_axis=False)
|
||||
|
||||
def prepend_lines(self, markups: list[str]) -> None:
|
||||
if not markups:
|
||||
return
|
||||
width = self._cached_width or self.size.width
|
||||
self._cached_width = width
|
||||
|
||||
new_counts = [self._wrap_markup(m) for m in markups]
|
||||
new_visual = sum(new_counts)
|
||||
|
||||
self._lines[0:0] = markups
|
||||
self._wrap_counts[0:0] = new_counts
|
||||
self._recompute_prefix()
|
||||
self._render_line_cache.clear()
|
||||
self.virtual_size = Size(width, self._total_visual)
|
||||
self.scroll_to(y=self.scroll_y + new_visual, animate=False, immediate=True)
|
||||
|
||||
def render_line(self, y: int) -> Strip:
|
||||
_, scroll_y = self.scroll_offset
|
||||
abs_y = scroll_y + y
|
||||
width = self.size.width
|
||||
wrap_width = self._cached_width or width
|
||||
rich_style = self.rich_style
|
||||
|
||||
if abs_y >= self._total_visual:
|
||||
return Strip.blank(width, rich_style)
|
||||
if abs_y in self._render_line_cache:
|
||||
return self._render_line_cache[abs_y]
|
||||
|
||||
logical_idx = bisect.bisect_right(self._wrap_prefix, abs_y) - 1
|
||||
text = Text.from_markup(self._lines[logical_idx], style=rich_style)
|
||||
wrapped = text.wrap(self.app.console, wrap_width)
|
||||
|
||||
base = self._wrap_prefix[logical_idx]
|
||||
for i, line_text in enumerate(wrapped):
|
||||
strip = Strip(line_text.render(self.app.console), line_text.cell_len)
|
||||
strip = strip.crop_extend(0, width, rich_style)
|
||||
self._render_line_cache[base + i] = strip
|
||||
|
||||
try:
|
||||
return self._render_line_cache[abs_y]
|
||||
except KeyError:
|
||||
return Strip.blank(width, rich_style)
|
||||
|
||||
def notify_style_update(self) -> None:
|
||||
super().notify_style_update()
|
||||
self._render_line_cache.clear()
|
||||
|
||||
def on_resize(self, event: events.Resize) -> None:
|
||||
if event.size.width != self._cached_width:
|
||||
self._reflow()
|
||||
|
||||
def on_click(self, event: events.Click) -> None:
|
||||
_, scroll_y = self.scroll_offset
|
||||
visual_y = scroll_y + event.y
|
||||
logical_idx = bisect.bisect_right(self._wrap_prefix, visual_y) - 1
|
||||
if 0 <= logical_idx < len(self._lines):
|
||||
plain = Text.from_markup(self._lines[logical_idx]).plain
|
||||
self.app.copy_to_clipboard(plain)
|
||||
self.app.notify("Copied to clipboard", timeout=2.0)
|
||||
|
||||
def _try_load_previous(self) -> None:
|
||||
if not self._has_more() or self.scroll_y > 0:
|
||||
return
|
||||
self._load_page()
|
||||
|
||||
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
||||
super()._on_mouse_scroll_up(event)
|
||||
self._try_load_previous()
|
||||
|
||||
def action_scroll_up(self) -> None:
|
||||
super().action_scroll_up()
|
||||
self._try_load_previous()
|
||||
|
||||
def action_page_up(self) -> None:
|
||||
super().action_page_up()
|
||||
self._try_load_previous()
|
||||
|
||||
def action_scroll_home(self) -> None:
|
||||
super().action_scroll_home()
|
||||
self._try_load_previous()
|
||||
|
||||
|
||||
class DebugConsole(Vertical):
|
||||
def __init__(
|
||||
self, log_reader: LogReader, page_size: int = DEFAULT_LOG_PAGE_SIZE
|
||||
) -> None:
|
||||
super().__init__(id="debug-console")
|
||||
self._log_reader = log_reader
|
||||
self._log_view: _LogView | None = None
|
||||
self._cursor: int | None = None
|
||||
self._has_more: bool = True
|
||||
self._page_size = page_size
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(
|
||||
"Debug Console [dim](ctrl+\\ to close)[/dim]", id="debug-console-header"
|
||||
)
|
||||
self._log_view = _LogView(
|
||||
load_page=self._load_page,
|
||||
has_more=lambda: self._has_more and self._cursor is not None,
|
||||
id="debug-console-log",
|
||||
)
|
||||
yield self._log_view
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._fill_viewport()
|
||||
self._log_reader.set_consumer(self._on_log_entry)
|
||||
self._log_reader.start_watching()
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._log_reader.set_consumer(None)
|
||||
self._log_reader.stop_watching()
|
||||
|
||||
def _load_page(self) -> None:
|
||||
if self._log_view is None:
|
||||
return
|
||||
result = self._log_reader.get_logs(
|
||||
limit=self._page_size, offset=self._cursor or 0
|
||||
)
|
||||
self._cursor = result.cursor
|
||||
self._has_more = result.has_more
|
||||
markups = [self._format_entry(e) for e in reversed(result.entries)]
|
||||
self._log_view.prepend_lines(markups)
|
||||
|
||||
def _fill_viewport(self) -> None:
|
||||
"""Load enough logs to fill the viewport, then scroll to the bottom."""
|
||||
if self._log_view is None or not self._has_more:
|
||||
return
|
||||
self.call_after_refresh(self._check_and_fill)
|
||||
|
||||
def _check_and_fill(self) -> None:
|
||||
if self._log_view is None or not self._has_more:
|
||||
return
|
||||
if self._log_view.virtual_size.height <= self._log_view.size.height:
|
||||
self._load_page()
|
||||
self._fill_viewport()
|
||||
else:
|
||||
self._log_view.scroll_end(animate=False)
|
||||
|
||||
def _on_log_entry(self, entry: LogEntry) -> None:
|
||||
self.app.call_from_thread(self._append_log_entry, entry)
|
||||
|
||||
def _append_log_entry(self, entry: LogEntry) -> None:
|
||||
if self._log_view is None:
|
||||
return
|
||||
self._log_view.write_line(self._format_entry(entry))
|
||||
|
||||
@staticmethod
|
||||
def _format_entry(entry: LogEntry) -> str:
|
||||
color = LOG_LEVEL_COLORS.get(entry.level, "dim")
|
||||
ts = entry.timestamp.astimezone().strftime("%Y-%m-%d %H:%M:%S")
|
||||
message = decode_log_message(entry.message)
|
||||
safe_message = escape(message)
|
||||
return f"[dim]{ts}[/dim] [{color}]{entry.level:<8}[/{color}] {safe_message}"
|
||||
157
vibe/cli/textual_ui/widgets/mcp_app.py
Normal file
157
vibe/cli/textual_ui/widgets/mcp_app.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.events import DescendantBlur
|
||||
from textual.message import Message
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.core.tools.mcp.tools import MCPTool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import MCPServer
|
||||
from vibe.core.tools.manager import ToolManager
|
||||
|
||||
|
||||
class MCPToolIndex(NamedTuple):
|
||||
server_tools: dict[str, list[tuple[str, type[MCPTool]]]]
|
||||
enabled_tools: dict[str, type[Any]]
|
||||
|
||||
|
||||
def collect_mcp_tool_index(
|
||||
mcp_servers: Sequence[MCPServer], tool_manager: ToolManager
|
||||
) -> MCPToolIndex:
|
||||
registered = tool_manager.registered_tools
|
||||
available = tool_manager.available_tools
|
||||
configured_servers = {server.name for server in mcp_servers}
|
||||
server_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
|
||||
|
||||
for tool_name, cls in registered.items():
|
||||
if not issubclass(cls, MCPTool):
|
||||
continue
|
||||
server_name = cls.get_server_name()
|
||||
if server_name is None or server_name not in configured_servers:
|
||||
continue
|
||||
server_tools.setdefault(server_name, []).append((tool_name, cls))
|
||||
|
||||
return MCPToolIndex(server_tools, enabled_tools=available)
|
||||
|
||||
|
||||
class MCPApp(Container):
|
||||
can_focus_children = True
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "close", "Close", show=False),
|
||||
Binding("backspace", "back", "Back", show=False),
|
||||
]
|
||||
|
||||
class MCPClosed(Message):
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mcp_servers: Sequence[MCPServer],
|
||||
tool_manager: ToolManager,
|
||||
initial_server: str = "",
|
||||
) -> None:
|
||||
super().__init__(id="mcp-app")
|
||||
self._mcp_servers = mcp_servers
|
||||
self._tool_manager = tool_manager
|
||||
self._index = collect_mcp_tool_index(mcp_servers, tool_manager)
|
||||
self._viewing_server: str | None = initial_server.strip() or None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="mcp-content"):
|
||||
yield NoMarkupStatic("", id="mcp-title", classes="settings-title")
|
||||
yield NoMarkupStatic("")
|
||||
yield OptionList(id="mcp-options")
|
||||
yield NoMarkupStatic("")
|
||||
yield NoMarkupStatic("", id="mcp-help", classes="settings-help")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._refresh_view(self._viewing_server)
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def on_descendant_blur(self, _event: DescendantBlur) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
option_id = event.option.id or ""
|
||||
if option_id.startswith("server:"):
|
||||
self._refresh_view(option_id.removeprefix("server:"))
|
||||
|
||||
def action_back(self) -> None:
|
||||
if self._viewing_server is not None:
|
||||
self._refresh_view(None)
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.MCPClosed())
|
||||
|
||||
def _refresh_view(self, server_name: str | None) -> None:
|
||||
index = self._index
|
||||
option_list = self.query_one(OptionList)
|
||||
option_list.clear_options()
|
||||
|
||||
server_names = {s.name for s in self._mcp_servers}
|
||||
if server_name is None or server_name not in server_names:
|
||||
self._viewing_server = None
|
||||
self.query_one("#mcp-title", NoMarkupStatic).update("MCP Servers")
|
||||
self.query_one("#mcp-help", NoMarkupStatic).update(
|
||||
"↑↓ Navigate Enter Show tools Esc Close"
|
||||
)
|
||||
for srv in self._mcp_servers:
|
||||
tools = index.server_tools.get(srv.name, [])
|
||||
enabled = sum(1 for t, _ in tools if t in index.enabled_tools)
|
||||
status = _server_status(enabled)
|
||||
label = Text(no_wrap=True)
|
||||
label.append(srv.name)
|
||||
label.append(f" [{srv.transport}] {status}")
|
||||
option_list.add_option(Option(label, id=f"server:{srv.name}"))
|
||||
if self._mcp_servers:
|
||||
option_list.highlighted = 0
|
||||
return
|
||||
|
||||
self._viewing_server = server_name
|
||||
self.query_one("#mcp-title", NoMarkupStatic).update(
|
||||
f"MCP Server: {server_name}"
|
||||
)
|
||||
self.query_one("#mcp-help", NoMarkupStatic).update(
|
||||
"↑↓ Navigate Backspace Back Esc Close"
|
||||
)
|
||||
enabled_tools = [
|
||||
(tool_name, cls)
|
||||
for tool_name, cls in sorted(
|
||||
index.server_tools.get(server_name, []), key=lambda t: t[0]
|
||||
)
|
||||
if tool_name in index.enabled_tools
|
||||
]
|
||||
if not enabled_tools:
|
||||
option_list.add_option(
|
||||
Option("No enabled tools for this server", disabled=True)
|
||||
)
|
||||
return
|
||||
for tool_name, cls in enabled_tools:
|
||||
remote_name = cls.get_remote_name()
|
||||
raw_desc = (
|
||||
(cls.description or "").removeprefix(f"[{server_name}] ").split("\n")[0]
|
||||
)
|
||||
label = Text(no_wrap=True)
|
||||
label.append(remote_name, style="bold")
|
||||
if raw_desc:
|
||||
label.append(f" - {raw_desc}")
|
||||
option_list.add_option(Option(label, id=f"tool:{tool_name}"))
|
||||
if enabled_tools:
|
||||
option_list.highlighted = 0
|
||||
|
||||
|
||||
def _server_status(enabled: int) -> str:
|
||||
if enabled == 0:
|
||||
return "unavailable"
|
||||
noun = "tool" if enabled == 1 else "tools"
|
||||
return f"{enabled} {noun} enabled"
|
||||
Loading…
Add table
Add a link
Reference in a new issue