Co-authored-by: Antoine <33425718+anth2o@users.noreply.github.com>
Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Robin Gullo <robin.gullo@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-04-28 17:44:07 +02:00 committed by GitHub
parent a83c81ecf5
commit 632ea8c032
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
253 changed files with 13965 additions and 2525 deletions

View file

@ -5,8 +5,6 @@ from textual import events
from vibe.cli.autocompletion.base import CompletionResult, CompletionView
from vibe.core.autocompletion.completers import CommandCompleter
MAX_SUGGESTIONS_COUNT = 10
class SlashCommandController:
def __init__(self, completer: CommandCompleter, view: CompletionView) -> None:
@ -34,9 +32,7 @@ class SlashCommandController:
return
suggestions = self._completer.get_completion_items(text, cursor_index)
if len(suggestions) > MAX_SUGGESTIONS_COUNT:
suggestions = suggestions[:MAX_SUGGESTIONS_COUNT]
if suggestions:
if suggestions and all(alias != text for alias, _ in suggestions):
self._suggestions = suggestions
self._selected_index = 0
self._view.render_completion_suggestions(

32
vibe/cli/cache.py Normal file
View file

@ -0,0 +1,32 @@
from __future__ import annotations
import logging
from pathlib import Path
import tomllib
from typing import Any
import tomli_w
logger = logging.getLogger(__name__)
def read_cache(cache_path: Path) -> dict[str, Any]:
"""Read the cache.toml file, returning an empty dict on any error."""
try:
with cache_path.open("rb") as f:
return tomllib.load(f)
except (OSError, tomllib.TOMLDecodeError):
return {}
def write_cache(cache_path: Path, section: str, data: dict[str, Any]) -> None:
"""Write the full cache dict to cache.toml, merging with existing data."""
existing = read_cache(cache_path)
existing.setdefault(section, {})
existing[section].update(data)
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
with cache_path.open("wb") as f:
tomli_w.dump(existing, f)
except OSError:
logger.debug("Failed to write cache file %s", cache_path, exc_info=True)

View file

@ -5,6 +5,7 @@ from pathlib import Path
import sys
from rich import print as rprint
from rich.console import Console
import tomli_w
from vibe import __version__
@ -18,16 +19,29 @@ from vibe.core.config import (
load_dotenv_values,
)
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.hooks.config import load_hooks_from_fs
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.session.session_loader import SessionLoader
from vibe.core.telemetry.build_metadata import build_entrypoint_metadata
from vibe.core.telemetry.types import EntrypointMetadata
from vibe.core.tracing import setup_tracing
from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager
from vibe.core.types import LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
return build_entrypoint_metadata(
agent_entrypoint="cli",
agent_version=__version__,
client_name="vibe_cli",
client_version=__version__,
)
def get_initial_agent_name(args: argparse.Namespace) -> str:
if args.prompt is not None and args.agent == BuiltinAgentName.DEFAULT:
return BuiltinAgentName.AUTO_APPROVE
@ -49,11 +63,18 @@ def get_prompt_from_stdin() -> str | None:
return None
def load_config_or_exit() -> VibeConfig:
def load_config_or_exit(*, interactive: bool) -> VibeConfig:
try:
return VibeConfig.load()
except MissingAPIKeyError:
run_onboarding()
except MissingAPIKeyError as e:
if not interactive:
print(
f"Error: {e}. Set the environment variable (e.g. in ~/.vibe/.env "
"or your shell), or run `vibe --setup` once interactively.",
file=sys.stderr,
)
sys.exit(1)
run_onboarding(entrypoint_metadata=_build_cli_entrypoint_metadata())
return VibeConfig.load()
except MissingPromptFileError as e:
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
@ -63,6 +84,26 @@ def load_config_or_exit() -> VibeConfig:
sys.exit(1)
def warn_if_workdir_trust_is_unset() -> None:
try:
cwd = Path.cwd()
except FileNotFoundError:
return
if cwd.resolve() == Path.home().resolve():
return
if trusted_folders_manager.is_trusted(cwd) is not None:
return
detected = find_trustable_files(cwd)
if not detected:
return
files_str = ", ".join(detected)
Console(stderr=True).print(
f"[yellow]Warning:[/] {cwd} is not trusted; "
f"project configuration ({files_str}) will be ignored. "
"Re-run with --trust to trust this folder temporarily."
)
def bootstrap_config_files() -> None:
mgr = get_harness_files_manager()
config_file = mgr.user_config_file
@ -98,11 +139,14 @@ def load_session(
session_to_load = None
if args.continue_session:
session_to_load = SessionLoader.find_latest_session(config.session_logging)
cwd = Path.cwd().resolve()
session_to_load = SessionLoader.find_latest_session(
config.session_logging, working_directory=cwd
)
if not session_to_load:
rprint(
f"[red]No previous sessions found in "
f"{config.session_logging.save_dir}[/]"
f"{config.session_logging.save_dir} for {cwd=}[/]"
)
sys.exit(1)
elif args.resume is True:
@ -135,6 +179,7 @@ def _resume_previous_session(
_, metadata = SessionLoader.load_session(session_path)
session_id = metadata.get("session_id", agent_loop.session_id)
agent_loop.session_id = session_id
agent_loop.parent_session_id = metadata.get("parent_session_id")
agent_loop.session_logger.resume_existing_session(session_id, session_path)
logger.info(
@ -147,12 +192,14 @@ def run_cli(args: argparse.Namespace) -> None:
bootstrap_config_files()
if args.setup:
run_onboarding()
run_onboarding(entrypoint_metadata=_build_cli_entrypoint_metadata())
sys.exit(0)
try:
initial_agent_name = get_initial_agent_name(args)
config = load_config_or_exit()
is_interactive = args.prompt is None
config = load_config_or_exit(interactive=is_interactive)
hook_config_result = load_hooks_from_fs(config)
setup_tracing(config)
if args.enabled_tools:
@ -162,6 +209,7 @@ def run_cli(args: argparse.Namespace) -> None:
stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None:
warn_if_workdir_trust_is_unset()
config.disabled_tools = [*config.disabled_tools, "ask_user_question"]
programmatic_prompt = args.prompt or stdin_prompt
if not programmatic_prompt:
@ -182,7 +230,9 @@ def run_cli(args: argparse.Namespace) -> None:
output_format=output_format,
previous_messages=loaded_session[0] if loaded_session else None,
agent_name=initial_agent_name,
teleport=args.teleport and config.nuage_enabled,
teleport=args.teleport and config.vibe_code_enabled,
headless=True,
hook_config_result=hook_config_result,
)
if final_response:
print(final_response)
@ -201,13 +251,9 @@ def run_cli(args: argparse.Namespace) -> None:
config,
agent_name=initial_agent_name,
enable_streaming=True,
entrypoint_metadata=EntrypointMetadata(
agent_entrypoint="cli",
agent_version=__version__,
client_name="vibe_cli",
client_version=__version__,
),
entrypoint_metadata=_build_cli_entrypoint_metadata(),
defer_heavy_init=True,
hook_config_result=hook_config_result,
)
if loaded_session:

View file

@ -30,17 +30,24 @@ def _has_cmd(cmd: str) -> bool:
def _copy_pbcopy(text: str) -> None:
subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
subprocess.run(
["pbcopy"], input=text.encode("utf-8"), check=True, stderr=subprocess.DEVNULL
)
def _copy_xclip(text: str) -> None:
subprocess.run(
["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True
["xclip", "-selection", "clipboard"],
input=text.encode("utf-8"),
check=True,
stderr=subprocess.DEVNULL,
)
def _copy_wl_copy(text: str) -> None:
subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True)
subprocess.run(
["wl-copy"], input=text.encode("utf-8"), check=True, stderr=subprocess.DEVNULL
)
_CMD_STRATEGIES: list[tuple[str, Callable[[str], None]]] = [
@ -137,24 +144,36 @@ def _get_selected_texts(app: App) -> list[str]:
return selected_texts
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
selected_texts = _get_selected_texts(app)
if not selected_texts:
def copy_text_to_clipboard(
app: App,
text: str,
*,
show_toast: bool = True,
success_message: str = "Copied to clipboard",
) -> str | None:
if not text:
return None
combined_text = "\n".join(selected_texts)
try:
_copy_to_clipboard(combined_text)
_copy_to_clipboard(text)
if show_toast:
app.notify(
"Selection copied to clipboard",
severity="information",
timeout=2,
markup=False,
)
return combined_text
app.notify(success_message, severity="information", timeout=2, markup=False)
return text
except Exception:
app.notify(
"Failed to copy - clipboard not available", severity="warning", timeout=3
)
return None
def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None:
selected_texts = _get_selected_texts(app)
if not selected_texts:
return None
return copy_text_to_clipboard(
app,
"\n".join(selected_texts),
show_toast=show_toast,
success_message="Selection copied to clipboard",
)

View file

@ -1,24 +1,56 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import sys
from vibe.cli.plan_offer.decide_plan_offer import PlanInfo
ALT_KEY = "" if sys.platform == "darwin" else "Alt"
@dataclass(frozen=True)
class CommandAvailabilityContext:
vibe_code_enabled: bool = False
is_active_model_mistral: bool = False
plan_info: PlanInfo | None = None
def is_teleport_available(self) -> bool:
return (
self.vibe_code_enabled
and self.is_active_model_mistral
and self.plan_info is not None
and self.plan_info.is_teleport_eligible()
)
CommandAvailability = Callable[[CommandAvailabilityContext], bool]
@dataclass
class Command:
aliases: frozenset[str]
description: str
handler: str
exits: bool = False
is_available: CommandAvailability | None = None
class CommandRegistry:
def __init__(self, excluded_commands: list[str] | None = None) -> None:
def __init__(
self,
excluded_commands: list[str] | None = None,
availability_context: CommandAvailabilityContext | None = None,
) -> None:
if excluded_commands is None:
excluded_commands = []
self.commands = {
self._disabled_commands = set(excluded_commands)
self._availability_context = CommandAvailabilityContext()
self._commands: dict[str, Command] = {}
self.refresh(availability_context)
def _build_commands(self) -> dict[str, Command]:
return {
"help": Command(
aliases=frozenset(["/help"]),
description="Show help message",
@ -34,6 +66,11 @@ class CommandRegistry:
description="Select active model",
handler="_show_model",
),
"thinking": Command(
aliases=frozenset(["/thinking"]),
description="Select thinking level",
handler="_show_thinking",
),
"reload": Command(
aliases=frozenset(["/reload"]),
description="Reload configuration, agent instructions, and skills from disk",
@ -44,6 +81,11 @@ class CommandRegistry:
description="Clear conversation history",
handler="_clear_history",
),
"copy": Command(
aliases=frozenset(["/copy"]),
description="Copy the last agent message to the clipboard",
handler="_copy_last_agent_message",
),
"log": Command(
aliases=frozenset(["/log"]),
description="Show path to current interaction log file",
@ -56,7 +98,7 @@ class CommandRegistry:
),
"compact": Command(
aliases=frozenset(["/compact"]),
description="Compact conversation history by summarizing",
description="Compact conversation history by summarizing. Optionally pass instructions to guide the summary",
handler="_compact_history",
),
"exit": Command(
@ -72,8 +114,9 @@ class CommandRegistry:
),
"teleport": Command(
aliases=frozenset(["/teleport"]),
description="Teleport session to Vibe Nuage",
description="Teleport session to Vibe Code",
handler="_teleport_command",
is_available=CommandAvailabilityContext.is_teleport_available,
),
"proxy-setup": Command(
aliases=frozenset(["/proxy-setup"]),
@ -120,16 +163,43 @@ class CommandRegistry:
),
}
for command in excluded_commands:
self.commands.pop(command, None)
@property
def commands(self) -> dict[str, Command]:
return self._commands
self._alias_map = {}
for cmd_name, cmd in self.commands.items():
for alias in cmd.aliases:
self._alias_map[alias] = cmd_name
def refresh(
self, availability_context: CommandAvailabilityContext | None = None
) -> None:
self._availability_context = (
availability_context or CommandAvailabilityContext()
)
self._commands = {
name: command
for name, command in self._build_commands().items()
if name not in self._disabled_commands
and self._is_command_available(command)
}
def _is_command_available(self, command: Command) -> bool:
if command.is_available is None:
return True
return command.is_available(self._availability_context)
def _alias_map(self) -> dict[str, str]:
return {
alias: cmd_name
for cmd_name, cmd in self.commands.items()
for alias in cmd.aliases
}
def get(self, name: str) -> Command | None:
return self.commands.get(name)
def has_command(self, name: str) -> bool:
return name in self.commands
def get_command_name(self, user_input: str) -> str | None:
return self._alias_map.get(user_input.lower().strip())
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)

View file

@ -83,6 +83,13 @@ def parse_arguments() -> argparse.Namespace:
metavar="DIR",
help="Change to this directory before running",
)
parser.add_argument(
"--trust",
action="store_true",
help="Trust the working directory for this invocation only (not "
"persisted to trusted_folders.toml). Skips the trust prompt. "
"Use this for non-interactive automation.",
)
# Feature flag for teleport, not exposed to the user yet
parser.add_argument("--teleport", action="store_true", help=argparse.SUPPRESS)
@ -106,18 +113,7 @@ def parse_arguments() -> argparse.Namespace:
return parser.parse_args()
def check_and_resolve_trusted_folder() -> None:
try:
cwd = Path.cwd()
except FileNotFoundError:
rprint(
"[red]Error: Current working directory no longer exists.[/]\n"
"[yellow]The directory you started vibe from has been deleted. "
"Please change to an existing directory and try again, "
"or use --workdir to specify a working directory.[/]"
)
sys.exit(1)
def check_and_resolve_trusted_folder(cwd: Path) -> None:
if cwd.resolve() == Path.home().resolve():
return
@ -157,9 +153,23 @@ def main() -> None:
sys.exit(1)
os.chdir(workdir)
try:
cwd = Path.cwd()
except FileNotFoundError:
rprint(
"[red]Error: Current working directory no longer exists.[/]\n"
"[yellow]The directory you started vibe from has been deleted. "
"Please change to an existing directory and try again, "
"or use --workdir to specify a working directory.[/]"
)
sys.exit(1)
if args.trust:
trusted_folders_manager.trust_for_session(cwd)
is_interactive = args.prompt is None
if is_interactive:
check_and_resolve_trusted_folder()
check_and_resolve_trusted_folder(cwd)
init_harness_files_manager("user", "project")
from vibe.cli.cli import run_cli

View file

@ -41,7 +41,9 @@ class NarratorManager:
self._audio_player = audio_player
self._telemetry_client = telemetry_client
config = config_getter()
self._turn_summary: TurnSummaryPort = self._make_turn_summary(config)
self._turn_summary: TurnSummaryPort = self._make_turn_summary(
config, telemetry_client
)
self._turn_summary.on_summary = self._on_turn_summary
self._tts_client: TTSClientPort | None = self._make_tts_client(config)
self._state = NarratorState.IDLE
@ -124,18 +126,28 @@ class NarratorManager:
def sync(self) -> None:
self.cancel()
config = self._config_getter()
self.turn_summary = self._make_turn_summary(config)
self.turn_summary = self._make_turn_summary(config, self._telemetry_client)
self.tts_client = self._make_tts_client(config)
@staticmethod
def _make_turn_summary(config: VibeConfig) -> NoopTurnSummary | TurnSummaryTracker:
def _make_turn_summary(
config: VibeConfig, telemetry_client: TelemetryClient | None = None
) -> NoopTurnSummary | TurnSummaryTracker:
if not config.narrator_enabled:
return NoopTurnSummary()
result = create_narrator_backend(config)
if result is None:
return NoopTurnSummary()
backend, model = result
return TurnSummaryTracker(backend=backend, model=model)
return TurnSummaryTracker(
backend=backend,
model=model,
session_metadata_getter=(
None
if telemetry_client is None
else telemetry_client.build_client_event_metadata
),
)
@staticmethod
def _make_tts_client(config: VibeConfig) -> TTSClientPort | None:

View file

@ -58,6 +58,9 @@ class PlanInfo:
def is_chat_pro_plan(self) -> bool:
return self.plan_type == WhoAmIPlanType.CHAT
def is_teleport_eligible(self) -> bool:
return self.is_chat_pro_plan() and not self.prompt_switching_to_pro_plan
def is_free_mistral_code_plan(self) -> bool:
return (
self.plan_type == WhoAmIPlanType.MISTRAL_CODE

101
vibe/cli/stderr_guard.py Normal file
View file

@ -0,0 +1,101 @@
"""Guard against stray native writes to fd 2 corrupting the Textual TUI."""
from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
import os
import sys
def _cleanup(
render_file: object | None,
render_fd: int | None,
saved_dunder: object,
saved_stderr: object,
) -> None:
"""Restore sys.stderr / sys.__stderr__ and fd 2, ignoring errors."""
sys.__stderr__ = saved_dunder # type: ignore[assignment]
sys.stderr = saved_stderr # type: ignore[assignment]
if render_file is not None:
try:
render_file.close() # type: ignore[union-attr]
except Exception:
pass
if render_fd is not None:
try:
os.dup2(render_fd, 2)
except Exception:
pass
finally:
try:
os.close(render_fd)
except Exception:
pass
@contextmanager
def stderr_guard() -> Generator[None]:
"""Redirect OS-level fd 2 away from the terminal while keeping a
separate fd for Textual's rendering output.
Textual renders to ``sys.__stderr__`` (fd 2) and captures Python-level
``sys.stderr`` via ``contextlib.redirect_stderr``. Native C code (e.g.
macOS ``libsystem_malloc``) that calls ``write(2, )`` bypasses that
redirect and corrupts the TUI.
This guard:
1. Dups fd 2 to a new fd still pointing at the real terminal.
2. Redirects fd 2 to ``/dev/null`` (absorbs stray native writes).
3. Swaps ``sys.__stderr__`` / ``sys.stderr`` to a file object wrapping
the dup'd fd so Textual keeps rendering to the real terminal.
4. Restores everything on exit.
No-op on Windows or when fd 2 is not a tty.
"""
if sys.platform == "win32" or not _is_stderr_a_tty():
yield
return
render_fd: int | None = None
render_file = None
saved_dunder = sys.__stderr__
saved_stderr = sys.stderr
try:
render_fd = os.dup(2)
devnull_fd = os.open(os.devnull, os.O_WRONLY)
try:
os.dup2(devnull_fd, 2)
finally:
os.close(devnull_fd)
render_file = os.fdopen(
render_fd, "w", closefd=False, errors="backslashreplace"
)
sys.__stderr__ = render_file # type: ignore[assignment]
sys.stderr = render_file
except Exception:
_cleanup(render_file, render_fd, saved_dunder, saved_stderr)
yield
return
try:
yield
finally:
try:
render_file.flush()
except Exception:
pass
_cleanup(render_file, render_fd, saved_dunder, saved_stderr)
def _is_stderr_a_tty() -> bool:
try:
return os.isatty(2)
except OSError:
return False

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from contextlib import aclosing
from dataclasses import dataclass
from enum import StrEnum, auto
@ -25,8 +26,8 @@ from textual.widget import Widget
from textual.widgets import Static
from vibe import __version__ as CORE_VERSION
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.commands import CommandRegistry
from vibe.cli.clipboard import copy_selection_to_clipboard, copy_text_to_clipboard
from vibe.cli.commands import CommandAvailabilityContext, CommandRegistry
from vibe.cli.narrator_manager import (
NarratorManager,
NarratorManagerPort,
@ -47,6 +48,7 @@ from vibe.cli.textual_ui.notifications import (
NotificationPort,
TextualNotificationAdapter,
)
from vibe.cli.textual_ui.quit_manager import QuitManager
from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event
from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
@ -58,10 +60,16 @@ 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.feedback_bar_manager import FeedbackBarManager
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.loading import (
DEFAULT_LOADING_STATUS,
LoadingWidget,
paused_timer,
)
from vibe.cli.textual_ui.widgets.mcp_app import MCPApp, MCPSourceKind
from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
BashOutputMessage,
ErrorMessage,
InterruptMessage,
@ -80,6 +88,7 @@ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
from vibe.cli.textual_ui.widgets.rewind_app import RewindApp
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
from vibe.cli.textual_ui.widgets.thinking_picker import ThinkingPickerApp
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
from vibe.cli.textual_ui.widgets.voice_app import VoiceApp
from vibe.cli.textual_ui.windowing import (
@ -114,6 +123,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.hooks.models import HookStartEvent
from vibe.core.log_reader import LogReader
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
@ -144,14 +154,15 @@ from vibe.core.tools.builtins.ask_user_question import (
Choice,
Question,
)
from vibe.core.tools.connectors import connectors_enabled
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
from vibe.core.tools.mcp_settings import persist_mcp_toggle
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
from vibe.core.types import (
AgentStats,
ApprovalResponse,
Backend,
BaseEvent,
ContextTooLongError,
LLMMessage,
RateLimitError,
Role,
@ -164,6 +175,19 @@ from vibe.core.utils import (
)
def _compute_connectors_count(
config: VibeConfig, connector_registry: ConnectorRegistry | None
) -> int:
total = connector_registry.connector_count if connector_registry else 0
if total == 0:
return 0
disabled_names = {c.name for c in config.connectors if c.disabled}
known_names = set(
connector_registry.get_connector_names() if connector_registry else []
)
return total - len(disabled_names & known_names)
class BottomApp(StrEnum):
"""Bottom panel app types.
@ -179,6 +203,7 @@ class BottomApp(StrEnum):
ModelPicker = auto()
ProxySetup = auto()
Question = auto()
ThinkingPicker = auto()
Rewind = auto()
SessionPicker = auto()
Voice = auto()
@ -223,6 +248,7 @@ class ChatScroll(VerticalScroll):
PRUNE_LOW_MARK = 1000
PRUNE_HIGH_MARK = 1500
DOUBLE_ESC_DELAY = 0.2
async def prune_oldest_children(
@ -274,8 +300,8 @@ class VibeApp(App): # noqa: PLR0904
PAUSE_GC_ON_SCROLL: ClassVar[bool] = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "clear_quit", "Quit", show=False),
Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True),
Binding("ctrl+c", "interrupt_or_quit", "Quit", show=False),
Binding("ctrl+d", "delete_right_or_quit", "Quit", show=False, priority=True),
Binding("ctrl+z", "suspend_with_message", "Suspend", show=False, priority=True),
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
@ -309,6 +335,7 @@ class VibeApp(App): # noqa: PLR0904
super().__init__(**kwargs)
self.scroll_sensitivity_y = 1.0
self.agent_loop = agent_loop
self._plan_info: PlanInfo | None = None
self._voice_manager: VoiceManagerPort = (
voice_manager or self._make_default_voice_manager()
)
@ -329,11 +356,6 @@ class VibeApp(App): # noqa: PLR0904
self.event_handler: EventHandler | None = None
excluded_commands = []
if not self.config.nuage_enabled:
excluded_commands.append("teleport")
self.commands = CommandRegistry(excluded_commands=excluded_commands)
self._chat_input_container: ChatInputContainer | None = None
self._current_bottom_app: BottomApp = BottomApp.Input
@ -352,9 +374,12 @@ class VibeApp(App): # noqa: PLR0904
self._plan_offer_gateway = plan_offer_gateway
opts = startup or StartupOptions()
self._initial_prompt = opts.initial_prompt
self._teleport_on_start = opts.teleport_on_start and self.config.nuage_enabled
self._teleport_on_start = (
opts.teleport_on_start and self.agent_loop.base_config.vibe_code_enabled
)
self._show_resume_picker = opts.show_resume_picker
self._last_escape_time: float | None = None
self._quit_manager = QuitManager(self)
self._banner: Banner | None = None
self._whats_new_message: WhatsNewMessage | None = None
self._cached_messages_area: Widget | None = None
@ -363,7 +388,6 @@ class VibeApp(App): # noqa: PLR0904
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 = (
narrator_manager or self._make_default_narrator_manager()
)
@ -371,6 +395,7 @@ class VibeApp(App): # noqa: PLR0904
self._rewind_mode = False
self._rewind_highlighted_widget: UserMessage | None = None
self._fatal_init_error = False
self.commands = self._build_command_registry()
@property
def config(self) -> VibeConfig:
@ -380,13 +405,30 @@ class VibeApp(App): # noqa: PLR0904
def _connectors_enabled(self) -> bool:
return connectors_enabled() and self.agent_loop.connector_registry is not None
def _get_command_availability_context(self) -> CommandAvailabilityContext:
return CommandAvailabilityContext(
vibe_code_enabled=self.agent_loop.base_config.vibe_code_enabled,
is_active_model_mistral=self.config.is_active_model_mistral(),
plan_info=self._plan_info,
)
def _build_command_registry(self) -> CommandRegistry:
return CommandRegistry(
availability_context=self._get_command_availability_context()
)
def _refresh_command_registry(self) -> None:
self.commands.refresh(self._get_command_availability_context())
def compose(self) -> ComposeResult:
with ChatScroll(id="chat"):
self._banner = Banner(
config=self.config,
skill_manager=self.agent_loop.skill_manager,
mcp_registry=self.agent_loop.mcp_registry,
connector_registry=self.agent_loop.connector_registry,
connectors_count=_compute_connectors_count(
self.config, self.agent_loop.connector_registry
),
)
yield self._banner
yield VerticalGroup(id="messages")
@ -405,7 +447,6 @@ class VibeApp(App): # noqa: PLR0904
agent_name=self.agent_loop.agent_profile.display_name.lower(),
skill_entries_getter=self._get_skill_entries,
file_watcher_for_autocomplete_getter=self._is_file_watcher_enabled,
nuage_enabled=self.config.nuage_enabled,
voice_manager=self._voice_manager,
)
@ -422,6 +463,7 @@ class VibeApp(App): # noqa: PLR0904
self._cached_chat = self.query_one("#chat", ChatScroll)
self._cached_loading_area = self.query_one("#loading-area-content")
self._feedback_bar = self.query_one(FeedbackBar)
self._feedback_bar_manager = FeedbackBarManager()
self.event_handler = EventHandler(
mount_callback=self._mount_and_scroll,
@ -456,6 +498,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.emit_new_session_telemetry()
self.call_after_refresh(self._refresh_banner)
self._show_hook_config_issues_once()
self.run_worker(self._watch_init_completion(), exclusive=False)
@ -467,12 +510,21 @@ class VibeApp(App): # noqa: PLR0904
gc.collect()
gc.freeze()
def _show_hook_config_issues_once(self) -> None:
for issue in self.agent_loop.hook_config_issues:
self.notify(
f"{issue.file}\n{issue.message}",
severity="warning",
markup=False,
timeout=10,
)
async def _watch_init_completion(self) -> None:
"""Show 'Initializing' loading indicator until background init finishes."""
init_widget = None
try:
if not self.agent_loop.is_initialized:
await self._ensure_loading_widget("Initializing")
await self._ensure_loading_widget("Initializing", show_hint=False)
init_widget = self._loading_widget
await self.agent_loop.wait_until_ready()
except Exception as e:
@ -499,7 +551,7 @@ class VibeApp(App): # noqa: PLR0904
pass
def _process_initial_prompt(self) -> None:
if self._teleport_on_start:
if self._teleport_on_start and self.commands.has_command("teleport"):
self.run_worker(
self._handle_teleport_command(self._initial_prompt), exclusive=False
)
@ -539,10 +591,9 @@ class VibeApp(App): # noqa: PLR0904
await self._handle_bash_command(value[1:])
return
if value.startswith("&"):
if self.config.nuage_enabled:
await self._handle_teleport_command(value[1:])
return
if value.startswith("&") and self.commands.has_command("teleport"):
await self._handle_teleport_command(value[1:])
return
if await self._handle_command(value):
return
@ -631,7 +682,20 @@ class VibeApp(App): # noqa: PLR0904
await self._switch_to_input_app()
await self._switch_to_model_picker_app()
async def _ensure_loading_widget(self, status: str = "Generating") -> None:
async def on_config_app_open_thinking_picker(
self, _message: ConfigApp.OpenThinkingPicker
) -> None:
config_app = self.query_one(ConfigApp)
changes = config_app._convert_changes_for_save()
if changes:
VibeConfig.save_updates(changes)
await self._reload_config()
await self._switch_to_input_app()
await self._switch_to_thinking_picker_app()
async def _ensure_loading_widget(
self, status: str = DEFAULT_LOADING_STATUS, *, show_hint: bool = True
) -> None:
if self._loading_widget and self._loading_widget.parent:
self._loading_widget.set_status(status)
return
@ -642,7 +706,7 @@ class VibeApp(App): # noqa: PLR0904
loading_area = self.query_one("#loading-area-content")
except Exception:
return
loading = LoadingWidget(status=status)
loading = LoadingWidget(status=status, show_hint=show_hint)
self._loading_widget = loading
await loading_area.mount(loading)
@ -716,10 +780,34 @@ class VibeApp(App): # noqa: PLR0904
) -> None:
await self._switch_to_input_app()
async def on_thinking_picker_app_thinking_selected(
self, message: ThinkingPickerApp.ThinkingSelected
) -> None:
self.config.set_thinking(message.level)
await self._reload_config()
await self._switch_to_input_app()
async def on_thinking_picker_app_cancelled(
self, _event: ThinkingPickerApp.Cancelled
) -> 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_mcpapp_mcptoggled(self, message: MCPApp.MCPToggled) -> None:
persist_mcp_toggle(
self.agent_loop.config,
name=message.name,
is_connector=message.kind == MCPSourceKind.CONNECTOR,
disabled=message.disabled,
tool_name=message.tool_name,
)
self.agent_loop.refresh_config()
self.query_one(MCPApp).refresh_index()
self._refresh_banner()
async def on_proxy_setup_app_proxy_setup_closed(
self, message: ProxySetupApp.ProxySetupClosed
) -> None:
@ -927,8 +1015,9 @@ class VibeApp(App): # noqa: PLR0904
user_message = UserMessage(message, message_index=message_index)
await self._mount_and_scroll(user_message)
if self.agent_loop.telemetry_client.is_active():
self._feedback_bar.maybe_show()
if self._feedback_bar_manager.should_show(self.agent_loop):
self._feedback_bar.show()
self._feedback_bar_manager.record_feedback_asked()
if not self._agent_running:
await self._remote_manager.stop_stream()
@ -1046,7 +1135,7 @@ class VibeApp(App): # noqa: PLR0904
) -> tuple[ApprovalResponse, str | None]:
# Auto-approve only if parent is in auto-approve mode AND tool is enabled
# This ensures subagents respect the main agent's tool restrictions
if self.agent_loop and self.agent_loop.config.auto_approve:
if self.agent_loop and self.agent_loop.config.bypass_tool_permissions:
if self._is_tool_enabled_in_main_agent(tool):
return (ApprovalResponse.YES, None)
@ -1083,40 +1172,46 @@ class VibeApp(App): # noqa: PLR0904
if self.event_handler:
self.event_handler.stop_current_tool_call(success=False)
async def _handle_agent_loop_init(self) -> None:
show_init_spinner = not self.agent_loop.is_initialized
if show_init_spinner:
await self._ensure_loading_widget("Initializing", show_hint=False)
await self.agent_loop.wait_until_ready()
if show_init_spinner:
await self._remove_loading_widget()
self._refresh_banner()
async def _handle_agent_loop_events(
self, events: AsyncGenerator[BaseEvent]
) -> None:
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 isinstance(event, HookStartEvent):
await self._ensure_loading_widget(f"Running hook {event.hook_name}")
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_widget=self._loading_widget
)
async def _handle_agent_loop_turn(self, prompt: str) -> None:
self._agent_running = True
await self._remove_loading_widget()
try:
show_init_spinner = not self.agent_loop.is_initialized
if show_init_spinner:
await self._ensure_loading_widget("Initializing")
await self.agent_loop.wait_until_ready()
if show_init_spinner:
await self._remove_loading_widget()
self._refresh_banner()
await self._handle_agent_loop_init()
await self._ensure_loading_widget()
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt)
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,
)
await self._handle_agent_loop_events(events)
except asyncio.CancelledError:
await self._handle_turn_error()
self._narrator_manager.on_turn_cancel()
@ -1129,9 +1224,7 @@ class VibeApp(App): # noqa: PLR0904
if self._fatal_init_error:
return
message = str(e)
if isinstance(e, RateLimitError):
message = self._rate_limit_message()
message = self._resolve_turn_error_message(e)
self._narrator_manager.on_turn_error(message)
await self._mount_and_scroll(
@ -1150,6 +1243,13 @@ class VibeApp(App): # noqa: PLR0904
await self._refresh_windowing_from_history()
self._terminal_notifier.notify(NotificationContext.COMPLETE)
def _resolve_turn_error_message(self, e: Exception) -> str:
if isinstance(e, RateLimitError):
return self._rate_limit_message()
if isinstance(e, ContextTooLongError):
return self._context_too_long_message()
return str(e)
def _rate_limit_message(self) -> str:
upgrade_to_pro = self._plan_info and (
self._plan_info.plan_type
@ -1160,6 +1260,16 @@ 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."
def _context_too_long_message(self) -> str:
return (
"The conversation context exceeds the model's maximum limit. "
"The last messages and output of agent actions went above the allowed size.\n\n"
"To recover:\n"
"1. Use /rewind to undo recent messages and tool outputs\n"
"2. Then use /compact to summarize the remaining conversation\n\n"
"This will free up context space so you can continue working."
)
async def _teleport_command(self, **kwargs: Any) -> None:
await self._handle_teleport_command(show_message=False)
@ -1203,7 +1313,7 @@ class VibeApp(App): # noqa: PLR0904
return
try:
gen = self.agent_loop.teleport_to_vibe_nuage(prompt)
gen = self.agent_loop.teleport_to_vibe_code(prompt)
async for event in gen:
match event:
case TeleportCheckingGitEvent():
@ -1316,6 +1426,29 @@ class VibeApp(App): # noqa: PLR0904
help_text = self.commands.get_help_text()
await self._mount_and_scroll(UserCommandMessage(help_text))
def _get_last_assistant_message_text(self) -> str | None:
messages_area = self._cached_messages_area or self.query_one("#messages")
for child in reversed(messages_area.children):
if not isinstance(child, AssistantMessage):
continue
if not (content := child.get_content().strip()):
continue
return content
return None
async def _copy_last_agent_message(self, **kwargs: Any) -> None:
if (content := self._get_last_assistant_message_text()) is None:
self.notify(
"No agent message available to copy", severity="warning", timeout=3
)
return
copied_text = copy_text_to_clipboard(
self, content, success_message="Last agent message copied to clipboard"
)
if copied_text is not None:
self.agent_loop.telemetry_client.send_user_copied_text(copied_text)
async def _refresh_mcp_browser(self) -> str:
await self.agent_loop.tool_manager.refresh_remote_tools_async()
await self.agent_loop.refresh_system_prompt()
@ -1338,6 +1471,7 @@ class VibeApp(App): # noqa: PLR0904
)
await self._mount_and_scroll(UserCommandMessage(msg))
return
if self._current_bottom_app == BottomApp.MCP:
return
name = cmd_args.strip()
@ -1365,6 +1499,7 @@ class VibeApp(App): # noqa: PLR0904
tool_manager=self.agent_loop.tool_manager,
initial_server=name,
connector_registry=connector_registry,
get_connector_configs=lambda: self.agent_loop.config.connectors,
refresh_callback=self._refresh_mcp_browser,
)
)
@ -1394,6 +1529,12 @@ class VibeApp(App): # noqa: PLR0904
return
await self._switch_to_model_picker_app()
async def _show_thinking(self, **kwargs: Any) -> None:
"""Switch to the thinking level picker in the bottom panel."""
if self._current_bottom_app == BottomApp.ThinkingPicker:
return
await self._switch_to_thinking_picker_app()
async def _show_proxy_setup(self, **kwargs: Any) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
@ -1503,7 +1644,7 @@ class VibeApp(App): # noqa: PLR0904
f"Session `{short_session_id(session.session_id)}` not found."
)
loaded_messages, _ = SessionLoader.load_session(session_path)
loaded_messages, metadata = SessionLoader.load_session(session_path)
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
@ -1515,6 +1656,7 @@ class VibeApp(App): # noqa: PLR0904
]
self.agent_loop.session_id = session.session_id
self.agent_loop.parent_session_id = metadata.get("parent_session_id")
self.agent_loop.session_logger.resume_existing_session(
session.session_id, session_path
)
@ -1554,13 +1696,9 @@ class VibeApp(App): # noqa: PLR0904
self.event_handler.is_remote = True
self._remote_manager.start_stream(self)
async def on_remote_event(
self, event: BaseEvent, loading_active: bool, loading_widget: Any
) -> None:
async def on_remote_event(self, event: BaseEvent, loading_widget: Any) -> None:
if self.event_handler:
await self.event_handler.handle_event(
event, loading_active=loading_active, loading_widget=loading_widget
)
await self.event_handler.handle_event(event, loading_widget=loading_widget)
async def on_remote_waiting_input(self, event: WaitingForInputEvent) -> None:
await self._handle_remote_waiting_input(event)
@ -1591,7 +1729,7 @@ class VibeApp(App): # noqa: PLR0904
async def remove_loading(self) -> None:
await self._remove_loading_widget()
async def ensure_loading(self, status: str = "Generating") -> None:
async def ensure_loading(self, status: str = DEFAULT_LOADING_STATUS) -> None:
await self._ensure_loading_widget(status)
@property
@ -1613,7 +1751,9 @@ class VibeApp(App): # noqa: PLR0904
base_config,
self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
connector_registry=self.agent_loop.connector_registry,
connectors_count=_compute_connectors_count(
base_config, self.agent_loop.connector_registry
),
plan_description=plan_title(self._plan_info),
)
await self._mount_and_scroll(
@ -1704,7 +1844,7 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _compact_history(self, **kwargs: Any) -> None:
async def _compact_history(self, cmd_args: str = "", **kwargs: Any) -> None:
if self._agent_running:
await self._mount_and_scroll(
ErrorMessage(
@ -1732,13 +1872,15 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(compact_msg)
self._agent_task = asyncio.create_task(
self._run_compact(compact_msg, old_tokens)
self._run_compact(compact_msg, old_tokens, cmd_args.strip())
)
async def _run_compact(self, compact_msg: CompactMessage, old_tokens: int) -> None:
async def _run_compact(
self, compact_msg: CompactMessage, old_tokens: int, extra_instructions: str = ""
) -> None:
self._agent_running = True
try:
await self.agent_loop.compact()
await self.agent_loop.compact(extra_instructions=extra_instructions)
new_tokens = self.agent_loop.stats.context_tokens
compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens)
@ -1839,6 +1981,19 @@ class VibeApp(App): # noqa: PLR0904
ModelPickerApp(model_aliases=model_aliases, current_model=current_model)
)
async def _switch_to_thinking_picker_app(self) -> None:
if self._current_bottom_app == BottomApp.ThinkingPicker:
return
from vibe.core.config import THINKING_LEVELS
current_thinking = self.config.get_active_model().thinking
await self._switch_from_input(
ThinkingPickerApp(
thinking_levels=THINKING_LEVELS, current_thinking=current_thinking
)
)
async def _switch_to_proxy_setup_app(self) -> None:
if self._current_bottom_app == BottomApp.ProxySetup:
return
@ -1892,6 +2047,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(ConfigApp).focus()
case BottomApp.ModelPicker:
self.query_one(ModelPickerApp).focus()
case BottomApp.ThinkingPicker:
self.query_one(ThinkingPickerApp).focus()
case BottomApp.ProxySetup:
self.query_one(ProxySetupApp).focus()
case BottomApp.Approval:
@ -1953,6 +2110,14 @@ class VibeApp(App): # noqa: PLR0904
pass
self._last_escape_time = None
def _handle_thinking_picker_app_escape(self) -> None:
try:
thinking_picker = self.query_one(ThinkingPickerApp)
thinking_picker.post_message(ThinkingPickerApp.Cancelled())
except Exception:
pass
self._last_escape_time = None
def _handle_session_picker_app_escape(self) -> None:
try:
session_picker = self.query_one(SessionPickerApp)
@ -2178,73 +2343,76 @@ class VibeApp(App): # noqa: PLR0904
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()
return
current_time = time.monotonic()
def _try_interrupt_bottom_app_escape(self) -> bool:
if self._current_bottom_app == BottomApp.Config:
self._handle_config_app_escape()
return
if self._current_bottom_app == BottomApp.Voice:
elif self._current_bottom_app == BottomApp.Voice:
self._handle_voice_app_escape()
return
if self._current_bottom_app == BottomApp.MCP:
elif self._current_bottom_app == BottomApp.MCP:
self._handle_bottom_app_close_escape(MCPApp)
return
if self._current_bottom_app == BottomApp.ProxySetup:
elif self._current_bottom_app == BottomApp.ProxySetup:
self._handle_bottom_app_close_escape(ProxySetupApp)
return
if self._current_bottom_app == BottomApp.Approval:
elif self._current_bottom_app == BottomApp.Approval:
self._handle_approval_app_escape()
return
if self._current_bottom_app == BottomApp.Question:
elif self._current_bottom_app == BottomApp.Question:
self._handle_question_app_escape()
return
if self._current_bottom_app == BottomApp.ModelPicker:
elif self._current_bottom_app == BottomApp.ModelPicker:
self._handle_model_picker_app_escape()
return
if self._current_bottom_app == BottomApp.SessionPicker:
elif self._current_bottom_app == BottomApp.ThinkingPicker:
self._handle_thinking_picker_app_escape()
elif self._current_bottom_app == BottomApp.SessionPicker:
self._handle_session_picker_app_escape()
return
if self._current_bottom_app == BottomApp.Rewind:
elif self._current_bottom_app == BottomApp.Rewind:
self.run_worker(self._exit_rewind_mode(), exclusive=False)
self._last_escape_time = None
return
if (
elif (
self._current_bottom_app == BottomApp.Input
and self._last_escape_time is not None
and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004
and (time.monotonic() - self._last_escape_time) < DOUBLE_ESC_DELAY
):
self._handle_input_app_escape()
return
else:
return False
return True
def _try_interrupt(self) -> bool:
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
self._voice_manager.cancel_recording()
return True
if (
self._chat_input_container
and self._chat_input_container.dismiss_completion()
):
if self._chat_input_container.value.startswith("/"):
self._chat_input_container.value = ""
self._last_escape_time = None
return True
if self._try_interrupt_bottom_app_escape():
return True
if (
self._narrator_manager.is_playing
or self._narrator_manager.state != NarratorState.IDLE
):
self._narrator_manager.cancel()
return
return True
interrupted = False
if self._agent_running:
self._handle_agent_running_escape()
interrupted = True
self._last_escape_time = current_time
self._last_escape_time = time.monotonic()
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
if chat.is_at_bottom:
self.call_after_refresh(chat.anchor)
self._focus_current_bottom_app()
return interrupted
def action_interrupt(self) -> None:
self._try_interrupt()
async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None:
self._load_more.set_enabled(False)
@ -2311,7 +2479,9 @@ class VibeApp(App): # noqa: PLR0904
self.config,
self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
connector_registry=self.agent_loop.connector_registry,
connectors_count=_compute_connectors_count(
self.config, self.agent_loop.connector_registry
),
plan_description=plan_title(self._plan_info),
)
@ -2369,17 +2539,36 @@ class VibeApp(App): # noqa: PLR0904
self._debug_console = DebugConsole(log_reader=self._log_reader)
await self.mount(self._debug_console)
def action_clear_quit(self) -> None:
def _get_chat_input(self) -> ChatInputContainer | None:
input_widgets = self.query(ChatInputContainer)
if input_widgets:
input_widget = input_widgets.first()
if input_widget.value:
input_widget.value = ""
return
return input_widgets.first()
return None
self.action_force_quit()
def action_interrupt_or_quit(self) -> None:
if (container := self._get_chat_input()) and container.value:
container.value = ""
return
def action_force_quit(self) -> None:
if self._quit_manager.is_confirmed("Ctrl+C"):
self._force_quit()
return
if self._try_interrupt():
return
self._quit_manager.request_confirmation("Ctrl+C")
def action_delete_right_or_quit(self) -> None:
if (container := self._get_chat_input()) and container.value:
if container.input_widget:
container.input_widget.action_delete_right()
return
if self._quit_manager.is_confirmed("Ctrl+D"):
self._force_quit()
return
self._quit_manager.request_confirmation("Ctrl+D")
def _force_quit(self) -> None:
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
self._remote_manager.cancel_stream_task()
@ -2439,23 +2628,24 @@ class VibeApp(App): # noqa: PLR0904
async def _resolve_plan(self) -> None:
if self._plan_offer_gateway is None:
self._plan_info = None
self._refresh_command_registry()
return
try:
active_model = self.config.get_active_model()
provider = self.config.get_provider_for_model(active_model)
if provider.backend != Backend.MISTRAL:
if not self.config.is_active_model_mistral():
self._plan_info = None
return
provider = self.config.get_active_provider()
api_key = resolve_api_key_for_plan(provider)
self._plan_info = await decide_plan_offer(api_key, self._plan_offer_gateway)
except Exception as exc:
logger.warning(
"Plan-offer check failed (%s).", type(exc).__name__, exc_info=True
)
return
self._plan_info = None
finally:
self._refresh_command_registry()
async def _mount_and_scroll(
self, widget: Widget, after: Widget | None = None
@ -2466,12 +2656,13 @@ class VibeApp(App): # noqa: PLR0904
is_user_initiated = isinstance(widget, (UserMessage, UserCommandMessage))
should_anchor = is_user_initiated or chat.is_at_bottom
if after is not None and after.parent is messages_area:
await messages_area.mount(widget, after=after)
else:
await messages_area.mount(widget)
if isinstance(widget, StreamingMessageBase):
await widget.write_initial_content()
with self.batch_update():
if after is not None and after.parent is messages_area:
await messages_area.mount(widget, after=after)
else:
await messages_area.mount(widget)
if isinstance(widget, StreamingMessageBase):
await widget.write_initial_content()
self.call_after_refresh(self._try_prune)
if should_anchor:
@ -2600,15 +2791,20 @@ class VibeApp(App): # noqa: PLR0904
def run_textual_ui(
agent_loop: AgentLoop, startup: StartupOptions | None = None
) -> None:
from vibe.cli.stderr_guard import stderr_guard
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
plan_offer_gateway = HttpWhoAmIGateway()
app = VibeApp(
agent_loop=agent_loop,
startup=startup,
update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
plan_offer_gateway=plan_offer_gateway,
)
session_id = app.run()
with stderr_guard():
app = VibeApp(
agent_loop=agent_loop,
startup=startup,
update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
plan_offer_gateway=plan_offer_gateway,
)
session_id = app.run()
print_session_resume_message(session_id, agent_loop.stats)

View file

@ -88,13 +88,18 @@ TextArea > .text-area--cursor {
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;
overflow-y: auto;
scrollbar-size-vertical: 1;
/* max-height, max-width and padding set in completion_popup.py */
}
#completion-popup _CompletionItem {
height: auto;
width: 1fr;
}
#input-box {
@ -250,7 +255,7 @@ Markdown {
padding: 0;
margin: 0;
& > *:last-child {
& > MarkdownBlock:last-child {
margin-bottom: 0;
}
}
@ -305,7 +310,7 @@ Markdown {
text-style: italic;
}
& > *:last-child {
& > MarkdownBlock:last-child {
margin-bottom: 0;
}
}
@ -371,11 +376,11 @@ Markdown {
margin: 0;
padding: 0;
& > *:first-child {
& > MarkdownBlock:first-child {
margin-top: 0;
}
& > *:last-child {
& > MarkdownBlock:last-child {
margin-bottom: 0;
}
}
@ -936,7 +941,7 @@ StatusMessage {
padding: 0;
margin: 0;
& > *:last-child {
& > MarkdownBlock:last-child {
margin-bottom: 0;
}
}
@ -1034,11 +1039,11 @@ NarratorStatus {
padding: 0 0 0 1;
margin: 0;
& > *:first-child {
& > MarkdownBlock:first-child {
margin-top: 0;
}
& > *:last-child {
& > MarkdownBlock:last-child {
margin-bottom: 0;
}
@ -1164,6 +1169,45 @@ NarratorStatus {
margin-top: 1;
}
#thinkingpicker-app {
width: 100%;
height: auto;
background: transparent;
border: solid ansi_bright_black;
padding: 0 1;
margin: 0;
}
#thinkingpicker-content {
width: 100%;
height: auto;
}
.thinkingpicker-title {
height: auto;
text-style: bold;
color: ansi_blue;
}
#thinkingpicker-options {
width: 100%;
max-height: 50vh;
text-wrap: nowrap;
text-overflow: ellipsis;
border: none;
}
#thinkingpicker-options:focus {
border: none;
}
.thinkingpicker-help {
width: 100%;
height: auto;
color: ansi_bright_black;
margin-top: 1;
}
FeedbackBar {
width: auto;
height: auto;
@ -1220,3 +1264,53 @@ FeedbackBar {
height: auto;
color: ansi_default;
}
.hook-system-message {
height: auto;
width: 100%;
margin-top: 0;
margin-bottom: 0;
}
.hook-run-container {
height: auto;
width: 100%;
margin-top: 1;
}
.hook-run-container:empty {
display: none;
}
.hook-system-container {
width: 100%;
height: auto;
margin: 0;
padding: 0;
}
.hook-system-icon {
width: auto;
height: 1;
padding: 0 1 0 0;
}
.hook-system-content {
width: 1fr;
height: auto;
max-height: 3;
overflow: hidden;
color: ansi_bright_black;
}
.hook-severity-ok .hook-system-icon {
color: ansi_green;
}
.hook-severity-warning .hook-system-icon {
color: ansi_yellow;
}
.hook-severity-error .hook-system-icon {
color: ansi_red;
}

View file

@ -4,13 +4,23 @@ from collections.abc import Callable
from typing import TYPE_CHECKING
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.loading import DEFAULT_LOADING_STATUS
from vibe.cli.textual_ui.widgets.messages import (
AssistantMessage,
HookRunContainer,
HookSystemMessageLine,
ReasoningMessage,
UserMessage,
)
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.hooks.models import (
HookEndEvent,
HookEvent,
HookRunEndEvent,
HookRunStartEvent,
HookStartEvent,
)
from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import (
AgentProfileChangedEvent,
@ -47,12 +57,36 @@ class EventHandler:
self.current_compact: CompactMessage | None = None
self.current_streaming_message: AssistantMessage | None = None
self.current_streaming_reasoning: ReasoningMessage | None = None
self._hook_run_container: HookRunContainer | None = None
async def _handle_hook_event(
self, event: HookEvent, loading_widget: LoadingWidget | None = None
) -> None:
match event:
case HookRunStartEvent():
self._hook_run_container = HookRunContainer()
await self.mount_callback(self._hook_run_container)
case HookRunEndEvent():
if self._hook_run_container and not self._hook_run_container.display:
await self._hook_run_container.remove()
self._hook_run_container = None
case HookStartEvent():
await self.finalize_streaming()
if loading_widget:
loading_widget.set_status(f"Running hook {event.hook_name}")
case HookEndEvent():
if event.content and self._hook_run_container is not None:
widget = HookSystemMessageLine(
hook_name=event.hook_name,
content=event.content,
severity=event.status,
)
await self._hook_run_container.add_message(widget)
if loading_widget:
loading_widget.set_status(DEFAULT_LOADING_STATUS)
async def handle_event(
self,
event: BaseEvent,
loading_active: bool = False,
loading_widget: LoadingWidget | None = None,
self, event: BaseEvent, loading_widget: LoadingWidget | None = None
) -> ToolCallMessage | None:
match event:
case ReasoningEvent():
@ -81,6 +115,8 @@ class EventHandler:
await self.finalize_streaming()
if self.is_remote:
await self.mount_callback(UserMessage(event.content))
case HookEvent():
await self._handle_hook_event(event, loading_widget)
case WaitingForInputEvent():
await self.finalize_streaming()
case _:

View file

@ -0,0 +1,61 @@
from __future__ import annotations
import time
from typing import Literal
from textual.app import App
from textual.timer import Timer
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
QuitConfirmKey = Literal["Ctrl+C", "Ctrl+D"]
QUIT_CONFIRM_DELAY = 1.0
class QuitManager:
def __init__(self, app: App) -> None:
self._confirm_time: float | None = None
self._confirm_key: QuitConfirmKey | None = None
self._confirm_timer: Timer | None = None
self._app = app
@property
def confirm_key(self) -> QuitConfirmKey | None:
return self._confirm_key
def is_confirmed(self, key: QuitConfirmKey) -> bool:
return (
self._confirm_time is not None
and self._confirm_key == key
and (time.monotonic() - self._confirm_time) < QUIT_CONFIRM_DELAY
)
def request_confirmation(self, key: QuitConfirmKey) -> None:
if self._confirm_timer is not None:
self._confirm_timer.stop()
self._confirm_timer = None
self._confirm_time = time.monotonic()
self._confirm_key = key
try:
path_display = self._app.query_one(PathDisplay)
path_display.update(f"Press {key} again to quit")
except Exception:
pass
self._confirm_timer = self._app.set_timer(
QUIT_CONFIRM_DELAY, self.cancel_confirmation
)
def cancel_confirmation(self) -> None:
if self._confirm_time is None:
return
self._confirm_time = None
self._confirm_key = None
if self._confirm_timer:
self._confirm_timer.stop()
self._confirm_timer = None
try:
path_display = self._app.query_one(PathDisplay)
path_display.refresh_display()
except Exception:
pass

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from typing import Any, Protocol
from vibe.cli.textual_ui.widgets.loading import DEFAULT_LOADING_STATUS
from vibe.core.config import VibeConfig
from vibe.core.nuage.remote_events_source import RemoteEventsSource
from vibe.core.tools.builtins.ask_user_question import (
@ -25,16 +26,14 @@ _MAX_QUESTION_OPTIONS = 4
class RemoteSessionUI(Protocol):
async def on_remote_event(
self, event: BaseEvent, loading_active: bool, loading_widget: Any
) -> None: ...
async def on_remote_event(self, event: BaseEvent, loading_widget: Any) -> None: ...
async def on_remote_waiting_input(self, event: WaitingForInputEvent) -> None: ...
async def on_remote_user_message_cleared_input(self) -> None: ...
async def on_remote_stream_error(self, error: str) -> None: ...
async def on_remote_stream_ended(self, msg_type: str, text: str) -> None: ...
async def on_remote_finalize_streaming(self) -> None: ...
async def remove_loading(self) -> None: ...
async def ensure_loading(self, status: str = "Generating") -> None: ...
async def ensure_loading(self, status: str = DEFAULT_LOADING_STATUS) -> None: ...
@property
def loading_widget(self) -> Any: ...
@ -182,7 +181,7 @@ class RemoteSessionManager:
events_source = self._events_source
if events_source is None:
return
await ui.ensure_loading("Generating")
await ui.ensure_loading(DEFAULT_LOADING_STATUS)
try:
async for event in events_source.attach():
if isinstance(event, WaitingForInputEvent):
@ -197,11 +196,7 @@ class RemoteSessionManager:
await ui.on_remote_user_message_cleared_input()
elif ui.loading_widget is None and is_progress_event(event):
await ui.ensure_loading()
await ui.on_remote_event(
event,
loading_active=ui.loading_widget is not None,
loading_widget=ui.loading_widget,
)
await ui.on_remote_event(event, loading_widget=ui.loading_widget)
except asyncio.CancelledError:
raise
except Exception as e:

View file

@ -13,7 +13,6 @@ from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import VibeConfig
from vibe.core.skills.manager import SkillManager
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.mcp.registry import MCPRegistry
@ -21,10 +20,6 @@ def _pluralize(count: int, singular: str) -> str:
return f"{count} {singular}{'s' if count != 1 else ''}"
def _connector_count(registry: ConnectorRegistry | None) -> int:
return registry.connector_count if registry else 0
@dataclass
class BannerState:
active_model: str = ""
@ -43,17 +38,16 @@ class Banner(Static):
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
connector_registry: ConnectorRegistry | None = None,
connectors_count: int = 0,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.can_focus = False
self._initial_state = BannerState(
active_model=config.active_model,
models_count=len(config.models),
mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers),
connectors_count=_connector_count(connector_registry),
skills_count=skill_manager.custom_skills_count,
self._initial_state = self._build_state(
config=config,
skill_manager=skill_manager,
mcp_registry=mcp_registry,
connectors_count=connectors_count,
plan_description=None,
)
self._animated = not config.disable_welcome_banner_animation
@ -97,14 +91,30 @@ class Banner(Static):
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
connector_registry: ConnectorRegistry | None = None,
connectors_count: int = 0,
plan_description: str | None = None,
) -> None:
self.state = BannerState(
active_model=config.active_model,
self.state = self._build_state(
config, skill_manager, mcp_registry, connectors_count, plan_description
)
@staticmethod
def _build_state(
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
connectors_count: int = 0,
plan_description: str | None = None,
) -> BannerState:
enabled_servers = [s for s in config.mcp_servers if not s.disabled]
mcp_count = mcp_registry.count_loaded(enabled_servers)
active_model = config.get_active_model()
return BannerState(
active_model=f"{active_model.alias}[{active_model.thinking}]",
models_count=len(config.models),
mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers),
connectors_count=_connector_count(connector_registry),
mcp_servers_count=mcp_count,
connectors_count=connectors_count,
skills_count=skill_manager.custom_skills_count,
plan_description=plan_description,
)

View file

@ -10,6 +10,7 @@ from textual.message import Message
from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.commands import CommandRegistry
from vibe.cli.history_manager import HistoryManager
from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
@ -44,15 +45,15 @@ class ChatInputBody(VoiceManagerListener, Widget):
def __init__(
self,
command_registry: CommandRegistry,
history_file: Path | None = None,
nuage_enabled: bool = False,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.input_widget: ChatTextArea | None = None
self.prompt_widget: NoMarkupStatic | None = None
self._nuage_enabled = nuage_enabled
self._command_registry = command_registry
self._switching_mode = False
self._voice_manager = voice_manager
self._recording_indicator: RecordingIndicator | None = None
@ -71,7 +72,7 @@ class ChatInputBody(VoiceManagerListener, Widget):
self.input_widget = ChatTextArea(
id="input",
nuage_enabled=self._nuage_enabled,
command_registry=self._command_registry,
voice_manager=self._voice_manager,
)
yield self.input_widget
@ -91,7 +92,7 @@ class ChatInputBody(VoiceManagerListener, Widget):
return "!", text[1:]
elif text.startswith("/"):
return "/", text[1:]
elif text.startswith("&") and self._nuage_enabled:
elif text.startswith("&") and self._command_registry.has_command("teleport"):
return "&", text[1:]
else:
return ">", text

View file

@ -52,6 +52,10 @@ class MultiCompletionManager:
return CompletionResult.IGNORED
return self._active.on_key(event, text, cursor_index)
@property
def is_active(self) -> bool:
return self._active is not None
def reset(self) -> None:
if self._active is not None:
self._active.reset()

View file

@ -2,14 +2,27 @@ from __future__ import annotations
from typing import Any
from rich.cells import cell_len
from rich.text import Text
from textual.containers import VerticalScroll
from textual.widgets import Static
COMPLETION_POPUP_MAX_HEIGHT = 12
COMPLETION_POPUP_MAX_WIDTH = 80
COMPLETION_POPUP_PADDING_X = 1
class CompletionPopup(Static):
class _CompletionItem(Static):
pass
class CompletionPopup(VerticalScroll):
def __init__(self, **kwargs: Any) -> None:
super().__init__("", id="completion-popup", **kwargs)
super().__init__(id="completion-popup", **kwargs)
self.styles.display = "none"
self.styles.max_height = COMPLETION_POPUP_MAX_HEIGHT
self.styles.max_width = COMPLETION_POPUP_MAX_WIDTH
self.styles.padding = (0, COMPLETION_POPUP_PADDING_X)
self.can_focus = False
def update_suggestions(
@ -19,11 +32,11 @@ class CompletionPopup(Static):
self.hide()
return
text = Text()
for idx, (label, description) in enumerate(suggestions):
if idx:
text.append("\n")
self.remove_children()
items: list[_CompletionItem] = []
for idx, (label, description) in enumerate(suggestions):
text = Text()
label_style = "bold reverse" if idx == selected else "bold"
description_style = "italic" if idx == selected else "dim"
@ -32,14 +45,32 @@ class CompletionPopup(Static):
text.append(" ")
text.append(description, style=description_style)
self.update(text)
item = _CompletionItem(text)
items.append(item)
self.mount_all(items)
self.styles.display = "block"
if 0 <= selected < len(items):
items[selected].scroll_visible(animate=False)
def hide(self) -> None:
self.update("")
self.remove_children()
self.styles.display = "none"
def _display_label(self, label: str) -> str:
@property
def content_text(self) -> str:
return "\n".join(str(child.render()) for child in self.query(_CompletionItem))
@staticmethod
def _display_label(label: str) -> str:
if label.startswith("@"):
return label[1:]
return label
@classmethod
def rendered_text_length(cls, label: str, description: str) -> int:
text_length = cell_len(cls._display_label(label)) + cell_len(description)
if description:
text_length += 2
return text_length

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Callable
import math
from pathlib import Path
from typing import Any
@ -15,7 +16,12 @@ from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import (
COMPLETION_POPUP_MAX_HEIGHT,
COMPLETION_POPUP_MAX_WIDTH,
COMPLETION_POPUP_PADDING_X,
CompletionPopup,
)
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerPort
from vibe.core.agents import AgentSafety
@ -28,6 +34,12 @@ SAFETY_BORDER_CLASSES: dict[AgentSafety, str] = {
}
COMPLETION_POPUP_MAX_LINES = COMPLETION_POPUP_MAX_HEIGHT - 2
COMPLETION_POPUP_MAX_CHARS = (
COMPLETION_POPUP_MAX_WIDTH - 2 * COMPLETION_POPUP_PADDING_X - 2
) # -2 for borders
class ChatInputContainer(Vertical):
ID_INPUT_BOX = "input-box"
REMOTE_BORDER_CLASS = "border-remote"
@ -39,26 +51,24 @@ class ChatInputContainer(Vertical):
def __init__(
self,
command_registry: CommandRegistry,
history_file: Path | None = None,
command_registry: CommandRegistry | None = None,
safety: AgentSafety = AgentSafety.NEUTRAL,
agent_name: str = "",
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None,
nuage_enabled: bool = False,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._history_file = history_file
self._command_registry = command_registry or CommandRegistry()
self._command_registry = command_registry
self._safety = safety
self._agent_name = agent_name
self._skill_entries_getter = skill_entries_getter
self._file_watcher_for_autocomplete_getter = (
file_watcher_for_autocomplete_getter
)
self._nuage_enabled = nuage_enabled
self._voice_manager = voice_manager
self._custom_border_label: str | None = None
self._custom_border_class: str | None = None
@ -92,8 +102,8 @@ class ChatInputContainer(Vertical):
input_box.border_title = self._get_border_title()
self._body = ChatInputBody(
history_file=self._history_file,
command_registry=self._command_registry,
id="input-body",
nuage_enabled=self._nuage_enabled,
voice_manager=self._voice_manager,
)
@ -129,6 +139,12 @@ class ChatInputContainer(Vertical):
widget.get_full_text(), widget._get_full_cursor_offset()
)
def dismiss_completion(self) -> bool:
if self._completion_manager.is_active:
self._completion_manager.reset()
return True
return False
def focus_input(self) -> None:
if self._body:
self._body.focus_input()
@ -141,7 +157,7 @@ class ChatInputContainer(Vertical):
except Exception:
return
popup.update_suggestions(suggestions, selected_index)
self._position_popup(popup, len(suggestions))
self._position_popup(popup, suggestions)
def clear_completion_suggestions(self) -> None:
try:
@ -150,18 +166,28 @@ class ChatInputContainer(Vertical):
return
popup.hide()
def _position_popup(self, popup: CompletionPopup, line_count: int) -> None:
def _compute_line_count(self, suggestions: list[tuple[str, str]]) -> int:
line_count_without_scrollbar = sum(
math.ceil(
CompletionPopup.rendered_text_length(label, description)
/ COMPLETION_POPUP_MAX_CHARS
)
for label, description in suggestions
)
return min(line_count_without_scrollbar, COMPLETION_POPUP_MAX_LINES)
def _position_popup(
self, popup: CompletionPopup, suggestions: list[tuple[str, str]]
) -> 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,
)
popup_height = self._compute_line_count(suggestions) + 2 # +2 for solid border
offset = (cursor.x - my_region.x, cursor.y - popup_height - my_region.y)
popup.styles.offset = offset
def _format_insertion(self, replacement: str, suffix: str) -> str:
"""Format the insertion text with appropriate spacing.

View file

@ -8,6 +8,7 @@ from textual.message import Message
from textual.widgets import TextArea
from vibe.cli.autocompletion.base import CompletionResult
from vibe.cli.commands import CommandRegistry
from vibe.cli.textual_ui.external_editor import ExternalEditor
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
@ -59,12 +60,12 @@ class ChatTextArea(TextArea):
def __init__(
self,
nuage_enabled: bool = False,
command_registry: CommandRegistry,
voice_manager: VoiceManagerPort | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._nuage_enabled = nuage_enabled
self._command_registry = command_registry
self._input_mode: InputMode = self.DEFAULT_MODE
self._last_text = ""
self._navigating_history = False
@ -365,7 +366,7 @@ class ChatTextArea(TextArea):
@property
def mode_characters(self) -> set[InputMode]:
chars: set[InputMode] = {"!", "/"}
if self._nuage_enabled:
if self._command_registry.has_command("teleport"):
chars.add("&")
return chars

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from enum import StrEnum, auto
from typing import TYPE_CHECKING, ClassVar
from rich.text import Text
@ -17,6 +18,23 @@ if TYPE_CHECKING:
from vibe.core.config import VibeConfig
class ConfigOptionKind(StrEnum):
ACTION_MODEL = auto()
ACTION_THINKING = auto()
@staticmethod
def toggle(key: str) -> str:
return f"toggle:{key}"
@staticmethod
def is_toggle(option_id: str) -> bool:
return option_id.startswith("toggle:")
@staticmethod
def toggle_key(option_id: str) -> str:
return option_id.removeprefix("toggle:")
class ConfigApp(Container):
"""Settings panel with navigatable option picker."""
@ -40,6 +58,9 @@ class ConfigApp(Container):
class OpenModelPicker(Message):
pass
class OpenThinkingPicker(Message):
pass
def __init__(self, config: VibeConfig) -> None:
super().__init__(id="config-app")
self.config = config
@ -69,6 +90,18 @@ class ConfigApp(Container):
text.append(self._get_current_model(), style="bold")
return text
def _get_current_thinking(self) -> str:
try:
return str(self.config.get_active_model().thinking)
except ValueError:
return "off"
def _thinking_prompt(self) -> Text:
text = Text(no_wrap=True)
text.append("Thinking: ")
text.append(self._get_current_thinking().capitalize(), style="bold")
return text
def _toggle_prompt(self, key: str, label: str) -> Text:
value = self._get_toggle_value(key)
text = Text(no_wrap=True)
@ -80,9 +113,14 @@ class ConfigApp(Container):
return text
def compose(self) -> ComposeResult:
options: list[Option] = [Option(self._model_prompt(), id="action:active_model")]
options: list[Option] = [
Option(self._model_prompt(), id=ConfigOptionKind.ACTION_MODEL),
Option(self._thinking_prompt(), id=ConfigOptionKind.ACTION_THINKING),
]
for key, label in self._toggle_settings:
options.append(Option(self._toggle_prompt(key, label), id=f"toggle:{key}"))
options.append(
Option(self._toggle_prompt(key, label), id=ConfigOptionKind.toggle(key))
)
with Vertical(id="config-content"):
yield NoMarkupStatic("Settings", classes="settings-title")
@ -101,10 +139,15 @@ class ConfigApp(Container):
def _refresh_options(self) -> None:
option_list = self.query_one(OptionList)
option_list.replace_option_prompt("action:active_model", self._model_prompt())
option_list.replace_option_prompt(
ConfigOptionKind.ACTION_MODEL, self._model_prompt()
)
option_list.replace_option_prompt(
ConfigOptionKind.ACTION_THINKING, self._thinking_prompt()
)
for key, label in self._toggle_settings:
option_list.replace_option_prompt(
f"toggle:{key}", self._toggle_prompt(key, label)
ConfigOptionKind.toggle(key), self._toggle_prompt(key, label)
)
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
@ -112,12 +155,16 @@ class ConfigApp(Container):
if not option_id:
return
if option_id == "action:active_model":
if option_id == ConfigOptionKind.ACTION_MODEL:
self.post_message(self.OpenModelPicker())
return
if option_id.startswith("toggle:"):
key = option_id.removeprefix("toggle:")
if option_id == ConfigOptionKind.ACTION_THINKING:
self.post_message(self.OpenThinkingPicker())
return
if ConfigOptionKind.is_toggle(option_id):
key = ConfigOptionKind.toggle_key(option_id)
current = self._get_toggle_value(key)
new_value = "Off" if current == "On" else "On"
self.changes[key] = new_value

View file

@ -1,7 +1,5 @@
from __future__ import annotations
import random
from rich.text import Text
from textual.app import ComposeResult
from textual.message import Message
@ -10,7 +8,6 @@ from textual.widgets import Static
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
FEEDBACK_PROBABILITY = 0.02
THANK_YOU_DURATION = 2.0
@ -38,10 +35,8 @@ class FeedbackBar(Widget):
def on_mount(self) -> None:
self.display = False
def maybe_show(self) -> None:
if self.display:
return
if random.random() <= FEEDBACK_PROBABILITY:
def show(self) -> None:
if not self.display:
self._set_active(True)
def hide(self) -> None:

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import random
import time
from vibe.cli.cache import read_cache, write_cache
from vibe.core.agent_loop import AgentLoop
from vibe.core.paths import CACHE_FILE
from vibe.core.types import Role
FEEDBACK_PROBABILITY = 0.2
FEEDBACK_COOLDOWN_SECONDS = 3600
_CACHE_SECTION = "user_feedback"
_LAST_SHOWN_KEY = "last_shown_at"
MIN_USER_MESSAGES_FOR_FEEDBACK = 3
class FeedbackBarManager:
"""Decides whether to show the feedback bar and records when feedback is given."""
def should_show(self, agent_loop: AgentLoop) -> bool:
if not agent_loop.telemetry_client.is_active():
return False
if not agent_loop.config.is_active_model_mistral():
return False
if (
sum(m.role == Role.user and not m.injected for m in agent_loop.messages)
+ 1 # +1 for the message the user just sent
< MIN_USER_MESSAGES_FOR_FEEDBACK
):
return False
last_ts = (
read_cache(CACHE_FILE.path).get(_CACHE_SECTION, {}).get(_LAST_SHOWN_KEY, 0)
)
if not isinstance(last_ts, int):
return False
return (
time.time() - last_ts >= FEEDBACK_COOLDOWN_SECONDS
and random.random() <= FEEDBACK_PROBABILITY
)
def record_feedback_asked(self) -> None:
write_cache(
CACHE_FILE.path, _CACHE_SECTION, {_LAST_SHOWN_KEY: int(time.time())}
)

View file

@ -15,6 +15,8 @@ from vibe.cli.textual_ui.constants import MistralColors
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
DEFAULT_LOADING_STATUS = "Generating"
def _format_elapsed(seconds: int) -> str:
if seconds < 60: # noqa: PLR2004
@ -71,7 +73,7 @@ class LoadingWidget(SpinnerMixin, Static):
"Writing holiday cards",
]
def __init__(self, status: str | None = None) -> None:
def __init__(self, status: str | None = None, *, show_hint: bool = True) -> None:
super().__init__(classes="loading-widget")
self.init_spinner()
self.status = status or self._get_default_status()
@ -81,6 +83,7 @@ class LoadingWidget(SpinnerMixin, Static):
self._indicator_widget: Static | None = None
self._status_widget: Static | None = None
self.hint_widget: Static | None = None
self._show_hint = show_hint
self.start_time: float | None = None
self._last_elapsed: int = -1
self._paused_total: float = 0.0
@ -104,7 +107,7 @@ class LoadingWidget(SpinnerMixin, Static):
return None
def _get_default_status(self) -> str:
return self._get_easter_egg() or "Generating"
return self._get_easter_egg() or DEFAULT_LOADING_STATUS
def _apply_easter_egg(self, status: str) -> str:
return self._get_easter_egg() or status
@ -120,7 +123,8 @@ class LoadingWidget(SpinnerMixin, Static):
def set_status(self, status: str) -> None:
self.status = self._apply_easter_egg(status)
self._update_animation()
if self._status_widget:
self._status_widget.update(self._build_status_text())
def compose(self) -> ComposeResult:
with Horizontal(classes="loading-container"):
@ -132,10 +136,11 @@ class LoadingWidget(SpinnerMixin, Static):
self._status_widget = Static("", classes="loading-status")
yield self._status_widget
self.hint_widget = NoMarkupStatic(
"(0s esc to interrupt)", classes="loading-hint"
)
yield self.hint_widget
if self._show_hint:
self.hint_widget = NoMarkupStatic(
"(0s Esc/Ctrl+C to interrupt)", classes="loading-hint"
)
yield self.hint_widget
def on_mount(self) -> None:
self.start_time = time()
@ -196,7 +201,7 @@ class LoadingWidget(SpinnerMixin, Static):
if elapsed != self._last_elapsed:
self._last_elapsed = elapsed
self.hint_widget.update(
f"({_format_elapsed(elapsed)} esc to interrupt)"
f"({_format_elapsed(elapsed)} Esc/Ctrl+C to interrupt)"
)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Sequence
from enum import StrEnum, auto
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
from rich.text import Text
@ -10,18 +11,25 @@ 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 textual.widgets.option_list import Option, OptionDoesNotExist
from textual.worker import Worker
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import ConnectorConfig
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
from vibe.core.tools.mcp.tools import MCPTool
from vibe.core.tools.mcp_settings import updated_tool_list
if TYPE_CHECKING:
from vibe.core.config import MCPServer
from vibe.core.tools.manager import ToolManager
class MCPSourceKind(StrEnum):
SERVER = auto()
CONNECTOR = auto()
class MCPToolIndex(NamedTuple):
server_tools: dict[str, list[tuple[str, type[MCPTool]]]]
connector_tools: dict[str, list[tuple[str, type[MCPTool]]]]
@ -54,38 +62,69 @@ def collect_mcp_tool_index(
return MCPToolIndex(server_tools, connector_tools, enabled_tools=available)
_LIST_VIEW_HELP = (
"↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close"
)
_DETAIL_VIEW_HELP = (
"↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close"
)
class MCPApp(Container):
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "close", "Close", show=False),
Binding("backspace", "back", "Back", show=False),
Binding("d", "disable", "Disable", show=False),
Binding("e", "enable", "Enable", show=False),
Binding("r", "refresh", "Refresh", show=False),
]
class MCPClosed(Message):
pass
class MCPToggled(Message):
"""Posted when a server/connector or individual tool is toggled."""
def __init__(
self,
name: str,
kind: MCPSourceKind,
disabled: bool,
tool_name: str | None = None,
) -> None:
super().__init__()
self.name = name
self.kind = kind
self.disabled = disabled
self.tool_name = tool_name
def __init__(
self,
mcp_servers: Sequence[MCPServer],
tool_manager: ToolManager,
initial_server: str = "",
connector_registry: ConnectorRegistry | None = None,
get_connector_configs: Callable[[], list[ConnectorConfig]] | None = None,
refresh_callback: Callable[[], Awaitable[str]] | None = None,
) -> None:
super().__init__(id="mcp-app")
self._mcp_servers = mcp_servers
self._connector_registry = connector_registry
self._get_connector_configs = get_connector_configs or (lambda: [])
connector_names = (
connector_registry.get_connector_names() if connector_registry else []
)
self._connector_names = connector_names
self._sorted_connector_names = _sort_connector_names_for_menu(
connector_names, connector_registry
)
self._tool_manager = tool_manager
self._index = collect_mcp_tool_index(mcp_servers, tool_manager, connector_names)
# Track both the name and the kind ("server" or "connector") to
# disambiguate entries that share the same normalised name.
# Track both the name and the kind to disambiguate entries that
# share the same normalised name.
self._viewing_server: str | None = initial_server.strip() or None
self._viewing_kind: str | None = None
self._viewing_kind: MCPSourceKind | None = None
self._refresh_callback = refresh_callback
self._status_message: str | None = None
self._refreshing = False
@ -106,10 +145,10 @@ class MCPApp(Container):
"""Re-snapshot the tool index (e.g. after deferred MCP discovery)."""
if self._connector_registry:
self._connector_names = self._connector_registry.get_connector_names()
self._index = collect_mcp_tool_index(
self._mcp_servers, self._tool_manager, self._connector_names
)
self._refresh_view(self._viewing_server, kind=self._viewing_kind)
self._sorted_connector_names = _sort_connector_names_for_menu(
self._connector_names, self._connector_registry
)
self._rebuild_preserving_scroll()
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()
@ -117,9 +156,25 @@ class MCPApp(Container):
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:"), kind="server")
self._refresh_view(
option_id.removeprefix("server:"), kind=MCPSourceKind.SERVER
)
elif option_id.startswith("connector:"):
self._refresh_view(option_id.removeprefix("connector:"), kind="connector")
self._refresh_view(
option_id.removeprefix("connector:"), kind=MCPSourceKind.CONNECTOR
)
def on_option_list_option_highlighted(
self, event: OptionList.OptionHighlighted
) -> None:
option_list = self.query_one(OptionList)
highlighted = option_list.highlighted
if highlighted is None or highlighted == 0:
return
# When the first enabled option is highlighted and all options above
# it are disabled headers, scroll to top so the header stays visible.
if all(option_list.get_option_at_index(i).disabled for i in range(highlighted)):
option_list.scroll_to(y=0, animate=False, force=True, immediate=True)
def action_back(self) -> None:
if self._refreshing:
@ -137,13 +192,15 @@ class MCPApp(Container):
return
self._status_message = "Refreshing..."
self._refresh_view(self._viewing_server, kind=self._viewing_kind)
help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP
self._set_help_text(help)
self._refreshing = True
self.run_worker(self._run_refresh(), exclusive=True, group="refresh")
async def _run_refresh(self) -> str:
assert self._refresh_callback is not None
if self._refresh_callback is None:
return ""
return await self._refresh_callback()
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
@ -160,10 +217,147 @@ class MCPApp(Container):
text = f"{self._status_message} {text}"
self.query_one("#mcp-help", NoMarkupStatic).update(text)
def _find_connector_config(self, name: str) -> ConnectorConfig | None:
"""Look up a connector config by name from the live VibeConfig source."""
return next((c for c in self._get_connector_configs() if c.name == name), None)
def action_disable(self) -> None:
self._set_highlighted_disabled(disabled=True)
def action_enable(self) -> None:
self._set_highlighted_disabled(disabled=False)
def _set_highlighted_disabled(self, *, disabled: bool) -> None:
"""Set the disabled state for the highlighted server/connector or tool."""
# In detail view, set the individual tool state, not the parent.
if self._viewing_server is not None and self._viewing_kind is not None:
self._set_highlighted_tool_disabled(disabled=disabled)
return
target = self._get_highlighted_target()
if target is None:
return
name, kind = target
if kind == MCPSourceKind.SERVER:
for srv in self._mcp_servers:
if srv.name == name:
srv.disabled = disabled
break
else:
cfg = self._find_connector_config(name)
if cfg is None:
cfg = ConnectorConfig(name=name, disabled=disabled)
self._get_connector_configs().append(cfg)
else:
cfg.disabled = disabled
self.post_message(self.MCPToggled(name=name, kind=kind, disabled=disabled))
self._rebuild_preserving_scroll()
def _set_highlighted_tool_disabled(self, *, disabled: bool) -> None:
"""Toggle a single tool inside a detail view."""
server_name = self._viewing_server
kind = self._viewing_kind
if server_name is None or kind is None:
return
option_list = self.query_one(OptionList)
highlighted = option_list.highlighted
if highlighted is None:
return
option = option_list.get_option_at_index(highlighted)
option_id = option.id or ""
if not option_id.startswith("tool:"):
return
full_tool_name = option_id.removeprefix("tool:")
# Look up the remote name from the index.
tools_source = (
self._index.connector_tools
if kind == MCPSourceKind.CONNECTOR
else self._index.server_tools
)
remote_name: str | None = None
for t_name, cls in tools_source.get(server_name, []):
if t_name == full_tool_name:
remote_name = cls.get_remote_name()
break
if remote_name is None:
return
# Update disabled_tools on the config object.
if kind == MCPSourceKind.SERVER:
for srv in self._mcp_servers:
if srv.name == server_name:
srv.disabled_tools = updated_tool_list(
srv.disabled_tools, remote_name, disabled
)
break
else:
cfg = self._find_connector_config(server_name)
if cfg is None:
cfg = ConnectorConfig(
name=server_name, disabled_tools=[remote_name] if disabled else []
)
self._get_connector_configs().append(cfg)
else:
cfg.disabled_tools = updated_tool_list(
cfg.disabled_tools, remote_name, disabled
)
self.post_message(
self.MCPToggled(
name=server_name, kind=kind, disabled=disabled, tool_name=remote_name
)
)
self._rebuild_preserving_scroll()
def _rebuild_preserving_scroll(self) -> None:
"""Rebuild the tool index and refresh the view, preserving highlight and scroll."""
option_list = self.query_one(OptionList)
saved_option_id: str | None = None
if (idx := option_list.highlighted) is not None:
saved_option_id = option_list.get_option_at_index(idx).id
saved_scroll_y = option_list.scroll_offset.y
self._index = collect_mcp_tool_index(
self._mcp_servers, self._tool_manager, self._connector_names
)
self._refresh_view(self._viewing_server, kind=self._viewing_kind)
if saved_option_id is not None:
try:
new_index = option_list.get_option_index(saved_option_id)
option_list.highlighted = new_index
except OptionDoesNotExist:
pass
option_list.scroll_to(
y=saved_scroll_y, animate=False, force=True, immediate=True
)
def _get_highlighted_target(self) -> tuple[str, MCPSourceKind] | None:
"""Return (name, kind) for the currently highlighted option, or None."""
# If we're inside a detail view, use the viewed server/connector.
if self._viewing_server is not None and self._viewing_kind is not None:
return self._viewing_server, self._viewing_kind
option_list = self.query_one(OptionList)
highlighted = option_list.highlighted
if highlighted is None:
return None
option = option_list.get_option_at_index(highlighted)
option_id = option.id or ""
if option_id.startswith("server:"):
return option_id.removeprefix("server:"), MCPSourceKind.SERVER
if option_id.startswith("connector:"):
return option_id.removeprefix("connector:"), MCPSourceKind.CONNECTOR
return None
# ── list view ────────────────────────────────────────────────────
def _refresh_view(
self, server_name: str | None, *, kind: str | None = None
self, server_name: str | None, *, kind: MCPSourceKind | None = None
) -> None:
index = self._index
option_list = self.query_one(OptionList)
@ -179,9 +373,9 @@ class MCPApp(Container):
# Prefer server over connector when the name is ambiguous.
if kind is None:
if server_name in server_names:
kind = "server"
kind = MCPSourceKind.SERVER
else:
kind = "connector"
kind = MCPSourceKind.CONNECTOR
self._show_detail_view(server_name, option_list, index, kind=kind)
@ -191,37 +385,16 @@ class MCPApp(Container):
has_connectors = connectors_enabled() and bool(self._connector_names)
title = "MCP Servers & Connectors" if has_connectors else "MCP Servers"
self.query_one("#mcp-title", NoMarkupStatic).update(title)
self._set_help_text("↑↓ Navigate Enter Show tools R Refresh Esc Close")
self._set_help_text(_LIST_VIEW_HELP)
has_servers = bool(self._mcp_servers)
# ── Local MCP Servers ──
if has_servers:
max_name = max(len(srv.name) for srv in self._mcp_servers)
max_type = max(len(srv.transport) + 2 for srv in self._mcp_servers) # +[]
option_list.add_option(
Option(
Text("Local MCP Servers", style="bold", no_wrap=True), disabled=True
)
)
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)
type_tag = f"[{srv.transport}]"
label = Text(no_wrap=True)
label.append(f" {srv.name:<{max_name}}")
label.append(f" {type_tag:<{max_type}}", style="dim")
label.append(f" {_tool_count_text(enabled)}", style="dim")
option_list.add_option(Option(label, id=f"server:{srv.name}"))
# ── Workspace Connectors ──
self._list_mcp_servers(option_list, index)
if has_connectors:
if has_servers:
option_list.add_option(Option(Text("", no_wrap=True), disabled=True))
self._add_connector_options(
option_list, index, self._connector_names, self._connector_registry
)
self._list_connectors(option_list=option_list, index=index)
if not has_servers and not has_connectors:
empty_msg = (
"No MCP servers or connectors configured"
@ -233,50 +406,65 @@ class MCPApp(Container):
if has_servers or has_connectors:
# Skip disabled header options (e.g. section labels).
first_enabled = next(
(i for i, opt in enumerate(option_list._options) if not opt.disabled), 0
(i for i, opt in enumerate(option_list.options) if not opt.disabled), 0
)
option_list.highlighted = first_enabled
def _add_connector_options(
self,
option_list: OptionList,
index: MCPToolIndex,
connector_names: Sequence[str],
connector_registry: ConnectorRegistry | None,
) -> None:
ordered_connector_names = _sort_connector_names_for_menu(
connector_names, connector_registry
def _list_mcp_servers(self, option_list: OptionList, index: MCPToolIndex) -> None:
max_name = max(len(srv.name) for srv in self._mcp_servers)
max_type = max(len(srv.transport) + 2 for srv in self._mcp_servers)
option_list.add_option(
Option(Text("Local MCP Servers", style="bold", no_wrap=True), disabled=True)
)
max_name = max(len(name) for name in ordered_connector_names)
for srv in self._mcp_servers:
tools = index.server_tools.get(srv.name, [])
total = len(tools)
enabled = sum(1 for t, _ in tools if t in index.enabled_tools)
type_tag = f"[{srv.transport}]"
label = Text(no_wrap=True)
label.append(f" {srv.name:<{max_name}}")
label.append(f" {type_tag:<{max_type}}", style="dim")
label.append(f" {_tool_count_text(enabled, total)}", style="dim")
if srv.disabled:
label.append(" ")
label.append("", style="dim")
label.append(" disabled", style="dim")
option_list.add_option(Option(label, id=f"server:{srv.name}"))
def _list_connectors(self, option_list: OptionList, index: MCPToolIndex) -> None:
ordered_connector_names = self._sorted_connector_names
max_name = max(len(n) for n in ordered_connector_names)
type_tag = "[connector]"
type_width = len(type_tag)
tool_texts = {
name: _tool_count_text(
sum(
1
for tool_name, _ in index.connector_tools.get(name, [])
if tool_name in index.enabled_tools
)
)
for name in ordered_connector_names
}
max_tools = max(len(text) for text in tool_texts.values())
tool_texts: dict[str, str] = {}
for n in ordered_connector_names:
tools = index.connector_tools.get(n, [])
total = len(tools)
enabled = sum(1 for t, _ in tools if t in index.enabled_tools)
tool_texts[n] = _tool_count_text(enabled, total)
max_tools = max(len(t) for t in tool_texts.values())
option_list.add_option(
Option(
Text("Workspace Connectors", style="bold", no_wrap=True), disabled=True
)
)
for connector_name in ordered_connector_names:
for cname in ordered_connector_names:
cfg = self._find_connector_config(cname)
is_disabled = cfg.disabled if cfg else False
connected = (
connector_registry.is_connected(connector_name)
if connector_registry
self._connector_registry.is_connected(cname)
if self._connector_registry
else False
)
label = Text(no_wrap=True)
label.append(f" {connector_name:<{max_name}}")
label.append(f" {cname:<{max_name}}")
label.append(f" {type_tag:<{type_width}}", style="dim")
label.append(f" {tool_texts[connector_name]:<{max_tools}}", style="dim")
if connected:
label.append(f" {tool_texts[cname]:<{max_tools}}", style="dim")
if is_disabled:
label.append(" ")
label.append("", style="dim")
label.append(" disabled", style="dim")
elif connected:
label.append(" ")
label.append("", style="green")
label.append(" connected", style="dim")
@ -284,7 +472,7 @@ class MCPApp(Container):
label.append(" ")
label.append("", style="dim")
label.append(" not connected", style="dim")
option_list.add_option(Option(label, id=f"connector:{connector_name}"))
option_list.add_option(Option(label, id=f"connector:{cname}"))
# ── detail view ──────────────────────────────────────────────────
@ -294,43 +482,51 @@ class MCPApp(Container):
option_list: OptionList,
index: MCPToolIndex,
*,
kind: str = "server",
kind: MCPSourceKind = MCPSourceKind.SERVER,
) -> None:
self._viewing_server = server_name
self._viewing_kind = kind
is_connector = kind == "connector"
is_connector = kind == MCPSourceKind.CONNECTOR
title_prefix = "Connector" if is_connector else "MCP Server"
self.query_one("#mcp-title", NoMarkupStatic).update(
f"{title_prefix}: {server_name}"
)
self._set_help_text("↑↓ Navigate Backspace Back R Refresh Esc Close")
self._set_help_text(_DETAIL_VIEW_HELP)
tools_source = index.connector_tools if is_connector else index.server_tools
all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0])
visible_tools = [(n, c) for n, c in all_tools if n in index.enabled_tools]
if not visible_tools:
if not all_tools:
option_list.add_option(
Option("No enabled tools for this server", disabled=True)
Option("No tools discovered for this server", disabled=True)
)
return
for tool_name, cls in visible_tools:
for tool_name, cls in all_tools:
is_tool_enabled = tool_name in index.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}")
if is_tool_enabled:
label.append(remote_name, style="bold")
if raw_desc:
label.append(f" - {raw_desc}")
else:
label.append(remote_name, style="dim")
if raw_desc:
label.append(f" - {raw_desc}", style="dim")
label.append(" (disabled)", style="dim italic")
option_list.add_option(Option(label, id=f"tool:{tool_name}"))
if visible_tools:
option_list.highlighted = 0
option_list.highlighted = 0
def _tool_count_text(count: int) -> str:
if count == 0:
def _tool_count_text(enabled: int, total: int | None = None) -> str:
if total is not None and enabled < total:
noun = "tool" if total == 1 else "tools"
return f"{enabled}/{total} {noun}"
if enabled == 0:
return "no tools"
noun = "tool" if count == 1 else "tools"
return f"{count} {noun}"
noun = "tool" if enabled == 1 else "tools"
return f"{enabled} {noun}"
def _sort_connector_names_for_menu(

View file

@ -2,6 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from vibe.core.hooks.models import HookMessageSeverity
if TYPE_CHECKING:
from vibe.cli.textual_ui.app import ChatScroll
@ -139,6 +141,9 @@ class StreamingMessageBase(Static):
def _should_write_content(self) -> bool:
return True
def get_content(self) -> str:
return self._content
def is_stripped_content_empty(self) -> bool:
return self._content.strip() == ""
@ -298,6 +303,48 @@ class ErrorMessage(Static):
pass
class HookRunContainer(Vertical):
def __init__(self) -> None:
super().__init__(classes="hook-run-container")
self.display = False
async def add_message(self, widget: HookSystemMessageLine) -> None:
await self.mount(widget)
self.display = True
_HOOK_SEVERITY_ICONS: dict[HookMessageSeverity, str] = {
HookMessageSeverity.OK: "",
HookMessageSeverity.WARNING: "",
HookMessageSeverity.ERROR: "",
}
class HookSystemMessageLine(Static):
def __init__(
self,
hook_name: str,
content: str,
severity: HookMessageSeverity = HookMessageSeverity.WARNING,
) -> None:
super().__init__()
self.add_class("hook-system-message")
self.add_class(f"hook-severity-{severity}")
self._hook_name = hook_name
self._content = content
self._severity = severity
def compose(self) -> ComposeResult:
icon = _HOOK_SEVERITY_ICONS.get(
self._severity, _HOOK_SEVERITY_ICONS[HookMessageSeverity.WARNING]
)
with Horizontal(classes="hook-system-container"):
yield NonSelectableStatic(icon, classes="hook-system-icon")
yield NoMarkupStatic(
f"[{self._hook_name}] {self._content}", classes="hook-system-content"
)
class WarningMessage(Static):
def __init__(self, message: str, show_border: bool = True) -> None:
super().__init__()

View file

@ -50,12 +50,12 @@ class NarratorStatus(NarratorManagerListener, Static):
case NarratorState.SUMMARIZING:
char = SHRINK_FRAMES[self._frame % len(SHRINK_FRAMES)]
self.update(
f"[bold orange]{char}[/bold orange] summarizing [dim]esc to stop[/dim]"
f"[bold orange]{char}[/bold orange] summarizing [dim]Esc/Ctrl+C to stop[/dim]"
)
case NarratorState.SPEAKING:
bars = BAR_FRAMES[self._frame % len(BAR_FRAMES)]
self.update(
f"[bold orange]{bars}[/bold orange] speaking [dim]esc to stop[/dim]"
f"[bold orange]{bars}[/bold orange] speaking [dim]Esc/Ctrl+C to stop[/dim]"
)
self._frame += 1

View file

@ -23,6 +23,9 @@ class PathDisplay(NoMarkupStatic):
self.update(path_str)
def refresh_display(self) -> None:
self._update_display()
def set_path(self, path: Path | str) -> None:
self._path = Path(path)
self._update_display()

View file

@ -15,7 +15,7 @@ class TeleportMessage(StatusMessage):
if self._error:
return f"Teleport failed: {self._error}"
if self._final_url:
return f"Teleported to a new async coding session: {self._final_url}"
return f"Teleported to Vibe Code: {self._final_url}"
return self._status
def set_status(self, status: str) -> None:

View file

@ -0,0 +1,79 @@
from __future__ import annotations
from typing import Any, ClassVar, cast
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
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.config._settings import ThinkingLevel
def _build_option_text(level: str, is_current: bool) -> Text:
text = Text(no_wrap=True)
marker = " " if is_current else " "
style = "bold" if is_current else ""
text.append(marker, style="green" if is_current else "")
text.append(level.capitalize(), style=style)
return text
class ThinkingPickerApp(Container):
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "cancel", "Cancel", show=False)
]
class ThinkingSelected(Message):
level: ThinkingLevel
def __init__(self, level: ThinkingLevel) -> None:
self.level = level
super().__init__()
class Cancelled(Message):
pass
def __init__(
self, thinking_levels: list[str], current_thinking: str, **kwargs: Any
) -> None:
super().__init__(id="thinkingpicker-app", **kwargs)
self._thinking_levels = thinking_levels
self._current_thinking = current_thinking
def compose(self) -> ComposeResult:
options = [
Option(_build_option_text(level, level == self._current_thinking), id=level)
for level in self._thinking_levels
]
with Vertical(id="thinkingpicker-content"):
yield NoMarkupStatic(
"Select Thinking Level", classes="thinkingpicker-title"
)
yield OptionList(*options, id="thinkingpicker-options")
yield NoMarkupStatic(
"↑↓ Navigate Enter Select Esc Cancel", classes="thinkingpicker-help"
)
def on_mount(self) -> None:
option_list = self.query_one(OptionList)
for i, level in enumerate(self._thinking_levels):
if level == self._current_thinking:
option_list.highlighted = i
break
option_list.focus()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
if event.option.id:
self.post_message(
self.ThinkingSelected(cast(ThinkingLevel, event.option.id))
)
def action_cancel(self) -> None:
self.post_message(self.Cancelled())

View file

@ -10,6 +10,7 @@ from vibe.core.types import BaseEvent
class TurnSummaryData(BaseModel):
user_message: str
message_id: str | None = None
assistant_fragments: list[str] = Field(default_factory=list)
error: str | None = None

View file

@ -13,7 +13,18 @@ from vibe.core.config import ModelConfig
from vibe.core.llm.types import BackendLike
from vibe.core.logger import logger
from vibe.core.prompts import UtilityPrompt
from vibe.core.types import AssistantEvent, BaseEvent, LLMMessage, Role
from vibe.core.telemetry.build_metadata import build_request_metadata
from vibe.core.types import (
AssistantEvent,
BaseEvent,
LLMMessage,
Role,
UserMessageEvent,
)
def _empty_session_metadata() -> dict[str, str]:
return {}
class TurnSummaryTracker(TurnSummaryPort):
@ -23,11 +34,17 @@ class TurnSummaryTracker(TurnSummaryPort):
model: ModelConfig,
on_summary: Callable[[TurnSummaryResult], None] | None = None,
max_tokens: int = 512,
session_metadata_getter: Callable[[], dict[str, str]] | None = None,
) -> None:
self._backend = backend
self._model = model
self._on_summary = on_summary
self._max_tokens = max_tokens
self._session_metadata_getter: Callable[[], dict[str, str]] = (
_empty_session_metadata
if session_metadata_getter is None
else session_metadata_getter
)
self._tasks: set[asyncio.Task[Any]] = set()
self._data: TurnSummaryData | None = None
self._generation: int = 0
@ -52,6 +69,8 @@ class TurnSummaryTracker(TurnSummaryPort):
if self._data is None:
return
match event:
case UserMessageEvent(message_id=message_id):
self._data.message_id = message_id
case AssistantEvent(content=c) if c:
self._data.assistant_fragments.append(c)
@ -78,6 +97,15 @@ class TurnSummaryTracker(TurnSummaryPort):
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks.clear()
def _build_metadata(self, data: TurnSummaryData) -> dict[str, str]:
default_metadata = build_request_metadata(
entrypoint_metadata=None,
session_id=None,
call_type="secondary_call",
message_id=data.message_id,
).model_dump(exclude_none=True)
return default_metadata | self._session_metadata_getter()
async def _generate_summary(self, data: TurnSummaryData, gen: int) -> None:
try:
prompt_text = UtilityPrompt.TURN_SUMMARY.read()
@ -107,7 +135,7 @@ class TurnSummaryTracker(TurnSummaryPort):
tool_choice=None,
max_tokens=self._max_tokens,
extra_headers={},
metadata={},
metadata=self._build_metadata(data),
)
summary = result.message.content or ""

View file

@ -4,32 +4,61 @@ import asyncio
import json
from pathlib import Path
from vibe.cli.cache import read_cache, write_cache
from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
from vibe.core.paths import VIBE_HOME
_CACHE_SECTION = "update_cache"
class FileSystemUpdateCacheRepository(UpdateCacheRepository):
def __init__(self, base_path: Path | str | None = None) -> None:
self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
self._cache_file = self._base_path / "update_cache.json"
self._cache_file = self._base_path / "cache.toml"
self._legacy_json = self._base_path / "update_cache.json"
async def get(self) -> UpdateCache | None:
try:
content = await asyncio.to_thread(self._cache_file.read_text)
except OSError:
data = await asyncio.to_thread(self._read_section)
if data is None:
return None
return self._parse(data)
async def set(self, update_cache: UpdateCache) -> None:
payload: dict[str, str | int] = {
"latest_version": update_cache.latest_version,
"stored_at_timestamp": update_cache.stored_at_timestamp,
}
if update_cache.seen_whats_new_version is not None:
payload["seen_whats_new_version"] = update_cache.seen_whats_new_version
await asyncio.to_thread(write_cache, self._cache_file, _CACHE_SECTION, payload)
def _read_section(self) -> dict | None:
cache = read_cache(self._cache_file)
if section := cache.get(_CACHE_SECTION):
return section
try:
data = json.loads(content)
latest_version = data.get("latest_version")
stored_at_timestamp = data.get("stored_at_timestamp")
seen_whats_new_version = data.get("seen_whats_new_version")
except (TypeError, json.JSONDecodeError):
data = json.loads(self._legacy_json.read_text())
except (OSError, json.JSONDecodeError):
return None
if isinstance(data, dict):
write_cache(
self._cache_file,
_CACHE_SECTION,
{k: v for k, v in data.items() if v is not None},
)
return data
@staticmethod
def _parse(data: dict) -> UpdateCache | None:
latest_version = data.get("latest_version")
stored_at_timestamp = data.get("stored_at_timestamp")
seen_whats_new_version = data.get("seen_whats_new_version")
if not isinstance(latest_version, str) or not isinstance(
stored_at_timestamp, int
):
@ -46,14 +75,3 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
stored_at_timestamp=stored_at_timestamp,
seen_whats_new_version=seen_whats_new_version,
)
async def set(self, update_cache: UpdateCache) -> None:
try:
payload = json.dumps({
"latest_version": update_cache.latest_version,
"stored_at_timestamp": update_cache.stored_at_timestamp,
"seen_whats_new_version": update_cache.seen_whats_new_version,
})
await asyncio.to_thread(self._cache_file.write_text, payload)
except OSError:
return None