Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Clément Siriex <clement.sirieix@mistral.ai>
Co-Authored-By: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-Authored-By: Thaddee Tyl <thaddee.tyl@gmail.com>
Co-Authored-By: David Brochart <david.brochart@gmail.com>
Co-Authored-By: Joseph Guhlin <joseph.guhlin@gmail.com>
Co-Authored-By: Thomas Kenbeek <thomaskenbeek@gmail.com>
Co-Authored-By: Remenby31 <baptiste.cruvellier31@gmail.com>
This commit is contained in:
Mathias Gesbert 2026-01-27 16:39:30 +01:00 committed by Mathias Gesbert
parent 79f215d91c
commit d33db9fff8
217 changed files with 16911 additions and 4305 deletions

View file

@ -6,29 +6,26 @@ import sys
from rich import print as rprint
from vibe.cli.textual_ui.app import run_textual_ui
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import (
MissingAPIKeyError,
MissingPromptFileError,
VibeConfig,
load_api_keys_from_env,
)
from vibe.core.interaction_logger import InteractionLogger
from vibe.core.modes import AgentMode
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
from vibe.core.paths.config_paths import CONFIG_FILE, HISTORY_FILE
from vibe.core.programmatic import run_programmatic
from vibe.core.types import LLMMessage, OutputFormat
from vibe.core.utils import ConversationLimitException
from vibe.core.session.session_loader import SessionLoader
from vibe.core.types import LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException, logger
from vibe.setup.onboarding import run_onboarding
def get_initial_mode(args: argparse.Namespace) -> AgentMode:
if args.plan:
return AgentMode.PLAN
if args.auto_approve:
return AgentMode.AUTO_APPROVE
if args.prompt is not None:
return AgentMode.AUTO_APPROVE
return AgentMode.DEFAULT
def get_initial_agent_name(args: argparse.Namespace) -> str:
if args.prompt is not None and args.agent == BuiltinAgentName.DEFAULT:
return BuiltinAgentName.AUTO_APPROVE
return args.agent
def get_prompt_from_stdin() -> str | None:
@ -46,14 +43,12 @@ def get_prompt_from_stdin() -> str | None:
return None
def load_config_or_exit(
agent: str | None = None, mode: AgentMode = AgentMode.DEFAULT
) -> VibeConfig:
def load_config_or_exit() -> VibeConfig:
try:
return VibeConfig.load(agent, **mode.config_overrides)
return VibeConfig.load()
except MissingAPIKeyError:
run_onboarding()
return VibeConfig.load(agent, **mode.config_overrides)
return VibeConfig.load()
except MissingPromptFileError as e:
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
sys.exit(1)
@ -69,13 +64,6 @@ def bootstrap_config_files() -> None:
except Exception as e:
rprint(f"[yellow]Could not create default config file: {e}[/]")
if not INSTRUCTIONS_FILE.path.exists():
try:
INSTRUCTIONS_FILE.path.parent.mkdir(parents=True, exist_ok=True)
INSTRUCTIONS_FILE.path.touch()
except Exception as e:
rprint(f"[yellow]Could not create instructions file: {e}[/]")
if not HISTORY_FILE.path.exists():
try:
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
@ -99,7 +87,7 @@ def load_session(
session_to_load = None
if args.continue_session:
session_to_load = InteractionLogger.find_latest_session(config.session_logging)
session_to_load = SessionLoader.find_latest_session(config.session_logging)
if not session_to_load:
rprint(
f"[red]No previous sessions found in "
@ -107,7 +95,7 @@ def load_session(
)
sys.exit(1)
else:
session_to_load = InteractionLogger.find_session_by_id(
session_to_load = SessionLoader.find_session_by_id(
args.resume, config.session_logging
)
if not session_to_load:
@ -118,25 +106,32 @@ def load_session(
sys.exit(1)
try:
loaded_messages, _ = InteractionLogger.load_session(session_to_load)
loaded_messages, _ = SessionLoader.load_session(session_to_load)
return loaded_messages
except Exception as e:
rprint(f"[red]Failed to load session: {e}[/]")
sys.exit(1)
def _load_messages_from_previous_session(
agent_loop: AgentLoop, loaded_messages: list[LLMMessage]
) -> None:
non_system_messages = [msg for msg in loaded_messages if msg.role != Role.system]
agent_loop.messages.extend(non_system_messages)
logger.info("Loaded %d messages from previous session", len(non_system_messages))
def run_cli(args: argparse.Namespace) -> None:
load_api_keys_from_env()
bootstrap_config_files()
if args.setup:
run_onboarding()
sys.exit(0)
try:
bootstrap_config_files()
initial_mode = get_initial_mode(args)
config = load_config_or_exit(args.agent, initial_mode)
initial_agent_name = get_initial_agent_name(args)
config = load_config_or_exit()
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
@ -163,7 +158,7 @@ def run_cli(args: argparse.Namespace) -> None:
max_price=args.max_price,
output_format=output_format,
previous_messages=loaded_messages,
mode=initial_mode,
agent_name=initial_agent_name,
)
if final_response:
print(final_response)
@ -175,12 +170,16 @@ def run_cli(args: argparse.Namespace) -> None:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
agent_loop = AgentLoop(
config, agent_name=initial_agent_name, enable_streaming=True
)
if loaded_messages:
_load_messages_from_previous_session(agent_loop, loaded_messages)
run_textual_ui(
config,
initial_mode=initial_mode,
enable_streaming=True,
agent_loop=agent_loop,
initial_prompt=args.initial_prompt or stdin_prompt,
loaded_messages=loaded_messages,
)
except (KeyboardInterrupt, EOFError):

View file

@ -100,6 +100,7 @@ def copy_selection_to_clipboard(app: App) -> None:
f'"{_shorten_preview(selected_texts)}" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
else:
app.notify(

View file

@ -84,6 +84,7 @@ class CommandRegistry:
"- `Ctrl+J` / `Shift+Enter` Insert newline",
"- `Escape` Interrupt agent or close dialogs",
"- `Ctrl+C` Quit (or clear input if text present)",
"- `Ctrl+G` Edit input in external editor",
"- `Ctrl+O` Toggle tool output view",
"- `Ctrl+T` Toggle todo view",
"- `Shift+Tab` Toggle auto-approve mode",

View file

@ -1,12 +1,14 @@
from __future__ import annotations
import argparse
import os
from pathlib import Path
import sys
from rich import print as rprint
from vibe import __version__
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.paths.config_paths import unlock_config_paths
from vibe.core.trusted_folders import has_trustable_content, trusted_folders_manager
from vibe.setup.trusted_folders.trust_folder_dialog import (
@ -35,18 +37,6 @@ def parse_arguments() -> argparse.Namespace:
help="Run in programmatic mode: send prompt, auto-approve all tools, "
"output response, and exit.",
)
parser.add_argument(
"--auto-approve",
action="store_true",
default=False,
help="Start in auto-approve mode: never ask for approval before running tools.",
)
parser.add_argument(
"--plan",
action="store_true",
default=False,
help="Start in plan mode: read-only tools for exploration and planning.",
)
parser.add_argument(
"--max-turns",
type=int,
@ -82,10 +72,17 @@ def parse_arguments() -> argparse.Namespace:
parser.add_argument(
"--agent",
metavar="NAME",
default=None,
help="Load agent configuration from ~/.vibe/agents/NAME.toml",
default=BuiltinAgentName.DEFAULT,
help="Agent to use (builtin: default, plan, accept-edits, auto-approve, "
"or custom from ~/.vibe/agents/NAME.toml)",
)
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
parser.add_argument(
"--workdir",
type=Path,
metavar="DIR",
help="Change to this directory before running",
)
continuation_group = parser.add_mutually_exclusive_group()
continuation_group.add_argument(
@ -104,7 +101,17 @@ def parse_arguments() -> argparse.Namespace:
def check_and_resolve_trusted_folder() -> None:
cwd = Path.cwd()
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 not has_trustable_content(cwd) or cwd.resolve() == Path.home().resolve():
return
@ -130,6 +137,15 @@ def check_and_resolve_trusted_folder() -> None:
def main() -> None:
args = parse_arguments()
if args.workdir:
workdir = args.workdir.expanduser().resolve()
if not workdir.is_dir():
rprint(
f"[red]Error: --workdir does not exist or is not a directory: {workdir}[/]"
)
sys.exit(1)
os.chdir(workdir)
is_interactive = args.prompt is None
if is_interactive:
check_and_resolve_trusted_folder()

View file

@ -38,7 +38,7 @@ class HistoryManager:
self.history_file.parent.mkdir(parents=True, exist_ok=True)
with self.history_file.open("w", encoding="utf-8") as f:
for entry in self._entries:
f.write(json.dumps(entry) + "\n")
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except OSError:
pass

View file

@ -0,0 +1,67 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import cast
import httpx
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayError,
WhoAmIGatewayUnauthorized,
WhoAmIResponse,
)
BASE_URL = "https://console.mistral.ai"
WHOAMI_PATH = "/api/vibe/whoami"
class HttpWhoAmIGateway:
def __init__(self, base_url: str = BASE_URL) -> None:
self._base_url = base_url.rstrip("/")
async def whoami(self, api_key: str) -> WhoAmIResponse:
url = f"{self._base_url}{WHOAMI_PATH}"
headers = {"Authorization": f"Bearer {api_key}"}
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers)
except httpx.RequestError as exc:
raise WhoAmIGatewayError() from exc
if response.status_code in {httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN}:
raise WhoAmIGatewayUnauthorized()
if not response.is_success:
raise WhoAmIGatewayError(f"Unexpected status {response.status_code}")
payload = _safe_json(response) or {}
return WhoAmIResponse(
is_pro_plan=_parse_bool(payload.get("is_pro_plan")),
advertise_pro_plan=_parse_bool(payload.get("advertise_pro_plan")),
prompt_switching_to_pro_plan=_parse_bool(
payload.get("prompt_switching_to_pro_plan")
),
)
def _safe_json(response: httpx.Response) -> Mapping[str, object] | None:
try:
data = response.json()
except ValueError:
return None
return cast(Mapping[str, object], data) if isinstance(data, dict) else None
def _parse_bool(value: object | None) -> bool:
if value is None:
return False
if isinstance(value, bool):
return value
if isinstance(value, str):
match value.strip().lower():
case "true":
return True
case "false":
return False
case _:
raise WhoAmIGatewayError("Invalid boolean string in whoami response")
raise WhoAmIGatewayError("Invalid boolean value in whoami response")

View file

@ -0,0 +1,56 @@
from __future__ import annotations
from enum import StrEnum
import logging
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGateway,
WhoAmIGatewayError,
WhoAmIGatewayUnauthorized,
WhoAmIResponse,
)
logger = logging.getLogger(__name__)
CONSOLE_CLI_URL = "https://console.mistral.ai/codestral/cli"
UPGRADE_URL = CONSOLE_CLI_URL
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
class PlanOfferAction(StrEnum):
NONE = "none"
UPGRADE = "upgrade"
SWITCH_TO_PRO_KEY = "switch_to_pro_key"
ACTION_TO_URL: dict[PlanOfferAction, str] = {
PlanOfferAction.UPGRADE: UPGRADE_URL,
PlanOfferAction.SWITCH_TO_PRO_KEY: SWITCH_TO_PRO_KEY_URL,
}
async def decide_plan_offer(
api_key: str | None, gateway: WhoAmIGateway
) -> PlanOfferAction:
if not api_key:
return PlanOfferAction.UPGRADE
try:
response = await gateway.whoami(api_key)
except WhoAmIGatewayUnauthorized:
return PlanOfferAction.UPGRADE
except WhoAmIGatewayError:
logger.warning("Failed to fetch plan status.", exc_info=True)
return PlanOfferAction.NONE
return _action_from_response(response)
def _action_from_response(response: WhoAmIResponse) -> PlanOfferAction:
match response:
case WhoAmIResponse(is_pro_plan=True):
return PlanOfferAction.NONE
case WhoAmIResponse(prompt_switching_to_pro_plan=True):
return PlanOfferAction.SWITCH_TO_PRO_KEY
case WhoAmIResponse(advertise_pro_plan=True):
return PlanOfferAction.UPGRADE
case _:
return PlanOfferAction.NONE

View file

@ -0,0 +1,23 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True, slots=True)
class WhoAmIResponse:
is_pro_plan: bool
advertise_pro_plan: bool
prompt_switching_to_pro_plan: bool
class WhoAmIGatewayUnauthorized(Exception):
pass
class WhoAmIGatewayError(Exception):
pass
class WhoAmIGateway(Protocol):
async def whoami(self, api_key: str) -> WhoAmIResponse: ...

View file

@ -7,11 +7,12 @@ import os
from pathlib import Path
import platform
import subprocess
from typing import Any
from typing import Any, Literal
class Terminal(Enum):
VSCODE = "vscode"
VSCODE_INSIDERS = "vscode_insiders"
CURSOR = "cursor"
ITERM2 = "iterm2"
WEZTERM = "wezterm"
@ -41,13 +42,21 @@ def _is_cursor() -> bool:
return False
def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDERS]:
term_version = os.environ.get("TERM_PROGRAM_VERSION", "").lower()
if term_version.endswith("-insider"):
return Terminal.VSCODE_INSIDERS
return Terminal.VSCODE
def detect_terminal() -> Terminal:
term_program = os.environ.get("TERM_PROGRAM", "").lower()
if term_program == "vscode":
if _is_cursor():
return Terminal.CURSOR
return Terminal.VSCODE
return _detect_vscode_terminal()
term_map = {
"iterm.app": Terminal.ITERM2,
@ -65,17 +74,19 @@ def detect_terminal() -> Terminal:
return Terminal.UNKNOWN
def _get_vscode_keybindings_path() -> Path | None:
def _get_vscode_keybindings_path(is_stable: bool) -> Path | None:
system = platform.system()
app_name = "Code" if is_stable else "Code - Insiders"
if system == "Darwin":
base = Path.home() / "Library" / "Application Support" / "Code" / "User"
base = Path.home() / "Library" / "Application Support" / app_name / "User"
elif system == "Linux":
base = Path.home() / ".config" / "Code" / "User"
base = Path.home() / ".config" / app_name / "User"
elif system == "Windows":
appdata = os.environ.get("APPDATA", "")
if appdata:
base = Path(appdata) / "Code" / "User"
base = Path(appdata) / app_name / "User"
else:
return None
else:
@ -118,13 +129,13 @@ def _parse_keybindings(content: str) -> list[dict[str, Any]]:
def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
"""Setup keybindings for VSCode or Cursor."""
"""Setup keybindings for VS Code or Cursor."""
if terminal == Terminal.CURSOR:
keybindings_path = _get_cursor_keybindings_path()
editor_name = "Cursor"
else:
keybindings_path = _get_vscode_keybindings_path()
editor_name = "VSCode"
keybindings_path = _get_vscode_keybindings_path(terminal == Terminal.VSCODE)
editor_name = "VS Code" if terminal == Terminal.VSCODE else "VS Code Insiders"
if keybindings_path is None:
return SetupResult(
@ -151,7 +162,9 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
)
keybindings.append(new_binding)
keybindings_path.write_text(json.dumps(keybindings, indent=2) + "\n")
keybindings_path.write_text(
json.dumps(keybindings, indent=2, ensure_ascii=False) + "\n"
)
return SetupResult(
success=True,
@ -376,10 +389,8 @@ def setup_terminal() -> SetupResult:
terminal = detect_terminal()
match terminal:
case Terminal.VSCODE:
return _setup_vscode_like_terminal(Terminal.VSCODE)
case Terminal.CURSOR:
return _setup_vscode_like_terminal(Terminal.CURSOR)
case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR:
return _setup_vscode_like_terminal(terminal)
case Terminal.ITERM2:
return _setup_iterm2()
case Terminal.WEZTERM:
@ -391,7 +402,7 @@ def setup_terminal() -> SetupResult:
success=False,
terminal=Terminal.UNKNOWN,
message="Could not detect terminal. Supported terminals:\n"
"- VSCode\n"
"- VS Code\n"
"- Cursor\n"
"- iTerm2\n"
"- WezTerm\n"

File diff suppressed because it is too large Load diff

View file

@ -133,7 +133,7 @@ Screen {
#input {
width: 1fr;
height: auto;
max-height: 16;
max-height: 50vh;
background: transparent;
color: $text;
border: none;
@ -523,6 +523,18 @@ StatusMessage {
margin-top: 1;
}
.tool-call-container {
width: 100%;
height: auto;
}
.tool-stream-message {
width: 100%;
height: auto;
color: $text-muted;
padding-left: 2;
}
.tool-result {
width: 100%;
height: auto;
@ -852,6 +864,81 @@ WelcomeBanner {
color: $foreground;
}
#question-app {
width: 100%;
height: auto;
max-height: 16;
background: transparent;
border: round $foreground-muted;
padding: 0 1;
margin: 0 0 1 0;
}
#question-content {
width: 100%;
height: auto;
}
.question-tabs {
height: auto;
color: $primary;
margin-bottom: 1;
}
.question-title {
height: auto;
text-style: bold;
color: $primary;
margin-bottom: 1;
}
.question-option {
height: auto;
color: $foreground;
}
.question-option-selected {
color: $primary;
text-style: bold;
}
.question-other-row {
width: 100%;
height: auto;
}
.question-other-prefix {
width: auto;
height: auto;
color: $foreground;
}
.question-other-input {
width: 1fr;
height: auto;
background: transparent;
border: none;
padding: 0;
}
.question-other-static {
width: auto;
height: auto;
color: $foreground;
}
.question-submit {
height: auto;
color: $foreground-muted;
margin-top: 1;
}
.question-help {
height: auto;
color: $foreground-muted;
margin-top: 1;
}
.code-block {
height: auto;
color: $foreground;
@ -859,6 +946,28 @@ WelcomeBanner {
padding: 1;
}
.whats-new-message {
background: $surface;
border-left: solid $warning;
}
.plan-offer-message {
background: $surface;
border-left: solid $warning;
height: auto;
text-align: center;
content-align: center middle;
padding: 1 2;
margin-top: 1;
Markdown > *:last-child {
margin-bottom: 0;
link-style: bold underline;
link-background-hover: transparent;
link-color-hover: $warning;
}
}
Horizontal {
width: 100%;
height: auto;
@ -870,7 +979,7 @@ ExpandingBorder {
content-align: left bottom;
}
ModeIndicator {
AgentIndicator {
width: auto;
height: auto;
background: transparent;
@ -879,19 +988,19 @@ ModeIndicator {
color: $text-muted;
align: left middle;
&.mode-safe {
&.agent-safe {
color: $success;
}
&.mode-neutral {
&.agent-neutral {
color: $text-muted;
}
&.mode-destructive {
&.agent-destructive {
color: $warning;
}
&.mode-yolo {
&.agent-yolo {
color: $error;
}
}

View file

@ -0,0 +1,32 @@
from __future__ import annotations
import os
from pathlib import Path
import shlex
import subprocess
import tempfile
class ExternalEditor:
"""Handles opening an external editor to edit prompt content."""
@staticmethod
def get_editor() -> str:
return os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
def edit(self, initial_content: str = "") -> str | None:
editor = self.get_editor()
fd, filepath = tempfile.mkstemp(suffix=".md", prefix="vibe_")
try:
with os.fdopen(fd, "w") as f:
f.write(initial_content)
parts = shlex.split(editor)
subprocess.run([*parts, filepath], check=True)
content = Path(filepath).read_text().rstrip()
return content if content != initial_content else None
except (OSError, subprocess.CalledProcessError):
return
finally:
Path(filepath).unlink(missing_ok=True)

View file

@ -15,6 +15,8 @@ from vibe.core.types import (
ReasoningEvent,
ToolCallEvent,
ToolResultEvent,
ToolStreamEvent,
UserMessageEvent,
)
from vibe.core.utils import TaggedText
@ -51,6 +53,8 @@ class EventHandler:
case ToolResultEvent():
sanitized_event = self._sanitize_event(event)
await self._handle_tool_result(sanitized_event)
case ToolStreamEvent():
await self._handle_tool_stream(event)
case ReasoningEvent():
await self._handle_reasoning_message(event)
case AssistantEvent():
@ -59,6 +63,8 @@ class EventHandler:
await self._handle_compact_start()
case CompactEndEvent():
await self._handle_compact_end(event)
case UserMessageEvent():
pass
case _:
await self._handle_unknown_event(event)
return None
@ -119,6 +125,10 @@ class EventHandler:
self.current_tool_call = None
async def _handle_tool_stream(self, event: ToolStreamEvent) -> None:
if self.current_tool_call:
self.current_tool_call.set_stream_message(event.message)
async def _handle_assistant_message(self, event: AssistantEvent) -> None:
await self.mount_callback(AssistantMessage(event.content))

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from textual.widgets import Static
from vibe.core.agents import AgentProfile, AgentSafety, BuiltinAgentName
AGENT_ICONS: dict[str, str] = {
BuiltinAgentName.DEFAULT: "",
BuiltinAgentName.PLAN: "",
BuiltinAgentName.ACCEPT_EDITS: "⏵⏵",
BuiltinAgentName.AUTO_APPROVE: "⏵⏵⏵",
}
SAFETY_CLASSES: dict[AgentSafety, str] = {
AgentSafety.SAFE: "agent-safe",
AgentSafety.NEUTRAL: "agent-neutral",
AgentSafety.DESTRUCTIVE: "agent-destructive",
AgentSafety.YOLO: "agent-yolo",
}
class AgentIndicator(Static):
def __init__(self, profile: AgentProfile) -> None:
super().__init__(markup=False)
self.can_focus = False
self.profile = profile
self._update_display()
def _update_display(self) -> None:
icon = AGENT_ICONS.get(self.profile.name, "")
name = self.profile.display_name.lower()
self.update(f"{icon}{' ' if icon else ''}{name} agent (shift+tab to cycle)")
for safety_class in SAFETY_CLASSES.values():
self.remove_class(safety_class)
self.add_class(SAFETY_CLASSES[self.profile.safety])
def set_profile(self, profile: AgentProfile) -> None:
self.profile = profile
self._update_display()

View file

@ -52,12 +52,11 @@ class ApprovalApp(Container):
self.tool_args = tool_args
def __init__(
self, tool_name: str, tool_args: BaseModel, workdir: str, config: VibeConfig
self, tool_name: str, tool_args: BaseModel, config: VibeConfig
) -> None:
super().__init__(id="approval-app")
self.tool_name = tool_name
self.tool_args = tool_args
self.workdir = workdir
self.config = config
self.selected_option = 0
self.content_container: Vertical | None = None

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import Any
@ -16,13 +17,13 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
)
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.core.agents import AgentSafety
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
from vibe.core.modes import ModeSafety
SAFETY_BORDER_CLASSES: dict[ModeSafety, str] = {
ModeSafety.SAFE: "border-safe",
ModeSafety.DESTRUCTIVE: "border-warning",
ModeSafety.YOLO: "border-error",
SAFETY_BORDER_CLASSES: dict[AgentSafety, str] = {
AgentSafety.SAFE: "border-safe",
AgentSafety.DESTRUCTIVE: "border-warning",
AgentSafety.YOLO: "border-error",
}
@ -38,27 +39,33 @@ class ChatInputContainer(Vertical):
self,
history_file: Path | None = None,
command_registry: CommandRegistry | None = None,
safety: ModeSafety = ModeSafety.NEUTRAL,
safety: AgentSafety = AgentSafety.NEUTRAL,
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._history_file = history_file
self._command_registry = command_registry or CommandRegistry()
self._safety = safety
command_entries = [
(alias, command.description)
for command in self._command_registry.commands.values()
for alias in sorted(command.aliases)
]
self._skill_entries_getter = skill_entries_getter
self._completion_manager = MultiCompletionManager([
SlashCommandController(CommandCompleter(command_entries), self),
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
PathCompletionController(PathCompleter(), self),
])
self._completion_popup: CompletionPopup | None = None
self._body: ChatInputBody | None = None
def _get_slash_entries(self) -> list[tuple[str, str]]:
entries = [
(alias, command.description)
for command in self._command_registry.commands.values()
for alias in sorted(command.aliases)
]
if self._skill_entries_getter:
entries.extend(self._skill_entries_getter())
return sorted(entries)
def compose(self) -> ComposeResult:
self._completion_popup = CompletionPopup()
yield self._completion_popup
@ -155,7 +162,7 @@ class ChatInputContainer(Vertical):
event.stop()
self.post_message(self.Submitted(event.value))
def set_safety(self, safety: ModeSafety) -> None:
def set_safety(self, safety: AgentSafety) -> None:
self._safety = safety
try:

View file

@ -8,6 +8,7 @@ from textual.message import Message
from textual.widgets import TextArea
from vibe.cli.autocompletion.base import CompletionResult
from vibe.cli.textual_ui.external_editor import ExternalEditor
from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
@ -23,7 +24,8 @@ class ChatTextArea(TextArea):
"New Line",
show=False,
priority=True,
)
),
Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
]
MODE_CHARACTERS: ClassVar[set[Literal["!", "/"]]] = {"!", "/"}
@ -84,6 +86,17 @@ class ChatTextArea(TextArea):
def action_insert_newline(self) -> None:
self.insert("\n")
def action_open_external_editor(self) -> None:
editor = ExternalEditor()
current_text = self.get_full_text()
with self.app.suspend():
result = editor.edit(current_text)
if result is not None:
self.clear()
self.insert(result)
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if not self._navigating_history and self.text != self._last_text:
self._reset_prefix()

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from textual.message import Message
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.core.utils import compact_reduction_display
class CompactMessage(StatusMessage):
@ -25,17 +26,7 @@ class CompactMessage(StatusMessage):
if self.error_message:
return f"Error: {self.error_message}"
if self.old_tokens is not None and self.new_tokens is not None:
reduction = self.old_tokens - self.new_tokens
reduction_pct = (
(reduction / self.old_tokens * 100) if self.old_tokens > 0 else 0
)
return (
f"Compaction complete: {self.old_tokens:,}"
f"{self.new_tokens:,} tokens (-{reduction_pct:.1f}%)"
)
return "Compaction complete"
return compact_reduction_display(self.old_tokens, self.new_tokens)
def set_complete(
self, old_tokens: int | None = None, new_tokens: int | None = None

View file

@ -26,7 +26,6 @@ class SettingDefinition(TypedDict):
label: str
type: str
options: list[str]
value: str
class ConfigApp(Container):
@ -69,14 +68,12 @@ class ConfigApp(Container):
"label": "Model",
"type": "cycle",
"options": [m.alias for m in self.config.models],
"value": self.config.active_model,
},
{
"key": "textual_theme",
"label": "Theme",
"type": "cycle",
"options": themes,
"value": self.config.textual_theme,
},
]
@ -115,7 +112,9 @@ class ConfigApp(Container):
cursor = " " if is_selected else " "
label: str = setting["label"]
value: str = self.changes.get(setting["key"], setting["value"])
value: str = self.changes.get(
setting["key"], getattr(self.config, setting["key"], "")
)
text = f"{cursor}{label}: {value}"
@ -141,7 +140,7 @@ class ConfigApp(Container):
def action_toggle_setting(self) -> None:
setting = self.settings[self.selected_index]
key: str = setting["key"]
current: str = self.changes.get(key, setting["value"])
current: str = self.changes.get(key, getattr(self.config, key)) or ""
options: list[str] = setting["options"]
new_value = ""

View file

@ -25,8 +25,6 @@ class ContextProgress(NoMarkupStatic):
self.update("")
return
percentage = min(
100, int((new_state.current_tokens / new_state.max_tokens) * 100)
)
text = f"{percentage}% of {new_state.max_tokens // 1000}k tokens"
ratio = min(1, new_state.current_tokens / new_state.max_tokens)
text = f"{ratio:.0%} of {new_state.max_tokens // 1000}k tokens"
self.update(text)

View file

@ -1,5 +1,7 @@
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
import random
from time import time
@ -60,6 +62,8 @@ class LoadingWidget(SpinnerMixin, Static):
self.hint_widget: Static | None = None
self.start_time: float | None = None
self._last_elapsed: int = -1
self._paused_total: float = 0.0
self._pause_start: float | None = None
def _get_easter_egg(self) -> str | None:
EASTER_EGG_PROBABILITY = 0.10
@ -84,6 +88,15 @@ class LoadingWidget(SpinnerMixin, Static):
def _apply_easter_egg(self, status: str) -> str:
return self._get_easter_egg() or status
def pause_timer(self) -> None:
if self._pause_start is None:
self._pause_start = time()
def resume_timer(self) -> None:
if self._pause_start is not None:
self._paused_total += time() - self._pause_start
self._pause_start = None
def set_status(self, status: str) -> None:
self.status = self._apply_easter_egg(status)
self._update_animation()
@ -154,7 +167,21 @@ class LoadingWidget(SpinnerMixin, Static):
self.transition_progress = 0
if self.hint_widget and self.start_time is not None:
elapsed = int(time() - self.start_time)
paused = self._paused_total + (
time() - self._pause_start if self._pause_start else 0
)
elapsed = int(time() - self.start_time - paused)
if elapsed != self._last_elapsed:
self._last_elapsed = elapsed
self.hint_widget.update(f"({elapsed}s esc to interrupt)")
@contextmanager
def paused_timer(loading_widget: LoadingWidget | None) -> Iterator[None]:
if loading_widget:
loading_widget.pause_timer()
try:
yield
finally:
if loading_widget:
loading_widget.resume_timer()

View file

@ -197,6 +197,29 @@ class UserCommandMessage(Static):
yield Markdown(self._content)
class WhatsNewMessage(Static):
def __init__(self, content: str) -> None:
super().__init__()
self.add_class("whats-new-message")
self._content = content
def compose(self) -> ComposeResult:
yield Markdown(self._content)
class PlanOfferMessage(Static):
def __init__(self, text: str) -> None:
super().__init__()
self.add_class("plan-offer-message")
self._text = text
def compose(self) -> ComposeResult:
yield Markdown(self._text)
def get_text(self) -> str:
return self._text
class InterruptMessage(Static):
def __init__(self) -> None:
super().__init__()

View file

@ -1,47 +0,0 @@
from __future__ import annotations
from textual.widgets import Static
from vibe.core.modes import AgentMode, ModeSafety
MODE_ICONS: dict[AgentMode, str] = {
AgentMode.DEFAULT: "",
AgentMode.PLAN: "⏸︎",
AgentMode.ACCEPT_EDITS: "⏵⏵",
AgentMode.AUTO_APPROVE: "⏵⏵⏵",
}
SAFETY_CLASSES: dict[ModeSafety, str] = {
ModeSafety.SAFE: "mode-safe",
ModeSafety.NEUTRAL: "mode-neutral",
ModeSafety.DESTRUCTIVE: "mode-destructive",
ModeSafety.YOLO: "mode-yolo",
}
class ModeIndicator(Static):
"""Displays the current agent mode with safety-colored indicator."""
def __init__(self, mode: AgentMode = AgentMode.DEFAULT) -> None:
super().__init__()
self.can_focus = False
self._mode = mode
self._update_display()
def _update_display(self) -> None:
icon = MODE_ICONS.get(self._mode, "??")
name = self._mode.display_name.lower()
self.update(f"{icon} {name} mode (shift+tab to cycle)")
for safety_class in SAFETY_CLASSES.values():
self.remove_class(safety_class)
self.add_class(SAFETY_CLASSES[self._mode.safety])
@property
def mode(self) -> AgentMode:
return self._mode
def set_mode(self, mode: AgentMode) -> None:
self._mode = mode
self._update_display()

View file

@ -0,0 +1,479 @@
from __future__ import annotations
import itertools
from typing import TYPE_CHECKING, ClassVar
from textual import events
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Horizontal, Vertical
from textual.message import Message
from textual.reactive import reactive
from textual.widgets import Input
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
if TYPE_CHECKING:
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
from vibe.core.tools.builtins.ask_user_question import Answer
class QuestionApp(Container):
MAX_OPTIONS: ClassVar[int] = 4
can_focus = True
can_focus_children = False
current_question_idx: reactive[int] = reactive(0)
selected_option: reactive[int] = reactive(0)
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("enter", "select", "Select", show=False),
Binding("escape", "cancel", "Cancel", show=False),
]
class Answered(Message):
def __init__(self, answers: list[Answer]) -> None:
super().__init__()
self.answers = answers
class Cancelled(Message):
pass
def __init__(self, args: AskUserQuestionArgs) -> None:
super().__init__(id="question-app")
self.args = args
self.questions = args.questions
self.answers: dict[int, tuple[str, bool]] = {}
self.multi_selections: dict[int, set[int]] = {}
self.other_texts: dict[int, str] = {}
self.option_widgets: list[NoMarkupStatic] = []
self.title_widget: NoMarkupStatic | None = None
self.other_prefix: NoMarkupStatic | None = None
self.other_input: Input | None = None
self.other_static: NoMarkupStatic | None = None
self.submit_widget: NoMarkupStatic | None = None
self.help_widget: NoMarkupStatic | None = None
self.tabs_widget: NoMarkupStatic | None = None
@property
def _current_question(self) -> Question:
return self.questions[self.current_question_idx]
@property
def _total_options(self) -> int:
base = len(self._current_question.options) + 1
if self._current_question.multi_select:
return base + 1
return base
@property
def _other_option_idx(self) -> int:
return len(self._current_question.options)
@property
def _submit_option_idx(self) -> int:
if not self._current_question.multi_select:
return -1
return self._other_option_idx + 1
@property
def _is_other_selected(self) -> bool:
return self.selected_option == self._other_option_idx
@property
def _is_submit_selected(self) -> bool:
return (
self._current_question.multi_select
and self.selected_option == self._submit_option_idx
)
def compose(self) -> ComposeResult:
with Vertical(id="question-content"):
if len(self.questions) > 1:
self.tabs_widget = NoMarkupStatic("", classes="question-tabs")
yield self.tabs_widget
self.title_widget = NoMarkupStatic("", classes="question-title")
yield self.title_widget
for _ in range(self.MAX_OPTIONS):
widget = NoMarkupStatic("", classes="question-option")
self.option_widgets.append(widget)
yield widget
with Horizontal(classes="question-other-row"):
self.other_prefix = NoMarkupStatic("", classes="question-other-prefix")
yield self.other_prefix
self.other_input = Input(
placeholder="Type your answer...", classes="question-other-input"
)
yield self.other_input
self.other_static = NoMarkupStatic(
"Type your answer...", classes="question-other-static"
)
yield self.other_static
self.submit_widget = NoMarkupStatic("", classes="question-submit")
yield self.submit_widget
self.help_widget = NoMarkupStatic("", classes="question-help")
yield self.help_widget
async def on_mount(self) -> None:
self._update_display()
self.focus()
def _watch_current_question_idx(self) -> None:
self._update_display()
def _watch_selected_option(self) -> None:
self._update_display()
def _update_display(self) -> None:
self._update_tabs()
self._update_title()
self._update_options()
self._update_other_row()
self._update_submit()
self._update_help()
def _update_tabs(self) -> None:
if not self.tabs_widget or len(self.questions) <= 1:
return
tabs = []
for i, question in enumerate(self.questions):
header = question.header or f"Q{i + 1}"
if i in self.answers:
header += ""
if i == self.current_question_idx:
tabs.append(f"[{header}]")
else:
tabs.append(f" {header} ")
self.tabs_widget.update(" ".join(tabs))
def _update_title(self) -> None:
if self.title_widget:
self.title_widget.update(self._current_question.question)
def _update_options(self) -> None:
q = self._current_question
options = q.options
is_multi = q.multi_select
multi_selected = self.multi_selections.get(self.current_question_idx, set())
for i, widget in enumerate(self.option_widgets):
if i < len(options):
is_focused = i == self.selected_option
is_selected = i in multi_selected
self._render_option(
widget, i, options[i], is_multi, is_focused, is_selected
)
else:
widget.update("")
widget.display = False
def _format_option_prefix(
self, idx: int, is_focused: bool, is_multi: bool, is_selected: bool
) -> str:
"""Format the prefix for an option line (cursor + number + checkbox if multi)."""
cursor = " " if is_focused else " "
if is_multi:
check = "[x]" if is_selected else "[ ]"
return f"{cursor}{idx + 1}. {check} "
return f"{cursor}{idx + 1}. "
def _render_option(
self,
widget: NoMarkupStatic,
idx: int,
opt: Choice,
is_multi: bool,
is_focused: bool,
is_selected: bool,
) -> None:
prefix = self._format_option_prefix(idx, is_focused, is_multi, is_selected)
text = f"{prefix}{opt.label}"
if opt.description:
text += f" - {opt.description}"
widget.update(text)
widget.display = True
widget.remove_class("question-option-selected")
if is_focused:
widget.add_class("question-option-selected")
def _update_other_row(self) -> None:
if not self.other_prefix or not self.other_input or not self.other_static:
return
q = self._current_question
is_multi = q.multi_select
multi_selected = self.multi_selections.get(self.current_question_idx, set())
other_idx = self._other_option_idx
is_focused = self._is_other_selected
is_selected = other_idx in multi_selected
prefix = self._format_option_prefix(
other_idx, is_focused, is_multi, is_selected
)
self.other_prefix.update(prefix)
stored_text = self.other_texts.get(self.current_question_idx, "")
if self.other_input.value != stored_text:
self.other_input.value = stored_text
show_input = is_focused or bool(stored_text)
self.other_input.display = show_input
self.other_static.display = not show_input
self.other_prefix.remove_class("question-option-selected")
if is_focused:
self.other_prefix.add_class("question-option-selected")
if is_focused and show_input:
self.other_input.focus()
elif not is_focused and not self._is_submit_selected:
self.focus()
def _update_submit(self) -> None:
if not self.submit_widget:
return
q = self._current_question
if not q.multi_select:
self.submit_widget.display = False
return
self.submit_widget.display = True
is_focused = self._is_submit_selected
cursor = " " if is_focused else " "
text = (
"Submit"
if len(set(self.answers.keys()) | {self.current_question_idx})
== len(self.questions)
else "Next"
)
self.submit_widget.update(f"{cursor} {text}")
self.submit_widget.remove_class("question-option-selected")
if is_focused:
self.submit_widget.add_class("question-option-selected")
self.focus()
def _update_help(self) -> None:
if not self.help_widget:
return
if self._current_question.multi_select:
help_text = "↑↓ navigate Enter toggle Esc cancel"
else:
help_text = "↑↓ navigate Enter select Esc cancel"
if len(self.questions) > 1:
help_text = "←→ questions " + help_text
self.help_widget.update(help_text)
def _store_other_text(self) -> None:
if self.other_input:
self.other_texts[self.current_question_idx] = self.other_input.value
def _get_other_text(self, idx: int) -> str:
return self.other_texts.get(idx, "")
def action_move_up(self) -> None:
self.selected_option = (self.selected_option - 1) % self._total_options
def action_move_down(self) -> None:
self.selected_option = (self.selected_option + 1) % self._total_options
def _switch_question(self, new_idx: int) -> None:
self.current_question_idx = new_idx
self.selected_option = 0
def action_next_question(self) -> None:
if self._is_other_selected:
other_text = self.other_texts.get(self.current_question_idx, "").strip()
if not other_text:
return
new_idx = (self.current_question_idx + 1) % len(self.questions)
self._switch_question(new_idx)
def action_prev_question(self) -> None:
new_idx = (self.current_question_idx - 1) % len(self.questions)
self._switch_question(new_idx)
def action_select(self) -> None:
if self._current_question.multi_select:
self._handle_multi_select_action()
else:
self._handle_single_select_action()
def _handle_multi_select_action(self) -> None:
"""Handle Enter key in multi-select mode: toggle option or submit."""
if self._is_submit_selected:
self._save_current_answer()
self._advance_or_submit()
elif self._is_other_selected:
if self.other_input:
self.other_input.focus()
else:
self._toggle_selection(self.selected_option)
def _handle_single_select_action(self) -> None:
"""Handle Enter key in single-select mode: select and advance."""
if self._is_other_selected:
if self.other_input:
other_text = self.other_texts.get(self.current_question_idx, "").strip()
if other_text:
self._save_current_answer()
self._advance_or_submit()
else:
self.other_input.focus()
else:
self._save_current_answer()
self._advance_or_submit()
def _toggle_selection(self, option_idx: int) -> None:
"""Toggle an option's selection state (multi-select only)."""
selections = self.multi_selections.setdefault(self.current_question_idx, set())
if option_idx in selections:
selections.discard(option_idx)
else:
selections.add(option_idx)
self._update_display()
def _advance_or_submit(self) -> None:
if self._all_answered():
self._submit()
else:
new_idx = next(
i
for i in itertools.chain(
range(self.current_question_idx + 1, len(self.questions)),
range(self.current_question_idx),
)
if i not in self.answers
)
self._switch_question(new_idx)
def action_cancel(self) -> None:
self.post_message(self.Cancelled())
def on_input_submitted(self, _event: Input.Submitted) -> None:
if not self.other_input or not self.other_input.value.strip():
return
q = self._current_question
if q.multi_select:
self.selected_option = self._submit_option_idx
else:
self._save_current_answer()
self._advance_or_submit()
def on_input_changed(self, _event: Input.Changed) -> None:
self._store_other_text()
self._sync_other_selection_with_text()
self._update_display()
def _sync_other_selection_with_text(self) -> None:
"""Auto-select/deselect 'Other' option based on whether text is entered (multi-select only)."""
if not self._current_question.multi_select or not self.other_input:
return
other_idx = self._other_option_idx
selections = self.multi_selections.setdefault(self.current_question_idx, set())
has_text = bool(self.other_input.value.strip())
if has_text and other_idx not in selections:
selections.add(other_idx)
elif not has_text and other_idx in selections:
selections.discard(other_idx)
def on_key(self, event: events.Key) -> None:
if len(self.questions) <= 1:
return
if self.other_input and self.other_input.has_focus:
return
if event.key == "left":
self.action_prev_question()
event.stop()
elif event.key == "right":
self.action_next_question()
event.stop()
def _save_current_answer(self) -> None:
if self._current_question.multi_select:
self._save_multi_select_answer()
else:
self._save_single_select_answer()
def _save_multi_select_answer(self) -> None:
"""Save answer for multi-select question (combines all selected options)."""
q = self._current_question
idx = self.current_question_idx
selections = self.multi_selections.get(idx, set())
if not selections:
return
other_text = self.other_texts.get(idx, "").strip()
answers = []
has_other = False
other_idx = len(q.options)
for sel_idx in sorted(selections):
if sel_idx < len(q.options):
answers.append(q.options[sel_idx].label)
elif sel_idx == other_idx and other_text:
answers.append(other_text)
has_other = True
if answers:
self.answers[idx] = (", ".join(answers), has_other)
def _save_single_select_answer(self) -> None:
"""Save answer for single-select question."""
idx = self.current_question_idx
if self._is_other_selected:
other_text = self.other_texts.get(idx, "").strip()
if other_text:
self.answers[idx] = (other_text, True)
else:
self.answers[idx] = (
self._current_question.options[self.selected_option].label,
False,
)
def _all_answered(self) -> bool:
return all(i in self.answers for i in range(len(self.questions)))
def _submit(self) -> None:
result: list[Answer] = []
for i, q in enumerate(self.questions):
answer_text, is_other = self.answers.get(i, ("", False))
result.append(
Answer(question=q.question, answer=answer_text, is_other=is_other)
)
self.post_message(self.Answered(answers=result))
def on_blur(self, _event: events.Blur) -> None:
self.call_after_refresh(self._refocus_if_needed)
def on_input_blurred(self, _event: Input.Blurred) -> None:
self.call_after_refresh(self._refocus_if_needed)
def _refocus_if_needed(self) -> None:
if self.has_focus or (self.other_input and self.other_input.has_focus):
return
self.focus()

View file

@ -10,6 +10,7 @@ from textual.widgets import Markdown, Static
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionResult
from vibe.core.tools.builtins.bash import BashArgs, BashResult
from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult
@ -321,6 +322,19 @@ class GrepResultWidget(ToolResultWidget[GrepResult]):
yield Markdown(f"```\n{_truncate_lines(self.result.matches, 30)}\n```")
class AskUserQuestionResultWidget(ToolResultWidget[AskUserQuestionResult]):
def compose(self) -> ComposeResult:
if self.collapsed or not self.result:
yield from self._header()
return
for answer in self.result.answers:
if len(self.result.answers) > 1:
yield NoMarkupStatic(answer.question, classes="tool-result-detail")
prefix = "(Other) " if answer.is_other else ""
yield NoMarkupStatic(f"{prefix}{answer.answer}", classes="ask-user-answer")
APPROVAL_WIDGETS: dict[str, type[ToolApprovalWidget]] = {
"bash": BashApprovalWidget,
"read_file": ReadFileApprovalWidget,
@ -337,6 +351,7 @@ RESULT_WIDGETS: dict[str, type[ToolResultWidget]] = {
"search_replace": SearchReplaceResultWidget,
"grep": GrepResultWidget,
"todo": TodoResultWidget,
"ask_user_question": AskUserQuestionResultWidget,
}

View file

@ -4,7 +4,7 @@ from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder, NonSelectableStatic
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.cli.textual_ui.widgets.tool_widgets import get_result_widget
@ -23,6 +23,7 @@ class ToolCallMessage(StatusMessage):
self._event = event
self._tool_name = tool_name or (event.tool_name if event else "unknown")
self._is_history = event is None
self._stream_widget: NoMarkupStatic | None = None
super().__init__()
self.add_class("tool-call")
@ -30,6 +31,19 @@ class ToolCallMessage(StatusMessage):
if self._is_history:
self._is_spinning = False
def compose(self) -> ComposeResult:
with Vertical(classes="tool-call-container"):
with Horizontal():
self._indicator_widget = NonSelectableStatic(
self._spinner.current_frame(), classes="status-indicator-icon"
)
yield self._indicator_widget
self._text_widget = NoMarkupStatic("", classes="status-indicator-text")
yield self._text_widget
self._stream_widget = NoMarkupStatic("", classes="tool-stream-message")
self._stream_widget.display = False
yield self._stream_widget
def get_content(self) -> str:
if self._event and self._event.tool_class:
adapter = ToolUIDataAdapter(self._event.tool_class)
@ -37,6 +51,18 @@ class ToolCallMessage(StatusMessage):
return display.summary
return self._tool_name
def set_stream_message(self, message: str) -> None:
"""Update the stream message displayed below the tool call indicator."""
if self._stream_widget:
self._stream_widget.update(f"{message}")
self._stream_widget.display = True
def stop_spinning(self, success: bool = True) -> None:
"""Stop the spinner and hide the stream widget."""
if self._stream_widget:
self._stream_widget.display = False
super().stop_spinning(success)
class ToolResultMessage(Static):
def __init__(
@ -80,12 +106,21 @@ class ToolResultMessage(Static):
async def on_mount(self) -> None:
if self._call_widget:
success = self._event is None or (
not self._event.error and not self._event.skipped
)
success = self._determine_success()
self._call_widget.stop_spinning(success=success)
await self._render_result()
def _determine_success(self) -> bool:
if self._event is None:
return True
if self._event.error or self._event.skipped:
return False
if self._event.tool_class:
adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_result_display(self._event)
return display.success
return True
async def _render_result(self) -> None:
if self._content_container is None:
return

View file

@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from time import monotonic
from rich.align import Align
@ -102,7 +103,7 @@ class WelcomeBanner(Static):
model_count = len(self.config.models)
self._static_line3_suffix = f"{self.LOGO_TEXT_GAP}[dim]{model_count} models · {mcp_count} MCP servers[/]"
self._static_line5_suffix = (
f"{self.LOGO_TEXT_GAP}[dim]{self.config.effective_workdir}[/]"
f"{self.LOGO_TEXT_GAP}[dim]{self.config.displayed_workdir or Path.cwd()}[/]"
)
self._static_line7 = f"[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information • [/][{self.BORDER_TARGET_COLOR}]/terminal-setup[/][dim] for shift+enter[/]"

View file

@ -3,41 +3,45 @@ from __future__ import annotations
from vibe.cli.update_notifier.adapters.filesystem_update_cache_repository import (
FileSystemUpdateCacheRepository,
)
from vibe.cli.update_notifier.adapters.github_version_update_gateway import (
GitHubVersionUpdateGateway,
)
from vibe.cli.update_notifier.adapters.pypi_version_update_gateway import (
PyPIVersionUpdateGateway,
)
from vibe.cli.update_notifier.adapters.github_update_gateway import GitHubUpdateGateway
from vibe.cli.update_notifier.adapters.pypi_update_gateway import PyPIUpdateGateway
from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
from vibe.cli.update_notifier.ports.version_update_gateway import (
from vibe.cli.update_notifier.ports.update_gateway import (
DEFAULT_GATEWAY_MESSAGES,
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
Update,
UpdateGateway,
UpdateGatewayCause,
UpdateGatewayError,
)
from vibe.cli.update_notifier.version_update import (
VersionUpdateAvailability,
VersionUpdateError,
from vibe.cli.update_notifier.update import (
UpdateAvailability,
UpdateError,
get_update_if_available,
)
from vibe.cli.update_notifier.whats_new import (
load_whats_new_content,
mark_version_as_seen,
should_show_whats_new,
)
__all__ = [
"DEFAULT_GATEWAY_MESSAGES",
"FileSystemUpdateCacheRepository",
"GitHubVersionUpdateGateway",
"PyPIVersionUpdateGateway",
"GitHubUpdateGateway",
"PyPIUpdateGateway",
"Update",
"UpdateAvailability",
"UpdateCache",
"UpdateCacheRepository",
"VersionUpdate",
"VersionUpdateAvailability",
"VersionUpdateError",
"VersionUpdateGateway",
"VersionUpdateGatewayCause",
"VersionUpdateGatewayError",
"UpdateError",
"UpdateGateway",
"UpdateGatewayCause",
"UpdateGatewayError",
"get_update_if_available",
"load_whats_new_content",
"mark_version_as_seen",
"should_show_whats_new",
]

View file

@ -26,6 +26,7 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
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):
return None
@ -34,16 +35,28 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
):
return None
if (
not isinstance(seen_whats_new_version, str)
and seen_whats_new_version is not None
):
seen_whats_new_version = None
return UpdateCache(
latest_version=latest_version, stored_at_timestamp=stored_at_timestamp
latest_version=latest_version,
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,
})
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,
},
ensure_ascii=False,
)
await asyncio.to_thread(self._cache_file.write_text, payload)
except OSError:
return None

View file

@ -2,15 +2,15 @@ from __future__ import annotations
import httpx
from vibe.cli.update_notifier.ports.version_update_gateway import (
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
from vibe.cli.update_notifier.ports.update_gateway import (
Update,
UpdateGateway,
UpdateGatewayCause,
UpdateGatewayError,
)
class GitHubVersionUpdateGateway(VersionUpdateGateway):
class GitHubUpdateGateway(UpdateGateway):
def __init__(
self,
owner: str,
@ -28,7 +28,7 @@ class GitHubVersionUpdateGateway(VersionUpdateGateway):
self._timeout = timeout
self._base_url = base_url.rstrip("/")
async def fetch_update(self) -> VersionUpdate | None:
async def fetch_update(self) -> Update | None:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "mistral-vibe-update-notifier",
@ -51,38 +51,30 @@ class GitHubVersionUpdateGateway(VersionUpdateGateway):
) as client:
response = await client.get(request_path, headers=headers)
except httpx.RequestError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.REQUEST_FAILED
) from exc
raise UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) from exc
rate_limit_remaining = response.headers.get("X-RateLimit-Remaining")
if response.status_code == httpx.codes.TOO_MANY_REQUESTS or (
rate_limit_remaining is not None and rate_limit_remaining == "0"
):
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.TOO_MANY_REQUESTS
)
raise UpdateGatewayError(cause=UpdateGatewayCause.TOO_MANY_REQUESTS)
if response.status_code == httpx.codes.FORBIDDEN:
raise VersionUpdateGatewayError(cause=VersionUpdateGatewayCause.FORBIDDEN)
raise UpdateGatewayError(cause=UpdateGatewayCause.FORBIDDEN)
if response.status_code == httpx.codes.NOT_FOUND:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.NOT_FOUND,
raise UpdateGatewayError(
cause=UpdateGatewayCause.NOT_FOUND,
message="Unable to fetch the GitHub releases. Did you export a GITHUB_TOKEN environment variable?",
)
if response.is_error:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.ERROR_RESPONSE
)
raise UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
try:
data = response.json()
except ValueError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.INVALID_RESPONSE
) from exc
raise UpdateGatewayError(cause=UpdateGatewayCause.INVALID_RESPONSE) from exc
if not data:
return None
@ -95,7 +87,7 @@ class GitHubVersionUpdateGateway(VersionUpdateGateway):
if release.get("prerelease") or release.get("draft"):
continue
if version := _extract_version(release.get("tag_name")):
return VersionUpdate(latest_version=version)
return Update(latest_version=version)
return None

View file

@ -4,21 +4,21 @@ import httpx
from packaging.utils import parse_sdist_filename, parse_wheel_filename
from packaging.version import InvalidVersion, Version
from vibe.cli.update_notifier.ports.version_update_gateway import (
VersionUpdate,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
from vibe.cli.update_notifier.ports.update_gateway import (
Update,
UpdateGateway,
UpdateGatewayCause,
UpdateGatewayError,
)
_STATUS_CAUSES: dict[int, VersionUpdateGatewayCause] = {
httpx.codes.NOT_FOUND: VersionUpdateGatewayCause.NOT_FOUND,
httpx.codes.FORBIDDEN: VersionUpdateGatewayCause.FORBIDDEN,
httpx.codes.TOO_MANY_REQUESTS: VersionUpdateGatewayCause.TOO_MANY_REQUESTS,
_STATUS_CAUSES: dict[int, UpdateGatewayCause] = {
httpx.codes.NOT_FOUND: UpdateGatewayCause.NOT_FOUND,
httpx.codes.FORBIDDEN: UpdateGatewayCause.FORBIDDEN,
httpx.codes.TOO_MANY_REQUESTS: UpdateGatewayCause.TOO_MANY_REQUESTS,
}
class PyPIVersionUpdateGateway(VersionUpdateGateway):
class PyPIUpdateGateway(UpdateGateway):
def __init__(
self,
project_name: str,
@ -32,16 +32,14 @@ class PyPIVersionUpdateGateway(VersionUpdateGateway):
self._timeout = timeout
self._base_url = base_url.rstrip("/")
async def fetch_update(self) -> VersionUpdate | None:
async def fetch_update(self) -> Update | None:
response = await self._fetch()
self._raise_gateway_error_if_any(response)
try:
data = response.json()
except ValueError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.INVALID_RESPONSE
) from exc
raise UpdateGatewayError(cause=UpdateGatewayCause.INVALID_RESPONSE) from exc
versions = data.get("versions") or []
files = data.get("files") or []
@ -66,7 +64,7 @@ class PyPIVersionUpdateGateway(VersionUpdateGateway):
for version in sorted(valid_versions, reverse=True):
if version in non_yanked_versions:
return VersionUpdate(latest_version=str(version))
return Update(latest_version=str(version))
return None
@ -87,18 +85,14 @@ class PyPIVersionUpdateGateway(VersionUpdateGateway):
) as client:
return await client.get(request_path, headers=headers)
except httpx.RequestError as exc:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.REQUEST_FAILED
) from exc
raise UpdateGatewayError(cause=UpdateGatewayCause.REQUEST_FAILED) from exc
def _raise_gateway_error_if_any(self, response: httpx.Response) -> None:
if response.status_code in _STATUS_CAUSES:
raise VersionUpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
raise UpdateGatewayError(cause=_STATUS_CAUSES[response.status_code])
if response.is_error:
raise VersionUpdateGatewayError(
cause=VersionUpdateGatewayCause.ERROR_RESPONSE
)
raise UpdateGatewayError(cause=UpdateGatewayCause.ERROR_RESPONSE)
def _parse_filename_version(filename: str) -> Version | None:

View file

@ -8,6 +8,7 @@ from typing import Protocol
class UpdateCache:
latest_version: str
stored_at_timestamp: int
seen_whats_new_version: str | None = None
class UpdateCacheRepository(Protocol):

View file

@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Protocol
@dataclass(frozen=True, slots=True)
class Update:
latest_version: str
class UpdateGatewayCause(StrEnum):
@staticmethod
def _generate_next_value_(
name: str, start: int, count: int, last_values: list[str]
) -> str:
return name.lower()
TOO_MANY_REQUESTS = auto()
FORBIDDEN = auto()
NOT_FOUND = auto()
REQUEST_FAILED = auto()
ERROR_RESPONSE = auto()
INVALID_RESPONSE = auto()
UNKNOWN = auto()
DEFAULT_GATEWAY_MESSAGES: dict[UpdateGatewayCause, str] = {
UpdateGatewayCause.TOO_MANY_REQUESTS: "Rate limit exceeded while checking for updates.",
UpdateGatewayCause.FORBIDDEN: "Request was forbidden while checking for updates.",
UpdateGatewayCause.NOT_FOUND: "Unable to fetch the releases. Please check your permissions.",
UpdateGatewayCause.REQUEST_FAILED: "Network error while checking for updates.",
UpdateGatewayCause.ERROR_RESPONSE: "Unexpected response received while checking for updates.",
UpdateGatewayCause.INVALID_RESPONSE: "Received an invalid response while checking for updates.",
UpdateGatewayCause.UNKNOWN: "Unable to determine whether an update is available.",
}
class UpdateGatewayError(Exception):
def __init__(
self, *, cause: UpdateGatewayCause, message: str | None = None
) -> None:
self.cause = cause
self.user_message = message
detail = message or DEFAULT_GATEWAY_MESSAGES.get(
cause, DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
)
super().__init__(detail)
class UpdateGateway(Protocol):
async def fetch_update(self) -> Update | None: ...

View file

@ -1,53 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum, auto
from typing import Protocol
@dataclass(frozen=True, slots=True)
class VersionUpdate:
latest_version: str
class VersionUpdateGatewayCause(StrEnum):
@staticmethod
def _generate_next_value_(
name: str, start: int, count: int, last_values: list[str]
) -> str:
return name.lower()
TOO_MANY_REQUESTS = auto()
FORBIDDEN = auto()
NOT_FOUND = auto()
REQUEST_FAILED = auto()
ERROR_RESPONSE = auto()
INVALID_RESPONSE = auto()
UNKNOWN = auto()
DEFAULT_GATEWAY_MESSAGES: dict[VersionUpdateGatewayCause, str] = {
VersionUpdateGatewayCause.TOO_MANY_REQUESTS: "Rate limit exceeded while checking for updates.",
VersionUpdateGatewayCause.FORBIDDEN: "Request was forbidden while checking for updates.",
VersionUpdateGatewayCause.NOT_FOUND: "Unable to fetch the releases. Please check your permissions.",
VersionUpdateGatewayCause.REQUEST_FAILED: "Network error while checking for updates.",
VersionUpdateGatewayCause.ERROR_RESPONSE: "Unexpected response received while checking for updates.",
VersionUpdateGatewayCause.INVALID_RESPONSE: "Received an invalid response while checking for updates.",
VersionUpdateGatewayCause.UNKNOWN: "Unable to determine whether an update is available.",
}
class VersionUpdateGatewayError(Exception):
def __init__(
self, *, cause: VersionUpdateGatewayCause, message: str | None = None
) -> None:
self.cause = cause
self.user_message = message
detail = message or DEFAULT_GATEWAY_MESSAGES.get(
cause, DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
)
super().__init__(detail)
class VersionUpdateGateway(Protocol):
async def fetch_update(self) -> VersionUpdate | None: ...

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
import time
@ -10,21 +11,21 @@ from vibe.cli.update_notifier import (
DEFAULT_GATEWAY_MESSAGES,
UpdateCache,
UpdateCacheRepository,
VersionUpdateGateway,
VersionUpdateGatewayCause,
VersionUpdateGatewayError,
UpdateGateway,
UpdateGatewayCause,
UpdateGatewayError,
)
UPDATE_CACHE_TTL_SECONDS = 24 * 60 * 60
@dataclass(frozen=True, slots=True)
class VersionUpdateAvailability:
class UpdateAvailability:
latest_version: str
should_notify: bool
class VersionUpdateError(Exception):
class UpdateError(Exception):
def __init__(self, message: str) -> None:
self.message = message
super().__init__(message)
@ -37,17 +38,17 @@ def _parse_version(raw: str) -> Version | None:
return None
def _describe_gateway_error(error: VersionUpdateGatewayError) -> str:
def _describe_gateway_error(error: UpdateGatewayError) -> str:
if message := getattr(error, "user_message", None):
return message
cause = getattr(error, "cause", VersionUpdateGatewayCause.UNKNOWN)
if isinstance(cause, VersionUpdateGatewayCause):
cause = getattr(error, "cause", UpdateGatewayCause.UNKNOWN)
if isinstance(cause, UpdateGatewayCause):
return DEFAULT_GATEWAY_MESSAGES.get(
cause, DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
cause, DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
)
return DEFAULT_GATEWAY_MESSAGES[VersionUpdateGatewayCause.UNKNOWN]
return DEFAULT_GATEWAY_MESSAGES[UpdateGatewayCause.UNKNOWN]
def _is_cache_fresh(
@ -60,14 +61,12 @@ def _is_cache_fresh(
def _get_cached_update_if_any(
cache: UpdateCache, current: Version
) -> VersionUpdateAvailability | None:
) -> UpdateAvailability | None:
latest_version_in_cache = _parse_version(cache.latest_version)
if latest_version_in_cache is None or latest_version_in_cache <= current:
return None
return VersionUpdateAvailability(
latest_version=cache.latest_version, should_notify=False
)
return UpdateAvailability(latest_version=cache.latest_version, should_notify=False)
async def _write_update_cache(
@ -81,11 +80,11 @@ async def _write_update_cache(
async def get_update_if_available(
version_update_notifier: VersionUpdateGateway,
update_notifier: UpdateGateway,
current_version: str,
update_cache_repository: UpdateCacheRepository,
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
) -> VersionUpdateAvailability | None:
) -> UpdateAvailability | None:
if not (current := _parse_version(current_version)):
return None
@ -94,12 +93,12 @@ async def get_update_if_available(
return _get_cached_update_if_any(update_cache, current)
try:
update = await version_update_notifier.fetch_update()
except VersionUpdateGatewayError as error:
update = await update_notifier.fetch_update()
except UpdateGatewayError as error:
await _write_update_cache(
update_cache_repository, current_version, get_current_timestamp
)
raise VersionUpdateError(_describe_gateway_error(error)) from error
raise UpdateError(_describe_gateway_error(error)) from error
if not update:
await _write_update_cache(
@ -120,6 +119,21 @@ async def get_update_if_available(
update_cache_repository, update.latest_version, get_current_timestamp
)
return VersionUpdateAvailability(
latest_version=update.latest_version, should_notify=True
)
return UpdateAvailability(latest_version=update.latest_version, should_notify=True)
UPDATE_COMMANDS = ["uv tool upgrade mistral-vibe", "brew upgrade mistral-vibe"]
async def do_update() -> bool:
for command in UPDATE_COMMANDS:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
)
await process.wait()
if process.returncode == 0:
return True
return False

View file

@ -0,0 +1,49 @@
from __future__ import annotations
import time
from vibe import VIBE_ROOT
from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
async def should_show_whats_new(
current_version: str, repository: UpdateCacheRepository
) -> bool:
cache = await repository.get()
if cache is None:
return False
return cache.seen_whats_new_version != current_version
def load_whats_new_content() -> str | None:
whats_new_file = VIBE_ROOT / "whats_new.md"
if not whats_new_file.exists():
return None
try:
content = whats_new_file.read_text(encoding="utf-8").strip()
return content if content else None
except OSError:
return None
async def mark_version_as_seen(version: str, repository: UpdateCacheRepository) -> None:
cache = await repository.get()
if cache is None:
await repository.set(
UpdateCache(
latest_version=version,
stored_at_timestamp=int(time.time()),
seen_whats_new_version=version,
)
)
else:
await repository.set(
UpdateCache(
latest_version=cache.latest_version,
stored_at_timestamp=cache.stored_at_timestamp,
seen_whats_new_version=version,
)
)