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:
Mathias Gesbert 2026-04-09 18:40:46 +02:00 committed by GitHub
parent 90763daf81
commit e9a9217cc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
113 changed files with 7202 additions and 541 deletions

View file

@ -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,

View file

@ -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",

View file

@ -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
View 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"

View file

@ -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())

View file

@ -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;

View file

@ -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:]

View file

@ -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.

View 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}"

View 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"