2.0.0
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:
parent
79f215d91c
commit
d33db9fff8
217 changed files with 16911 additions and 4305 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
67
vibe/cli/plan_offer/adapters/http_whoami_gateway.py
Normal file
67
vibe/cli/plan_offer/adapters/http_whoami_gateway.py
Normal 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")
|
||||
56
vibe/cli/plan_offer/decide_plan_offer.py
Normal file
56
vibe/cli/plan_offer/decide_plan_offer.py
Normal 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
|
||||
23
vibe/cli/plan_offer/ports/whoami_gateway.py
Normal file
23
vibe/cli/plan_offer/ports/whoami_gateway.py
Normal 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: ...
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
vibe/cli/textual_ui/external_editor.py
Normal file
32
vibe/cli/textual_ui/external_editor.py
Normal 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)
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
41
vibe/cli/textual_ui/widgets/agent_indicator.py
Normal file
41
vibe/cli/textual_ui/widgets/agent_indicator.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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__()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
479
vibe/cli/textual_ui/widgets/question_app.py
Normal file
479
vibe/cli/textual_ui/widgets/question_app.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[/]"
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
53
vibe/cli/update_notifier/ports/update_gateway.py
Normal file
53
vibe/cli/update_notifier/ports/update_gateway.py
Normal 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: ...
|
||||
|
|
@ -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: ...
|
||||
|
|
@ -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
|
||||
49
vibe/cli/update_notifier/whats_new.py
Normal file
49
vibe/cli/update_notifier/whats_new.py
Normal 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,
|
||||
)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue