v2.9.0 (#641)
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:
parent
a83c81ecf5
commit
632ea8c032
253 changed files with 13965 additions and 2525 deletions
|
|
@ -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
32
vibe/cli/cache.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
101
vibe/cli/stderr_guard.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 _:
|
||||
|
|
|
|||
61
vibe/cli/textual_ui/quit_manager.py
Normal file
61
vibe/cli/textual_ui/quit_manager.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
49
vibe/cli/textual_ui/widgets/feedback_bar_manager.py
Normal file
49
vibe/cli/textual_ui/widgets/feedback_bar_manager.py
Normal 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())}
|
||||
)
|
||||
|
|
@ -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)"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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__()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
79
vibe/cli/textual_ui/widgets/thinking_picker.py
Normal file
79
vibe/cli/textual_ui/widgets/thinking_picker.py
Normal 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())
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue