Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Kracekumar <kracethekingmaker@gmail.com>
This commit is contained in:
Quentin 2025-12-14 00:54:42 +01:00 committed by Mathias Gesbert
parent 661588de0c
commit d8dbeeb31e
91 changed files with 4521 additions and 873 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import atexit
from concurrent.futures import Future, ThreadPoolExecutor
from threading import Lock
@ -171,3 +172,6 @@ class PathCompletionController:
self._view.replace_completion_range(start, end, completion)
self.reset()
return True
atexit.register(PathCompletionController._executor.shutdown)

View file

@ -5,7 +5,7 @@ from textual import events
from vibe.cli.autocompletion.base import CompletionResult, CompletionView
from vibe.core.autocompletion.completers import CommandCompleter
MAX_SUGGESTIONS_COUNT = 5
MAX_SUGGESTIONS_COUNT = 10
class SlashCommandController:

186
vibe/cli/cli.py Normal file
View file

@ -0,0 +1,186 @@
from __future__ import annotations
import argparse
import sys
from rich import print as rprint
from vibe.cli.textual_ui.app import run_textual_ui
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.programmatic import run_programmatic
from vibe.core.types import LLMMessage, OutputFormat
from vibe.core.utils import ConversationLimitException
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
return AgentMode.DEFAULT
def get_prompt_from_stdin() -> str | None:
if sys.stdin.isatty():
return None
try:
if content := sys.stdin.read().strip():
sys.stdin = sys.__stdin__ = open("/dev/tty")
return content
except KeyboardInterrupt:
pass
except OSError:
return None
return None
def load_config_or_exit(
agent: str | None = None, mode: AgentMode = AgentMode.DEFAULT
) -> VibeConfig:
try:
return VibeConfig.load(agent, **mode.config_overrides)
except MissingAPIKeyError:
run_onboarding()
return VibeConfig.load(agent, **mode.config_overrides)
except MissingPromptFileError as e:
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
sys.exit(1)
except ValueError as e:
rprint(f"[yellow]{e}[/]")
sys.exit(1)
def bootstrap_config_files() -> None:
if not CONFIG_FILE.path.exists():
try:
VibeConfig.save_updates(VibeConfig.create_default())
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)
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
except Exception as e:
rprint(f"[yellow]Could not create history file: {e}[/]")
def load_session(
args: argparse.Namespace, config: VibeConfig
) -> list[LLMMessage] | None:
if not args.continue_session and not args.resume:
return None
if not config.session_logging.enabled:
rprint(
"[red]Session logging is disabled. "
"Enable it in config to use --continue or --resume[/]"
)
sys.exit(1)
session_to_load = None
if args.continue_session:
session_to_load = InteractionLogger.find_latest_session(config.session_logging)
if not session_to_load:
rprint(
f"[red]No previous sessions found in "
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
else:
session_to_load = InteractionLogger.find_session_by_id(
args.resume, config.session_logging
)
if not session_to_load:
rprint(
f"[red]Session '{args.resume}' not found in "
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
try:
loaded_messages, _ = InteractionLogger.load_session(session_to_load)
return loaded_messages
except Exception as e:
rprint(f"[red]Failed to load session: {e}[/]")
sys.exit(1)
def run_cli(args: argparse.Namespace) -> None:
load_api_keys_from_env()
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)
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
loaded_messages = load_session(args, config)
stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None:
programmatic_prompt = args.prompt or stdin_prompt
if not programmatic_prompt:
print(
"Error: No prompt provided for programmatic mode", file=sys.stderr
)
sys.exit(1)
output_format = OutputFormat(
args.output if hasattr(args, "output") else "text"
)
try:
final_response = run_programmatic(
config=config,
prompt=programmatic_prompt,
max_turns=args.max_turns,
max_price=args.max_price,
output_format=output_format,
previous_messages=loaded_messages,
mode=initial_mode,
)
if final_response:
print(final_response)
sys.exit(0)
except ConversationLimitException as e:
print(e, file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
run_textual_ui(
config,
initial_mode=initial_mode,
enable_streaming=True,
initial_prompt=args.initial_prompt or stdin_prompt,
loaded_messages=loaded_messages,
)
except (KeyboardInterrupt, EOFError):
rprint("\n[dim]Bye![/]")
sys.exit(0)

View file

@ -17,46 +17,51 @@ class CommandRegistry:
excluded_commands = []
self.commands = {
"help": Command(
aliases=frozenset(["/help", "/h"]),
aliases=frozenset(["/help"]),
description="Show help message",
handler="_show_help",
),
"status": Command(
aliases=frozenset(["/status", "/stats"]),
description="Display agent statistics",
handler="_show_status",
),
"config": Command(
aliases=frozenset(["/config", "/cfg", "/theme", "/model"]),
aliases=frozenset(["/config", "/theme", "/model"]),
description="Edit config settings",
handler="_show_config",
),
"reload": Command(
aliases=frozenset(["/reload", "/r"]),
aliases=frozenset(["/reload"]),
description="Reload configuration from disk",
handler="_reload_config",
),
"clear": Command(
aliases=frozenset(["/clear", "/reset"]),
aliases=frozenset(["/clear"]),
description="Clear conversation history",
handler="_clear_history",
),
"log": Command(
aliases=frozenset(["/log", "/logpath"]),
aliases=frozenset(["/log"]),
description="Show path to current interaction log file",
handler="_show_log_path",
),
"compact": Command(
aliases=frozenset(["/compact", "/summarize"]),
aliases=frozenset(["/compact"]),
description="Compact conversation history by summarizing",
handler="_compact_history",
),
"exit": Command(
aliases=frozenset(["/exit", "/quit", "/q"]),
aliases=frozenset(["/exit"]),
description="Exit the application",
handler="_exit_app",
exits=True,
),
"terminal-setup": Command(
aliases=frozenset(["/terminal-setup"]),
description="Configure Shift+Enter for newlines",
handler="_setup_terminal",
),
"status": Command(
aliases=frozenset(["/status"]),
description="Display agent statistics",
handler="_show_status",
),
}
for command in excluded_commands:

View file

@ -1,27 +1,25 @@
from __future__ import annotations
import argparse
from pathlib import Path
import sys
from rich import print as rprint
from vibe.cli.textual_ui.app import run_textual_ui
from vibe.core.config import (
MissingAPIKeyError,
MissingPromptFileError,
VibeConfig,
load_api_keys_from_env,
from vibe import __version__
from vibe.core.paths.config_paths import unlock_config_paths
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.setup.trusted_folders.trust_folder_dialog import (
TrustDialogQuitException,
ask_trust_folder,
)
from vibe.core.config_path import CONFIG_FILE, HISTORY_FILE, INSTRUCTIONS_FILE
from vibe.core.interaction_logger import InteractionLogger
from vibe.core.programmatic import run_programmatic
from vibe.core.types import OutputFormat, ResumeSessionInfo
from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the Mistral Vibe interactive CLI")
parser.add_argument(
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
)
parser.add_argument(
"initial_prompt",
nargs="?",
@ -41,7 +39,13 @@ def parse_arguments() -> argparse.Namespace:
"--auto-approve",
action="store_true",
default=False,
help="Automatically approve all tool executions.",
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",
@ -99,160 +103,41 @@ def parse_arguments() -> argparse.Namespace:
return parser.parse_args()
def get_prompt_from_stdin() -> str | None:
if sys.stdin.isatty():
return None
def check_and_resolve_trusted_folder() -> None:
cwd = Path.cwd()
if not (cwd / ".vibe").exists():
return
is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
if is_folder_trusted is not None:
return
try:
if content := sys.stdin.read().strip():
sys.stdin = sys.__stdin__ = open("/dev/tty")
return content
except KeyboardInterrupt:
pass
except OSError:
return None
is_folder_trusted = ask_trust_folder(cwd)
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
sys.exit(0)
except Exception as e:
rprint(f"[yellow]Error showing trust dialog: {e}[/]")
return
return None
if is_folder_trusted is True:
trusted_folders_manager.add_trusted(cwd)
elif is_folder_trusted is False:
trusted_folders_manager.add_untrusted(cwd)
def load_config_or_exit(agent: str | None = None) -> VibeConfig:
try:
return VibeConfig.load(agent)
except MissingAPIKeyError:
run_onboarding()
return VibeConfig.load(agent)
except MissingPromptFileError as e:
rprint(f"[yellow]Invalid system prompt id: {e}[/]")
sys.exit(1)
except ValueError as e:
rprint(f"[yellow]{e}[/]")
sys.exit(1)
def main() -> None: # noqa: PLR0912, PLR0915
load_api_keys_from_env()
def main() -> None:
args = parse_arguments()
if args.setup:
run_onboarding()
sys.exit(0)
try:
if not CONFIG_FILE.path.exists():
try:
VibeConfig.save_updates(VibeConfig.create_default())
except Exception as e:
rprint(f"[yellow]Could not create default config file: {e}[/]")
is_interactive = args.prompt is None
if is_interactive:
check_and_resolve_trusted_folder()
unlock_config_paths()
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}[/]")
from vibe.cli.cli import run_cli
if not HISTORY_FILE.path.exists():
try:
HISTORY_FILE.path.parent.mkdir(parents=True, exist_ok=True)
HISTORY_FILE.path.write_text("Hello Vibe!\n", "utf-8")
except Exception as e:
rprint(f"[yellow]Could not create history file: {e}[/]")
config = load_config_or_exit(args.agent)
if args.enabled_tools:
config.enabled_tools = args.enabled_tools
loaded_messages = None
session_info = None
if args.continue_session or args.resume:
if not config.session_logging.enabled:
rprint(
"[red]Session logging is disabled. "
"Enable it in config to use --continue or --resume[/]"
)
sys.exit(1)
session_to_load = None
if args.continue_session:
session_to_load = InteractionLogger.find_latest_session(
config.session_logging
)
if not session_to_load:
rprint(
f"[red]No previous sessions found in "
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
else:
session_to_load = InteractionLogger.find_session_by_id(
args.resume, config.session_logging
)
if not session_to_load:
rprint(
f"[red]Session '{args.resume}' not found in "
f"{config.session_logging.save_dir}[/]"
)
sys.exit(1)
try:
loaded_messages, metadata = InteractionLogger.load_session(
session_to_load
)
session_id = metadata.get("session_id", "unknown")[:8]
session_time = metadata.get("start_time", "unknown time")
session_info = ResumeSessionInfo(
type="continue" if args.continue_session else "resume",
session_id=session_id,
session_time=session_time,
)
except Exception as e:
rprint(f"[red]Failed to load session: {e}[/]")
sys.exit(1)
stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None:
programmatic_prompt = args.prompt or stdin_prompt
if not programmatic_prompt:
print(
"Error: No prompt provided for programmatic mode", file=sys.stderr
)
sys.exit(1)
output_format = OutputFormat(
args.output if hasattr(args, "output") else "text"
)
try:
final_response = run_programmatic(
config=config,
prompt=programmatic_prompt,
max_turns=args.max_turns,
max_price=args.max_price,
output_format=output_format,
previous_messages=loaded_messages,
)
if final_response:
print(final_response)
sys.exit(0)
except ConversationLimitException as e:
print(e, file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
run_textual_ui(
config,
auto_approve=args.auto_approve,
enable_streaming=True,
initial_prompt=args.initial_prompt or stdin_prompt,
loaded_messages=loaded_messages,
session_info=session_info,
)
except (KeyboardInterrupt, EOFError):
rprint("\n[dim]Bye![/]")
sys.exit(0)
run_cli(args)
if __name__ == "__main__":

400
vibe/cli/terminal_setup.py Normal file
View file

@ -0,0 +1,400 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import json
import os
from pathlib import Path
import platform
import subprocess
from typing import Any
class Terminal(Enum):
VSCODE = "vscode"
CURSOR = "cursor"
ITERM2 = "iterm2"
WEZTERM = "wezterm"
GHOSTTY = "ghostty"
UNKNOWN = "unknown"
@dataclass
class SetupResult:
success: bool
terminal: Terminal
message: str
requires_restart: bool = False
def _is_cursor() -> bool:
path_indicators = [
"VSCODE_GIT_ASKPASS_NODE",
"VSCODE_GIT_ASKPASS_MAIN",
"VSCODE_IPC_HOOK_CLI",
"VSCODE_NLS_CONFIG",
]
for var in path_indicators:
val = os.environ.get(var, "").lower()
if "cursor" in val:
return True
return False
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
term_map = {
"iterm.app": Terminal.ITERM2,
"wezterm": Terminal.WEZTERM,
"ghostty": Terminal.GHOSTTY,
}
if term_program in term_map:
return term_map[term_program]
if os.environ.get("WEZTERM_PANE"):
return Terminal.WEZTERM
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
return Terminal.GHOSTTY
return Terminal.UNKNOWN
def _get_vscode_keybindings_path() -> Path | None:
system = platform.system()
if system == "Darwin":
base = Path.home() / "Library" / "Application Support" / "Code" / "User"
elif system == "Linux":
base = Path.home() / ".config" / "Code" / "User"
elif system == "Windows":
appdata = os.environ.get("APPDATA", "")
if appdata:
base = Path(appdata) / "Code" / "User"
else:
return None
else:
return None
return base / "keybindings.json"
def _get_cursor_keybindings_path() -> Path | None:
system = platform.system()
if system == "Darwin":
base = Path.home() / "Library" / "Application Support" / "Cursor" / "User"
elif system == "Linux":
base = Path.home() / ".config" / "Cursor" / "User"
elif system == "Windows":
appdata = os.environ.get("APPDATA", "")
if appdata:
base = Path(appdata) / "Cursor" / "User"
else:
return None
else:
return None
return base / "keybindings.json"
def _parse_keybindings(content: str) -> list[dict[str, Any]]:
content = content.strip()
if not content or content.startswith("//"):
return []
lines = [line for line in content.split("\n") if not line.strip().startswith("//")]
clean_content = "\n".join(lines)
try:
return json.loads(clean_content)
except json.JSONDecodeError:
return []
def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
"""Setup keybindings for VSCode 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"
if keybindings_path is None:
return SetupResult(
success=False,
terminal=terminal,
message=f"Could not determine keybindings path for {editor_name}",
)
new_binding = {
"key": "shift+enter",
"command": "workbench.action.terminal.sendSequence",
"args": {"text": "\u001b[13;2u"},
"when": "terminalFocus",
}
try:
keybindings = _read_existing_keybindings(keybindings_path)
if _has_shift_enter_binding(keybindings):
return SetupResult(
success=True,
terminal=terminal,
message=f"Shift+Enter already configured in {editor_name}",
)
keybindings.append(new_binding)
keybindings_path.write_text(json.dumps(keybindings, indent=2) + "\n")
return SetupResult(
success=True,
terminal=terminal,
message=f"Added Shift+Enter binding to {keybindings_path}",
requires_restart=True,
)
except Exception as e:
return SetupResult(
success=False,
terminal=terminal,
message=f"Failed to configure {editor_name}: {e}",
)
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
if keybindings_path.exists():
content = keybindings_path.read_text()
return _parse_keybindings(content)
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
return []
def _has_shift_enter_binding(keybindings: list[dict[str, Any]]) -> bool:
for binding in keybindings:
if (
binding.get("key") == "shift+enter"
and binding.get("command") == "workbench.action.terminal.sendSequence"
and binding.get("when") == "terminalFocus"
):
return True
return False
def _setup_iterm2() -> SetupResult:
if platform.system() != "Darwin":
return SetupResult(
success=False,
terminal=Terminal.ITERM2,
message="iTerm2 is only available on macOS",
)
plist_key = "0xd-0x20000-0x24"
plist_value = """<dict>
<key>Text</key>
<string>\\n</string>
<key>Action</key>
<integer>12</integer>
<key>Version</key>
<integer>1</integer>
<key>Keycode</key>
<integer>13</integer>
<key>Modifiers</key>
<integer>131072</integer>
</dict>"""
try:
result = subprocess.run(
["defaults", "read", "com.googlecode.iterm2", "GlobalKeyMap"],
capture_output=True,
text=True,
)
if plist_key in result.stdout:
return SetupResult(
success=True,
terminal=Terminal.ITERM2,
message="Shift+Enter already configured in iTerm2",
)
subprocess.run(
[
"defaults",
"write",
"com.googlecode.iterm2",
"GlobalKeyMap",
"-dict-add",
plist_key,
plist_value,
],
check=True,
capture_output=True,
)
return SetupResult(
success=True,
terminal=Terminal.ITERM2,
message="Added Shift+Enter binding to iTerm2 preferences",
requires_restart=True,
)
except subprocess.CalledProcessError as e:
return SetupResult(
success=False,
terminal=Terminal.ITERM2,
message=f"Failed to configure iTerm2: {e.stderr}",
)
except Exception as e:
return SetupResult(
success=False,
terminal=Terminal.ITERM2,
message=f"Failed to configure iTerm2: {e}",
)
def _setup_wezterm() -> SetupResult:
wezterm_config = Path.home() / ".wezterm.lua"
key_binding = """{
key = "Enter",
mods = "SHIFT",
action = wezterm.action.SendString("\\x1b[13;2u"),
}"""
try:
if wezterm_config.exists():
content = wezterm_config.read_text()
if 'mods = "SHIFT"' in content and 'key = "Enter"' in content:
return SetupResult(
success=True,
terminal=Terminal.WEZTERM,
message="Shift+Enter already configured in WezTerm",
)
if "keys = {" in content:
content = content.replace("keys = {", f"keys = {{\n {key_binding},")
else:
return SetupResult(
success=False,
terminal=Terminal.WEZTERM,
message="Please manually add the following to your .wezterm.lua:\n\n"
f" keys = {{\n {key_binding}\n }}",
)
else:
content = f"""local wezterm = require 'wezterm'
return {{
keys = {{
{key_binding}
}},
}}
"""
wezterm_config.write_text(content)
return SetupResult(
success=True,
terminal=Terminal.WEZTERM,
message=f"Added Shift+Enter binding to {wezterm_config}",
requires_restart=True,
)
except Exception as e:
return SetupResult(
success=False,
terminal=Terminal.WEZTERM,
message=f"Failed to configure WezTerm: {e}",
)
def _setup_ghostty() -> SetupResult:
system = platform.system()
if system == "Darwin":
config_path = (
Path.home()
/ "Library"
/ "Application Support"
/ "com.mitchellh.ghostty"
/ "config"
)
elif system == "Linux":
xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
config_path = Path(xdg_config) / "ghostty" / "config"
else:
return SetupResult(
success=False,
terminal=Terminal.GHOSTTY,
message="Ghostty configuration path unknown for this OS",
)
keybind_line = "keybind = shift+enter=text:\\x1b[13;2u"
try:
if config_path.exists():
content = config_path.read_text()
if "shift+enter" in content.lower():
return SetupResult(
success=True,
terminal=Terminal.GHOSTTY,
message="Shift+Enter already configured in Ghostty",
)
if not content.endswith("\n"):
content += "\n"
content += keybind_line + "\n"
else:
config_path.parent.mkdir(parents=True, exist_ok=True)
content = keybind_line + "\n"
config_path.write_text(content)
return SetupResult(
success=True,
terminal=Terminal.GHOSTTY,
message=f"Added Shift+Enter binding to {config_path}",
requires_restart=True,
)
except Exception as e:
return SetupResult(
success=False,
terminal=Terminal.GHOSTTY,
message=f"Failed to configure Ghostty: {e}",
)
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.ITERM2:
return _setup_iterm2()
case Terminal.WEZTERM:
return _setup_wezterm()
case Terminal.GHOSTTY:
return _setup_ghostty()
case Terminal.UNKNOWN:
return SetupResult(
success=False,
terminal=Terminal.UNKNOWN,
message="Could not detect terminal. Supported terminals:\n"
"- VSCode\n"
"- Cursor\n"
"- iTerm2\n"
"- WezTerm\n"
"- Ghostty\n\n"
"You can manually configure Shift+Enter to send: \\x1b[13;2u",
)

View file

@ -3,17 +3,20 @@ from __future__ import annotations
import asyncio
from enum import StrEnum, auto
import subprocess
import time
from typing import Any, ClassVar, assert_never
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Horizontal, VerticalScroll
from textual.events import MouseUp
from textual.events import AppBlur, AppFocus, MouseUp
from textual.widget import Widget
from textual.widgets import Static
from vibe import __version__ as CORE_VERSION
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.commands import CommandRegistry
from vibe.cli.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
@ -28,6 +31,7 @@ from vibe.cli.textual_ui.widgets.messages import (
InterruptMessage,
UserCommandMessage,
UserMessage,
WarningMessage,
)
from vibe.cli.textual_ui.widgets.mode_indicator import ModeIndicator
from vibe.cli.textual_ui.widgets.path_display import PathDisplay
@ -42,13 +46,13 @@ from vibe.cli.update_notifier import (
VersionUpdateGateway,
get_update_if_available,
)
from vibe.core import __version__ as CORE_VERSION
from vibe.core.agent import Agent
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.config_path import HISTORY_FILE
from vibe.core.modes import AgentMode, next_mode
from vibe.core.paths.config_paths import HISTORY_FILE
from vibe.core.tools.base import BaseToolConfig, ToolPermission
from vibe.core.types import ApprovalResponse, LLMMessage, ResumeSessionInfo, Role
from vibe.core.types import ApprovalResponse, LLMMessage, Role
from vibe.core.utils import (
CancellationReason,
get_user_cancellation_message,
@ -68,7 +72,8 @@ class VibeApp(App):
CSS_PATH = "app.tcss"
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "force_quit", "Quit", show=False),
Binding("ctrl+c", "clear_quit", "Quit", show=False),
Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True),
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False),
Binding("ctrl+t", "toggle_todo", "Toggle Todo", show=False),
@ -82,11 +87,10 @@ class VibeApp(App):
def __init__(
self,
config: VibeConfig,
auto_approve: bool = False,
initial_mode: AgentMode = AgentMode.DEFAULT,
enable_streaming: bool = False,
initial_prompt: str | None = None,
loaded_messages: list[LLMMessage] | None = None,
session_info: ResumeSessionInfo | None = None,
version_update_notifier: VersionUpdateGateway | None = None,
update_cache_repository: UpdateCacheRepository | None = None,
current_version: str = CORE_VERSION,
@ -94,7 +98,7 @@ class VibeApp(App):
) -> None:
super().__init__(**kwargs)
self.config = config
self.auto_approve = auto_approve
self._current_agent_mode = initial_mode
self.enable_streaming = enable_streaming
self.agent: Agent | None = None
self._agent_running = False
@ -128,12 +132,12 @@ class VibeApp(App):
self._initial_prompt = initial_prompt
self._loaded_messages = loaded_messages
self._session_info = session_info
self._agent_init_task: asyncio.Task | None = None
# prevent a race condition where the agent initialization
# completes exactly at the moment the user interrupts
self._agent_init_interrupted = False
self._auto_scroll = True
self._last_escape_time: float | None = None
def compose(self) -> ComposeResult:
with VerticalScroll(id="chat"):
@ -142,7 +146,7 @@ class VibeApp(App):
with Horizontal(id="loading-area"):
yield Static(id="loading-area-content")
yield ModeIndicator(auto_approve=self.auto_approve)
yield ModeIndicator(mode=self._current_agent_mode)
yield Static(id="todo-area")
@ -151,7 +155,7 @@ class VibeApp(App):
history_file=self.history_file,
command_registry=self.commands,
id="input-container",
show_warning=self.auto_approve,
safety=self._current_agent_mode.safety,
)
with Horizontal(id="bottom-bar"):
@ -184,8 +188,8 @@ class VibeApp(App):
await self._show_dangerous_directory_warning()
self._schedule_update_notification()
if self._session_info:
await self._mount_and_scroll(AssistantMessage(self._session_info.message()))
if self._loaded_messages:
await self._rebuild_history_from_messages()
if self._initial_prompt:
self.call_after_refresh(self._process_initial_prompt)
@ -258,6 +262,21 @@ class VibeApp(App):
if self._loading_widget and self._loading_widget.parent:
await self._loading_widget.remove()
self._loading_widget = None
self._hide_todo_area()
def _show_todo_area(self) -> None:
try:
todo_area = self.query_one("#todo-area")
todo_area.add_class("loading-active")
except Exception:
pass
def _hide_todo_area(self) -> None:
try:
todo_area = self.query_one("#todo-area")
todo_area.remove_class("loading-active")
except Exception:
pass
def on_config_app_setting_changed(self, message: ConfigApp.SettingChanged) -> None:
if message.key == "textual_theme":
@ -307,6 +326,7 @@ class VibeApp(App):
async def _handle_command(self, user_input: str) -> bool:
if command := self.commands.find_command(user_input):
await self._mount_and_scroll(UserMessage(user_input))
handler = getattr(self, command.handler)
if asyncio.iscoroutinefunction(handler):
await handler()
@ -419,11 +439,11 @@ class VibeApp(App):
try:
agent = Agent(
self.config,
auto_approve=self.auto_approve,
mode=self._current_agent_mode,
enable_streaming=self.enable_streaming,
)
if not self.auto_approve:
if not self._current_agent_mode.auto_approve:
agent.approval_callback = self._approval_callback
if self._loaded_messages:
@ -450,6 +470,58 @@ class VibeApp(App):
self._agent_initializing = False
self._agent_init_task = None
async def _rebuild_history_from_messages(self) -> None:
if not self._loaded_messages:
return
messages_area = self.query_one("#messages")
tool_call_map: dict[str, str] = {}
for msg in self._loaded_messages:
if msg.role == Role.system:
continue
match msg.role:
case Role.user:
if msg.content:
await messages_area.mount(UserMessage(msg.content))
case Role.assistant:
await self._mount_history_assistant_message(
msg, messages_area, tool_call_map
)
case Role.tool:
tool_name = msg.name or tool_call_map.get(
msg.tool_call_id or "", "tool"
)
await messages_area.mount(
ToolResultMessage(
tool_name=tool_name,
content=msg.content,
collapsed=self._tools_collapsed,
)
)
async def _mount_history_assistant_message(
self, msg: LLMMessage, messages_area: Widget, tool_call_map: dict[str, str]
) -> None:
if msg.content:
widget = AssistantMessage(msg.content)
await messages_area.mount(widget)
await widget.write_initial_content()
await widget.stop_stream()
if not msg.tool_calls:
return
for tool_call in msg.tool_calls:
tool_name = tool_call.function.name or "unknown"
if tool_call.id:
tool_call_map[tool_call.id] = tool_name
await messages_area.mount(ToolCallMessage(tool_name=tool_name))
def _ensure_agent_init_task(self) -> asyncio.Task | None:
if self.agent:
self._agent_init_task = None
@ -486,6 +558,7 @@ class VibeApp(App):
loading = LoadingWidget()
self._loading_widget = loading
await loading_area.mount(loading)
self._show_todo_area()
try:
rendered_prompt = render_path_prompt(
@ -527,6 +600,7 @@ class VibeApp(App):
if self._loading_widget:
await self._loading_widget.remove()
self._loading_widget = None
self._hide_todo_area()
await self._finalize_current_streaming_message()
async def _interrupt_agent(self) -> None:
@ -563,6 +637,8 @@ class VibeApp(App):
self._agent_running = False
loading_area = self.query_one("#loading-area-content")
await loading_area.remove_children()
self._loading_widget = None
self._hide_todo_area()
await self._finalize_current_streaming_message()
await self._mount_and_scroll(InterruptMessage())
@ -603,7 +679,7 @@ class VibeApp(App):
async def _reload_config(self) -> None:
try:
new_config = VibeConfig.load()
new_config = VibeConfig.load(**self._current_agent_mode.config_overrides)
if self.agent:
await self.agent.reload_with_initial_messages(config=new_config)
@ -656,6 +732,7 @@ class VibeApp(App):
max_tokens=current_state.max_tokens,
current_tokens=self.agent.stats.context_tokens,
)
await messages_area.mount(UserMessage("/clear"))
await self._mount_and_scroll(
UserCommandMessage("Conversation history cleared!")
)
@ -738,23 +815,68 @@ class VibeApp(App):
self.event_handler.current_compact = compact_msg
await self._mount_and_scroll(compact_msg)
self._agent_task = asyncio.create_task(
self._run_compact(compact_msg, old_tokens)
)
async def _run_compact(self, compact_msg: CompactMessage, old_tokens: int) -> None:
self._agent_running = True
try:
if not self.agent:
return
await self.agent.compact()
new_tokens = self.agent.stats.context_tokens
compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens)
self.event_handler.current_compact = None
if self._context_progress:
current_state = self._context_progress.tokens
self._context_progress.tokens = TokenState(
max_tokens=current_state.max_tokens, current_tokens=new_tokens
)
except asyncio.CancelledError:
compact_msg.set_error("Compaction interrupted")
raise
except Exception as e:
compact_msg.set_error(str(e))
self.event_handler.current_compact = None
finally:
self._agent_running = False
self._agent_task = None
if self.event_handler:
self.event_handler.current_compact = None
def _get_session_resume_info(self) -> str | None:
if not self.agent:
return None
if not self.agent.interaction_logger.enabled:
return None
if not self.agent.interaction_logger.session_id:
return None
return self.agent.interaction_logger.session_id[:8]
async def _exit_app(self) -> None:
self.exit()
self.exit(result=self._get_session_resume_info())
async def _setup_terminal(self) -> None:
result = setup_terminal()
if result.success:
if result.requires_restart:
await self._mount_and_scroll(
UserCommandMessage(
f"{result.terminal.value}: Set up Shift+Enter keybind (You may need to restart your terminal.)"
)
)
else:
await self._mount_and_scroll(
WarningMessage(
f"{result.terminal.value}: Shift+Enter keybind already set up"
)
)
else:
await self._mount_and_scroll(
ErrorMessage(result.message, collapsed=self._tools_collapsed)
)
async def _switch_to_config_app(self) -> None:
if self._current_bottom_app == BottomApp.Config:
@ -833,7 +955,7 @@ class VibeApp(App):
history_file=self.history_file,
command_registry=self.commands,
id="input-container",
show_warning=self.auto_approve,
safety=self._current_agent_mode.safety,
)
await bottom_container.mount(chat_input_container)
self._chat_input_container = chat_input_container
@ -857,12 +979,15 @@ class VibeApp(App):
pass
def action_interrupt(self) -> None:
current_time = time.monotonic()
if self._current_bottom_app == BottomApp.Config:
try:
config_app = self.query_one(ConfigApp)
config_app.action_close()
except Exception:
pass
self._last_escape_time = None
return
if self._current_bottom_app == BottomApp.Approval:
@ -871,8 +996,23 @@ class VibeApp(App):
approval_app.action_reject()
except Exception:
pass
self._last_escape_time = None
return
if (
self._current_bottom_app == BottomApp.Input
and self._last_escape_time is not None
and (current_time - self._last_escape_time) < 0.2 # noqa: PLR2004
):
try:
input_widget = self.query_one(ChatInputContainer)
if input_widget.value:
input_widget.value = ""
self._last_escape_time = None
return
except Exception:
pass
has_pending_user_message = any(
msg.has_class("pending") for msg in self.query(UserMessage)
)
@ -886,71 +1026,72 @@ class VibeApp(App):
if interrupt_needed:
self.run_worker(self._interrupt_agent(), exclusive=False)
self._last_escape_time = current_time
self._scroll_to_bottom()
self._focus_current_bottom_app()
async def action_toggle_tool(self) -> None:
if not self.event_handler:
return
self._tools_collapsed = not self._tools_collapsed
non_todo_results = [
result
for result in self.event_handler.tool_results
if result.event.tool_name != "todo"
]
for result in non_todo_results:
result.collapsed = self._tools_collapsed
await result.render_result()
for result in self.query(ToolResultMessage):
if result.tool_name != "todo":
await result.set_collapsed(self._tools_collapsed)
try:
error_messages = self.query(ErrorMessage)
for error_msg in error_messages:
for error_msg in self.query(ErrorMessage):
error_msg.set_collapsed(self._tools_collapsed)
except Exception:
pass
async def action_toggle_todo(self) -> None:
if not self.event_handler:
return
self._todos_collapsed = not self._todos_collapsed
todo_results = [
result
for result in self.event_handler.tool_results
if result.event.tool_name == "todo"
]
for result in todo_results:
result.collapsed = self._todos_collapsed
await result.render_result()
for result in self.query(ToolResultMessage):
if result.tool_name == "todo":
await result.set_collapsed(self._todos_collapsed)
def action_cycle_mode(self) -> None:
if self._current_bottom_app != BottomApp.Input:
return
self.auto_approve = not self.auto_approve
new_mode = next_mode(self._current_agent_mode)
self._switch_mode(new_mode)
def _switch_mode(self, mode: AgentMode) -> None:
if mode == self._current_agent_mode:
return
self._current_agent_mode = mode
if self._mode_indicator:
self._mode_indicator.set_auto_approve(self.auto_approve)
self._mode_indicator.set_mode(mode)
if self._chat_input_container:
self._chat_input_container.set_show_warning(self.auto_approve)
self._chat_input_container.set_safety(mode.safety)
if self.agent:
self.agent.auto_approve = self.auto_approve
if self.auto_approve:
if mode.auto_approve:
self.agent.approval_callback = None
else:
self.agent.approval_callback = self._approval_callback
self.run_worker(
self._do_agent_switch(mode), group="mode_switch", exclusive=True
)
self._focus_current_bottom_app()
def action_force_quit(self) -> None:
async def _do_agent_switch(self, mode: AgentMode) -> None:
if self.agent:
await self.agent.switch_mode(mode)
if self._context_progress:
current_state = self._context_progress.tokens
self._context_progress.tokens = TokenState(
max_tokens=current_state.max_tokens,
current_tokens=self.agent.stats.context_tokens,
)
def action_clear_quit(self) -> None:
input_widgets = self.query(ChatInputContainer)
if input_widgets:
input_widget = input_widgets.first()
@ -958,10 +1099,13 @@ class VibeApp(App):
input_widget.value = ""
return
self.action_force_quit()
def action_force_quit(self) -> None:
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
self.exit()
self.exit(result=self._get_session_resume_info())
def action_scroll_chat_up(self) -> None:
try:
@ -984,7 +1128,7 @@ class VibeApp(App):
is_dangerous, reason = is_dangerous_directory()
if is_dangerous:
warning = (
f" WARNING: {reason}\n\nRunning in this location is not recommended."
f" WARNING: {reason}\n\nRunning in this location is not recommended."
)
await self._mount_and_scroll(UserCommandMessage(warning))
@ -1110,25 +1254,42 @@ class VibeApp(App):
def on_mouse_up(self, event: MouseUp) -> None:
copy_selection_to_clipboard(self)
def on_app_blur(self, event: AppBlur) -> None:
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(False)
def on_app_focus(self, event: AppFocus) -> None:
if self._chat_input_container and self._chat_input_container.input_widget:
self._chat_input_container.input_widget.set_app_focus(True)
def _print_session_resume_message(session_id: str | None) -> None:
if not session_id:
return
print()
print("To continue this session, run: vibe --continue")
print(f"Or: vibe --resume {session_id}")
def run_textual_ui(
config: VibeConfig,
auto_approve: bool = False,
initial_mode: AgentMode = AgentMode.DEFAULT,
enable_streaming: bool = False,
initial_prompt: str | None = None,
loaded_messages: list[LLMMessage] | None = None,
session_info: ResumeSessionInfo | None = None,
) -> None:
"""Run the Textual UI."""
update_notifier = PyPIVersionUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
app = VibeApp(
config=config,
auto_approve=auto_approve,
initial_mode=initial_mode,
enable_streaming=enable_streaming,
initial_prompt=initial_prompt,
loaded_messages=loaded_messages,
session_info=session_info,
version_update_notifier=update_notifier,
update_cache_repository=update_cache_repository,
)
app.run()
session_id = app.run()
_print_session_resume_message(session_id)

View file

@ -6,14 +6,14 @@ Screen {
height: 1fr;
width: 100%;
background: $background;
padding: 0 2 0 2;
padding: 0;
}
#loading-area {
height: auto;
width: 100%;
background: $background;
padding: 1 2 0 2;
padding: 1 0 0 0;
layout: horizontal;
align: left middle;
}
@ -26,14 +26,28 @@ Screen {
#todo-area {
height: auto;
max-height: 8;
max-height: 10;
width: 100%;
background: $background;
margin: 0;
padding: 0;
overflow-y: auto;
overflow: hidden;
text-align: left;
margin-top: 0;
.tool-result-border {
display: none;
}
&.loading-active .tool-result-border {
display: block;
}
.tool-result-content {
max-height: 9;
overflow-y: auto;
scrollbar-visibility: hidden;
}
}
#bottom-app-container {
@ -46,7 +60,7 @@ Screen {
height: auto;
width: 100%;
background: $background;
padding: 0 2 1 2;
padding: 0 0 1 0;
align: left middle;
layout: horizontal;
}
@ -71,7 +85,7 @@ Screen {
width: 100%;
background: $background;
padding: 0;
margin: 0 2 1 2;
margin: 0;
}
#completion-popup {
@ -84,11 +98,23 @@ Screen {
height: auto;
width: 100%;
background: $background;
border: round $foreground-muted;
border-top: solid $foreground-muted;
border-bottom: solid $foreground-muted;
padding: 0 1;
&.border-warning {
border: round $warning;
border-top: solid $warning;
border-bottom: solid $warning;
}
&.border-safe {
border-top: solid $success;
border-bottom: solid $success;
}
&.border-error {
border-top: solid $error;
border-bottom: solid $error;
}
}
@ -112,12 +138,13 @@ Screen {
color: $text;
border: none;
padding: 0;
scrollbar-visibility: hidden;
}
ToastRack {
align: left bottom;
padding: 0 2;
margin: 0 2 6 2;
padding: 0;
margin: 0 0 6 0;
}
Markdown MarkdownFence {
@ -128,8 +155,10 @@ Markdown MarkdownFence {
.user-message {
margin-top: 1;
padding: 1 0;
width: 100%;
height: auto;
background: $surface;
&:first-child {
margin-top: 0;
@ -194,25 +223,135 @@ Markdown MarkdownFence {
}
}
.assistant-message-content Markdown > *:last-child {
margin-bottom: 0;
}
.interrupt-message {
margin-top: 0;
margin-bottom: 0;
margin-left: 2;
height: auto;
padding: 1 2;
background: $warning 10%;
width: 100%;
}
.interrupt-container {
width: 100%;
height: auto;
margin: 0;
padding: 0;
}
.interrupt-border {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: $foreground-muted;
}
.interrupt-content {
width: 1fr;
height: auto;
margin: 0;
color: $text-warning;
}
.error-message {
margin-top: 1;
margin-top: 0;
margin-bottom: 0;
height: auto;
padding: 1 2;
background: $error 10%;
width: 100%;
}
.error-container {
width: 100%;
height: auto;
margin: 0;
padding: 0;
}
.error-border {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: $foreground-muted;
}
.error-content {
width: 1fr;
height: auto;
margin: 0;
color: $error;
text-style: bold;
}
.warning-message {
margin-top: 0;
margin-bottom: 0;
height: auto;
width: 100%;
}
.warning-container {
width: 100%;
height: auto;
margin: 0;
padding: 0;
}
.warning-border {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: $foreground-muted;
}
.warning-content {
width: 1fr;
height: auto;
margin: 0;
color: $text-warning;
}
.user-command-message {
margin-top: 0;
margin-bottom: 0;
height: auto;
width: 100%;
}
.user-command-container {
width: 100%;
height: auto;
margin: 0;
padding: 0;
}
.user-command-border {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: $foreground-muted;
}
.user-command-content {
width: 1fr;
height: auto;
margin: 0;
Markdown {
margin: 0;
padding: 0;
}
Markdown > *:first-child {
margin-top: 0;
}
Markdown > *:last-child {
margin-bottom: 0;
}
}
.bash-output-message {
margin-top: 1;
width: 100%;
@ -280,7 +419,7 @@ Markdown MarkdownFence {
color: $text-muted;
}
BlinkingMessage {
StatusMessage {
width: 100%;
height: auto;
@ -290,10 +429,11 @@ BlinkingMessage {
}
}
.blink-dot {
.status-indicator-icon {
width: auto;
height: auto;
color: $foreground;
margin-right: 1;
&.success {
color: $text-success;
@ -304,7 +444,7 @@ BlinkingMessage {
}
}
.blink-text {
.status-indicator-text {
width: 1fr;
height: auto;
color: $foreground;
@ -326,22 +466,40 @@ BlinkingMessage {
width: 100%;
height: auto;
margin-top: 0;
margin-left: 2;
padding: 1 2;
background: $surface;
margin-left: 0;
padding: 0;
background: transparent;
color: $foreground;
&.error-text {
background: $error 10%;
color: $text-error;
}
&.warning-text {
background: $warning 10%;
color: $text-warning;
}
}
.tool-result-container {
width: 100%;
height: auto;
margin-top: 0;
padding: 0;
}
.tool-result-border {
width: auto;
height: 100%;
padding: 0 1 0 2;
color: $foreground-muted;
}
.tool-result-content {
width: 1fr;
height: auto;
padding: 0;
}
.tool-call-widget {
width: 100%;
height: auto;
@ -430,7 +588,6 @@ BlinkingMessage {
#todo-area .tool-result {
margin-left: 0;
background: $surface;
}
.loading-widget {
@ -443,10 +600,11 @@ BlinkingMessage {
height: auto;
}
.loading-star {
.loading-indicator {
width: auto;
height: auto;
color: $warning;
margin-right: 1;
}
.loading-status {
@ -478,8 +636,8 @@ WelcomeBanner {
border-title-align: center;
text-align: center;
content-align: center middle;
padding: 2 4;
margin: 1 1 0 1;
padding: 2 0;
margin: 1 0 0 0;
color: $foreground;
.muted {
@ -493,7 +651,7 @@ WelcomeBanner {
background: $background;
border: round $foreground-muted;
padding: 0 1;
margin: 0 2 1 2;
margin: 0 0 1 0;
}
#config-content {
@ -556,7 +714,7 @@ WelcomeBanner {
background: $background;
border: round $foreground-muted;
padding: 0 1;
margin: 0 2 1 2;
margin: 0 0 1 0;
}
#approval-content {
@ -645,21 +803,35 @@ Horizontal {
height: auto;
}
ExpandingBorder {
width: auto;
height: 100%;
content-align: left bottom;
}
ModeIndicator {
width: auto;
height: auto;
background: transparent;
padding: 0;
margin: 0 0 0 1;
color: $warning;
color: $text-muted;
align: left middle;
&.mode-on {
&.mode-safe {
color: $success;
}
&.mode-neutral {
color: $text-muted;
}
&.mode-destructive {
color: $warning;
}
&.mode-off {
color: $text-muted;
&.mode-yolo {
color: $error;
}
}

View file

@ -146,12 +146,12 @@ class EventHandler:
def stop_current_tool_call(self) -> None:
if self.current_tool_call:
self.current_tool_call.stop_blinking()
self.current_tool_call.stop_spinning()
self.current_tool_call = None
def stop_current_compact(self) -> None:
if self.current_compact:
self.current_compact.stop_blinking(success=False)
self.current_compact.stop_spinning(success=False)
self.current_compact = None
def get_last_tool_result(self) -> ToolResultMessage | None:

View file

@ -110,7 +110,7 @@ class ApprovalApp(Container):
def _update_options(self) -> None:
options = [
("Yes", "yes"),
(f"Yes and always allow {self.tool_name} this session", "yes"),
(f"Yes and always allow {self.tool_name} for this session", "yes"),
("No and tell the agent what to do instead", "no"),
]

View file

@ -1,67 +0,0 @@
from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
class BlinkingMessage(Static):
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
self.blink_state = False
self._blink_timer = None
self._is_blinking = True
self.success = True
self._initial_text = initial_text
self._dot_widget: Static | None = None
self._text_widget: Static | None = None
super().__init__(**kwargs)
def compose(self) -> ComposeResult:
with Horizontal():
self._dot_widget = Static("", classes="blink-dot")
yield self._dot_widget
self._text_widget = Static("", markup=False, classes="blink-text")
yield self._text_widget
def on_mount(self) -> None:
self.update_display()
self._blink_timer = self.set_interval(0.5, self.toggle_blink)
def toggle_blink(self) -> None:
if not self._is_blinking:
return
self.blink_state = not self.blink_state
self.update_display()
def update_display(self) -> None:
if not self._dot_widget or not self._text_widget:
return
content = self.get_content()
if self._is_blinking:
dot = "" if self.blink_state else ""
self._dot_widget.update(dot)
self._dot_widget.remove_class("success")
self._dot_widget.remove_class("error")
else:
self._dot_widget.update("")
if self.success:
self._dot_widget.add_class("success")
self._dot_widget.remove_class("error")
else:
self._dot_widget.add_class("error")
self._dot_widget.remove_class("success")
self._text_widget.update(content)
def get_content(self) -> str:
return self._initial_text
def stop_blinking(self, success: bool = True) -> None:
self._is_blinking = False
self.blink_state = True
self.success = success
self.update_display()

View file

@ -11,7 +11,7 @@ from textual.widget import Widget
from textual.widgets import Static
from vibe.cli.history_manager import HistoryManager
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
class ChatInputBody(Widget):
@ -44,26 +44,35 @@ class ChatInputBody(Widget):
if self.input_widget:
self.input_widget.focus()
def _parse_mode_and_text(self, text: str) -> tuple[InputMode, str]:
if text.startswith("!"):
return "!", text[1:]
elif text.startswith("/"):
return "/", text[1:]
else:
return ">", text
def _update_prompt(self) -> None:
if not self.input_widget or not self.prompt_widget:
return
text = self.input_widget.text
if text.startswith("!"):
self.prompt_widget.update("!")
elif text.startswith("/"):
self.prompt_widget.update("/")
else:
self.prompt_widget.update(">")
self.prompt_widget.update(self.input_widget.input_mode)
def on_chat_text_area_mode_changed(self, event: ChatTextArea.ModeChanged) -> None:
if self.prompt_widget:
self.prompt_widget.update(event.mode)
def _load_history_entry(self, text: str, cursor_col: int | None = None) -> None:
if not self.input_widget:
return
self.input_widget._navigating_history = True
self.input_widget.load_text(text)
mode, display_text = self._parse_mode_and_text(text)
first_line = text.split("\n")[0] if text else ""
self.input_widget._navigating_history = True
self.input_widget.set_mode(mode)
self.input_widget.load_text(display_text)
first_line = display_text.split("\n")[0]
col = cursor_col if cursor_col is not None else len(first_line)
cursor_pos = (0, col)
@ -137,9 +146,6 @@ class ChatInputBody(Widget):
self.input_widget._cursor_pos_after_load = None
self.input_widget._cursor_moved_since_load = False
def on_text_area_changed(self, event: ChatTextArea.Changed) -> None:
self._update_prompt()
def on_chat_text_area_submitted(self, event: ChatTextArea.Submitted) -> None:
event.stop()
@ -161,12 +167,16 @@ class ChatInputBody(Widget):
@property
def value(self) -> str:
return self.input_widget.text if self.input_widget else ""
if not self.input_widget:
return ""
return self.input_widget.get_full_text()
@value.setter
def value(self, text: str) -> None:
if self.input_widget:
self.input_widget.load_text(text)
mode, display_text = self._parse_mode_and_text(text)
self.input_widget.set_mode(mode)
self.input_widget.load_text(display_text)
self._update_prompt()
def focus_input(self) -> None:

View file

@ -17,11 +17,17 @@ 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.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",
}
class ChatInputContainer(Vertical):
ID_INPUT_BOX = "input-box"
BORDER_WARNING_CLASS = "border-warning"
class Submitted(Message):
def __init__(self, value: str) -> None:
@ -32,13 +38,13 @@ class ChatInputContainer(Vertical):
self,
history_file: Path | None = None,
command_registry: CommandRegistry | None = None,
show_warning: bool = False,
safety: ModeSafety = ModeSafety.NEUTRAL,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._history_file = history_file
self._command_registry = command_registry or CommandRegistry()
self._show_warning = show_warning
self._safety = safety
command_entries = [
(alias, command.description)
@ -57,9 +63,8 @@ class ChatInputContainer(Vertical):
self._completion_popup = CompletionPopup()
yield self._completion_popup
with Vertical(
id=self.ID_INPUT_BOX, classes="border-warning" if self._show_warning else ""
):
border_class = SAFETY_BORDER_CLASSES.get(self._safety, "")
with Vertical(id=self.ID_INPUT_BOX, classes=border_class):
self._body = ChatInputBody(history_file=self._history_file, id="input-body")
yield self._body
@ -91,7 +96,7 @@ class ChatInputContainer(Vertical):
widget = self._body.input_widget
if widget:
self._completion_manager.on_text_changed(
widget.text, widget.get_cursor_offset()
widget.get_full_text(), widget._get_full_cursor_offset()
)
def focus_input(self) -> None:
@ -131,6 +136,9 @@ class ChatInputContainer(Vertical):
widget = self.input_widget
if not widget or not self._body:
return
start, end, replacement = widget.adjust_from_full_text_coords(
start, end, replacement
)
text = widget.text
start = max(0, min(start, len(text)))
@ -147,11 +155,12 @@ class ChatInputContainer(Vertical):
event.stop()
self.post_message(self.Submitted(event.value))
def set_show_warning(self, show_warning: bool) -> None:
self._show_warning = show_warning
def set_safety(self, safety: ModeSafety) -> None:
self._safety = safety
input_box = self.get_widget_by_id(self.ID_INPUT_BOX)
if show_warning:
input_box.add_class(self.BORDER_WARNING_CLASS)
else:
input_box.remove_class(self.BORDER_WARNING_CLASS)
for border_class in SAFETY_BORDER_CLASSES.values():
input_box.remove_class(border_class)
if safety in SAFETY_BORDER_CLASSES:
input_box.add_class(SAFETY_BORDER_CLASSES[safety])

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Any, ClassVar
from typing import Any, ClassVar, Literal
from textual import events
from textual.binding import Binding
@ -12,6 +12,8 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
MultiCompletionManager,
)
InputMode = Literal["!", "/", ">"]
class ChatTextArea(TextArea):
BINDINGS: ClassVar[list[Binding]] = [
@ -24,6 +26,9 @@ class ChatTextArea(TextArea):
)
]
MODE_CHARACTERS: ClassVar[set[Literal["!", "/"]]] = {"!", "/"}
DEFAULT_MODE: ClassVar[Literal[">"]] = ">"
class Submitted(Message):
def __init__(self, value: str) -> None:
self.value = value
@ -42,8 +47,16 @@ class ChatTextArea(TextArea):
class HistoryReset(Message):
"""Message sent when history navigation should be reset."""
class ModeChanged(Message):
"""Message sent when the input mode changes (>, !, /)."""
def __init__(self, mode: InputMode) -> None:
self.mode = mode
super().__init__()
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._input_mode: InputMode = self.DEFAULT_MODE
self._history_prefix: str | None = None
self._last_text = ""
self._navigating_history = False
@ -53,9 +66,17 @@ class ChatTextArea(TextArea):
self._cursor_pos_after_load: tuple[int, int] | None = None
self._cursor_moved_since_load: bool = False
self._completion_manager: MultiCompletionManager | None = None
self._app_has_focus: bool = True
def on_blur(self, event: events.Blur) -> None:
self.call_after_refresh(self.focus)
if self._app_has_focus:
self.call_after_refresh(self.focus)
def set_app_focus(self, has_focus: bool) -> None:
self._app_has_focus = has_focus
self.cursor_blink = has_focus
if has_focus and not self.has_focus:
self.call_after_refresh(self.focus)
def on_click(self, event: events.Click) -> None:
self._mark_cursor_moved_if_needed()
@ -76,7 +97,7 @@ class ChatTextArea(TextArea):
if self._completion_manager and not was_navigating_history:
self._completion_manager.on_text_changed(
self.text, self.get_cursor_offset()
self.get_full_text(), self._get_full_cursor_offset()
)
def _reset_prefix(self) -> None:
@ -96,7 +117,10 @@ class ChatTextArea(TextArea):
cursor_row, cursor_col = self.cursor_location
lines = self.text.split("\n")
if cursor_row < len(lines):
return lines[cursor_row][:cursor_col]
visible_prefix = lines[cursor_row][:cursor_col]
if cursor_row == 0 and self._input_mode != self.DEFAULT_MODE:
return self._input_mode + visible_prefix
return visible_prefix
return ""
def _handle_history_up(self) -> bool:
@ -139,12 +163,14 @@ class ChatTextArea(TextArea):
self.post_message(self.HistoryNext(self._history_prefix))
return True
async def _on_key(self, event: events.Key) -> None:
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
self._mark_cursor_moved_if_needed()
manager = self._completion_manager
if manager:
match manager.on_key(event, self.text, self.get_cursor_offset()):
match manager.on_key(
event, self.get_full_text(), self._get_full_cursor_offset()
):
case CompletionResult.HANDLED:
event.prevent_default()
event.stop()
@ -152,7 +178,7 @@ class ChatTextArea(TextArea):
case CompletionResult.SUBMIT:
event.prevent_default()
event.stop()
value = self.text.strip()
value = self.get_full_text().strip()
if value:
self._reset_prefix()
self.post_message(self.Submitted(value))
@ -161,7 +187,7 @@ class ChatTextArea(TextArea):
if event.key == "enter":
event.prevent_default()
event.stop()
value = self.text.strip()
value = self.get_full_text().strip()
if value:
self._reset_prefix()
self.post_message(self.Submitted(value))
@ -172,6 +198,23 @@ class ChatTextArea(TextArea):
event.stop()
return
if (
event.character
and event.character in self.MODE_CHARACTERS
and not self.text
and self._input_mode == self.DEFAULT_MODE
):
self._set_mode(event.character)
event.prevent_default()
event.stop()
return
if event.key == "backspace" and self._should_reset_mode_on_backspace():
self._set_mode(self.DEFAULT_MODE)
event.prevent_default()
event.stop()
return
if event.key == "up" and self._handle_history_up():
event.prevent_default()
event.stop()
@ -189,7 +232,7 @@ class ChatTextArea(TextArea):
self._completion_manager = manager
if self._completion_manager:
self._completion_manager.on_text_changed(
self.text, self.get_cursor_offset()
self.get_full_text(), self._get_full_cursor_offset()
)
def get_cursor_offset(self) -> int:
@ -244,3 +287,59 @@ class ChatTextArea(TextArea):
def clear_text(self) -> None:
self.clear()
self.reset_history_state()
self._set_mode(self.DEFAULT_MODE)
def _set_mode(self, mode: InputMode) -> None:
if self._input_mode == mode:
return
self._input_mode = mode
self.post_message(self.ModeChanged(mode))
if self._completion_manager:
self._completion_manager.on_text_changed(
self.get_full_text(), self._get_full_cursor_offset()
)
def _should_reset_mode_on_backspace(self) -> bool:
return (
self._input_mode != self.DEFAULT_MODE
and not self.text
and self.get_cursor_offset() == 0
)
def get_full_text(self) -> str:
if self._input_mode != self.DEFAULT_MODE:
return self._input_mode + self.text
return self.text
def _get_full_cursor_offset(self) -> int:
return self.get_cursor_offset() + self._get_mode_prefix_length()
def _get_mode_prefix_length(self) -> int:
return {">": 0, "/": 1, "!": 1}[self._input_mode]
@property
def input_mode(self) -> InputMode:
return self._input_mode
def set_mode(self, mode: InputMode) -> None:
if self._input_mode != mode:
self._input_mode = mode
self.post_message(self.ModeChanged(mode))
def adjust_from_full_text_coords(
self, start: int, end: int, replacement: str
) -> tuple[int, int, str]:
"""Translate from full-text coordinates to widget coordinates.
The completion manager works with 'full text' that includes the mode prefix.
This adjusts coordinates and replacement text for the actual widget text.
"""
mode_len = self._get_mode_prefix_length()
adj_start = max(0, start - mode_len)
adj_end = max(adj_start, end - mode_len)
if mode_len > 0 and replacement.startswith(self._input_mode):
replacement = replacement[mode_len:]
return adj_start, adj_end, replacement

View file

@ -1,9 +1,9 @@
from __future__ import annotations
from vibe.cli.textual_ui.widgets.blinking_message import BlinkingMessage
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
class CompactMessage(BlinkingMessage):
class CompactMessage(StatusMessage):
def __init__(self) -> None:
super().__init__()
self.add_class("compact-message")
@ -12,7 +12,7 @@ class CompactMessage(BlinkingMessage):
self.error_message: str | None = None
def get_content(self) -> str:
if self._is_blinking:
if self._is_spinning:
return "Compacting conversation history..."
if self.error_message:
@ -35,8 +35,8 @@ class CompactMessage(BlinkingMessage):
) -> None:
self.old_tokens = old_tokens
self.new_tokens = new_tokens
self.stop_blinking(success=True)
self.stop_spinning(success=True)
def set_error(self, error_message: str) -> None:
self.error_message = error_message
self.stop_blinking(success=False)
self.stop_spinning(success=False)

View file

@ -9,10 +9,10 @@ from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.spinner import BrailleSpinner
class LoadingWidget(Static):
BRAILLE_SPINNER = ("", "", "", "", "", "", "", "", "", "")
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
EASTER_EGGS: ClassVar[list[str]] = [
@ -51,8 +51,9 @@ class LoadingWidget(Static):
def __init__(self, status: str | None = None) -> None:
super().__init__(classes="loading-widget")
self.status = status or self._get_default_status()
self.gradient_offset = 0
self.spinner_pos = 0
self.current_color_index = 0
self.transition_progress = 0
self._spinner = BrailleSpinner()
self.char_widgets: list[Static] = []
self.spinner_widget: Static | None = None
self.ellipsis_widget: Static | None = None
@ -84,13 +85,12 @@ class LoadingWidget(Static):
def set_status(self, status: str) -> None:
self.status = self._apply_easter_egg(status)
self.gradient_offset = 0
self._rebuild_chars()
def compose(self) -> ComposeResult:
with Horizontal(classes="loading-container"):
self.spinner_widget = Static(
self.BRAILLE_SPINNER[0] + " ", classes="loading-star"
self._spinner.current_frame(), classes="loading-indicator"
)
yield self.spinner_widget
@ -127,30 +127,40 @@ class LoadingWidget(Static):
self.update_animation()
self.set_interval(0.1, self.update_animation)
def _get_gradient_color(self, position: int) -> str:
color_index = (position - self.gradient_offset) % len(self.TARGET_COLORS)
return self.TARGET_COLORS[color_index]
def _get_color_for_position(self, position: int) -> str:
current_color = self.TARGET_COLORS[self.current_color_index]
next_color = self.TARGET_COLORS[
(self.current_color_index + 1) % len(self.TARGET_COLORS)
]
if position < self.transition_progress:
return next_color
return current_color
def update_animation(self) -> None:
total_elements = 1 + len(self.char_widgets) + 2
if self.spinner_widget:
spinner_char = self.BRAILLE_SPINNER[self.spinner_pos]
color_0 = self._get_gradient_color(0)
color_1 = self._get_gradient_color(1)
self.spinner_widget.update(f"[{color_0}]{spinner_char}[/][{color_1}] [/]")
self.spinner_pos = (self.spinner_pos + 1) % len(self.BRAILLE_SPINNER)
spinner_char = self._spinner.next_frame()
color = self._get_color_for_position(0)
self.spinner_widget.update(f"[{color}]{spinner_char}[/]")
for i, widget in enumerate(self.char_widgets):
position = 2 + i
color = self._get_gradient_color(position)
position = 1 + i
color = self._get_color_for_position(position)
widget.update(f"[{color}]{self.status[i]}[/]")
if self.ellipsis_widget:
ellipsis_start = 2 + len(self.status)
color_ellipsis = self._get_gradient_color(ellipsis_start)
color_space = self._get_gradient_color(ellipsis_start + 1)
ellipsis_start = 1 + len(self.status)
color_ellipsis = self._get_color_for_position(ellipsis_start)
color_space = self._get_color_for_position(ellipsis_start + 1)
self.ellipsis_widget.update(f"[{color_ellipsis}]…[/][{color_space}] [/]")
self.gradient_offset = (self.gradient_offset + 1) % len(self.TARGET_COLORS)
self.transition_progress += 1
if self.transition_progress > total_elements:
self.current_color_index = (self.current_color_index + 1) % len(
self.TARGET_COLORS
)
self.transition_progress = 0
if self.hint_widget and self.start_time is not None:
elapsed = int(time() - self.start_time)

View file

@ -1,11 +1,35 @@
from __future__ import annotations
from typing import Any
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Markdown, Static
from textual.widgets._markdown import MarkdownStream
class NonSelectableStatic(Static):
@property
def text_selection(self) -> None:
return None
@text_selection.setter
def text_selection(self, value: Any) -> None:
pass
def get_selection(self, selection: Any) -> None:
return None
class ExpandingBorder(NonSelectableStatic):
def render(self) -> str:
height = self.size.height
return "\n".join([""] * (height - 1) + [""])
def on_resize(self) -> None:
self.refresh()
class UserMessage(Static):
def __init__(self, content: str, pending: bool = False) -> None:
super().__init__()
@ -15,7 +39,7 @@ class UserMessage(Static):
def compose(self) -> ComposeResult:
with Horizontal(classes="user-message-container"):
yield Static("> ", classes="user-message-prompt")
yield NonSelectableStatic("> ", classes="user-message-prompt")
yield Static(self._content, markup=False, classes="user-message-content")
if self._pending:
self.add_class("pending")
@ -43,7 +67,7 @@ class AssistantMessage(Static):
def compose(self) -> ComposeResult:
with Horizontal(classes="assistant-message-container"):
yield Static("", classes="assistant-message-dot")
yield NonSelectableStatic("", classes="assistant-message-dot")
with Vertical(classes="assistant-message-content"):
markdown = Markdown("")
self._markdown = markdown
@ -87,14 +111,25 @@ class UserCommandMessage(Static):
self._content = content
def compose(self) -> ComposeResult:
yield Markdown(self._content)
with Horizontal(classes="user-command-container"):
yield ExpandingBorder(classes="user-command-border")
with Vertical(classes="user-command-content"):
yield Markdown(self._content)
class InterruptMessage(Static):
def __init__(self) -> None:
super().__init__(
"Interrupted · What should Vibe do instead?", classes="interrupt-message"
)
super().__init__()
self.add_class("interrupt-message")
def compose(self) -> ComposeResult:
with Horizontal(classes="interrupt-container"):
yield ExpandingBorder(classes="interrupt-border")
yield Static(
"Interrupted · What should Vibe do instead?",
markup=False,
classes="interrupt-content",
)
class BashOutputMessage(Static):
@ -125,24 +160,41 @@ class BashOutputMessage(Static):
class ErrorMessage(Static):
def __init__(self, error: str, collapsed: bool = True) -> None:
super().__init__(classes="error-message")
super().__init__()
self.add_class("error-message")
self._error = error
self.collapsed = collapsed
self._content_widget: Static | None = None
def compose(self) -> ComposeResult:
with Horizontal(classes="error-container"):
yield ExpandingBorder(classes="error-border")
self._content_widget = Static(
self._get_text(), markup=False, classes="error-content"
)
yield self._content_widget
def _get_text(self) -> str:
if self.collapsed:
yield Static("Error. (ctrl+o to expand)", markup=False)
else:
yield Static(f"Error: {self._error}", markup=False)
return "Error. (ctrl+o to expand)"
return f"Error: {self._error}"
def set_collapsed(self, collapsed: bool) -> None:
if self.collapsed == collapsed:
return
self.collapsed = collapsed
self.remove_children()
if self._content_widget:
self._content_widget.update(self._get_text())
if self.collapsed:
self.mount(Static("Error. (ctrl+o to expand)", markup=False))
else:
self.mount(Static(f"Error: {self._error}", markup=False))
class WarningMessage(Static):
def __init__(self, message: str) -> None:
super().__init__()
self.add_class("warning-message")
self._message = message
def compose(self) -> ComposeResult:
with Horizontal(classes="warning-container"):
yield ExpandingBorder(classes="warning-border")
yield Static(self._message, markup=False, classes="warning-content")

View file

@ -2,24 +2,46 @@ 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):
def __init__(self, auto_approve: bool = False) -> None:
"""Displays the current agent mode with safety-colored indicator."""
def __init__(self, mode: AgentMode = AgentMode.DEFAULT) -> None:
super().__init__()
self.can_focus = False
self._auto_approve = auto_approve
self._mode = mode
self._update_display()
def _update_display(self) -> None:
if self._auto_approve:
self.update("⏵⏵ auto-approve on (shift+tab to toggle)")
self.add_class("mode-on")
self.remove_class("mode-off")
else:
self.update("⏵ auto-approve off (shift+tab to toggle)")
self.add_class("mode-off")
self.remove_class("mode-on")
icon = MODE_ICONS.get(self._mode, "??")
name = self._mode.display_name.lower()
self.update(f"{icon} {name} mode (shift+tab to cycle)")
def set_auto_approve(self, enabled: bool) -> None:
self._auto_approve = enabled
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,87 @@
from __future__ import annotations
from abc import ABC
from enum import Enum
from typing import ClassVar
class Spinner(ABC):
FRAMES: ClassVar[tuple[str, ...]]
def __init__(self) -> None:
self._position = 0
def next_frame(self) -> str:
frame = self.FRAMES[self._position]
self._position = (self._position + 1) % len(self.FRAMES)
return frame
def current_frame(self) -> str:
return self.FRAMES[self._position]
def reset(self) -> None:
self._position = 0
class BrailleSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = (
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
)
class LineSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = ("|", "/", "-", "\\")
class CircleSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = ("", "", "", "")
class BowtieSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = (
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
)
class DotWaveSpinner(Spinner):
FRAMES: ClassVar[tuple[str, ...]] = ("", "", "", "", "", "", "", "")
class SpinnerType(Enum):
BRAILLE = "braille"
LINE = "line"
CIRCLE = "circle"
BOWTIE = "bowtie"
DOT_WAVE = "dot_wave"
_SPINNER_CLASSES: dict[SpinnerType, type[Spinner]] = {
SpinnerType.BRAILLE: BrailleSpinner,
SpinnerType.LINE: LineSpinner,
SpinnerType.CIRCLE: CircleSpinner,
SpinnerType.BOWTIE: BowtieSpinner,
SpinnerType.DOT_WAVE: DotWaveSpinner,
}
def create_spinner(spinner_type: SpinnerType = SpinnerType.BRAILLE) -> Spinner:
spinner_class = _SPINNER_CLASSES.get(spinner_type, BrailleSpinner)
return spinner_class()

View file

@ -0,0 +1,75 @@
from __future__ import annotations
from typing import Any, ClassVar
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.messages import NonSelectableStatic
from vibe.cli.textual_ui.widgets.spinner import Spinner, SpinnerType, create_spinner
class StatusMessage(Static):
SPINNER_TYPE: ClassVar[SpinnerType] = SpinnerType.LINE
def __init__(self, initial_text: str = "", **kwargs: Any) -> None:
self._spinner: Spinner = create_spinner(self.SPINNER_TYPE)
self._spinner_timer = None
self._is_spinning = True
self.success = True
self._initial_text = initial_text
self._indicator_widget: Static | None = None
self._text_widget: Static | None = None
super().__init__(**kwargs)
def compose(self) -> ComposeResult:
with Horizontal():
self._indicator_widget = NonSelectableStatic(
self._spinner.current_frame(),
markup=False,
classes="status-indicator-icon",
)
yield self._indicator_widget
self._text_widget = Static(
"", markup=False, classes="status-indicator-text"
)
yield self._text_widget
def on_mount(self) -> None:
self.update_display()
self._spinner_timer = self.set_interval(0.1, self._update_spinner)
def _update_spinner(self) -> None:
if not self._is_spinning:
return
self.update_display()
def update_display(self) -> None:
if not self._indicator_widget or not self._text_widget:
return
content = self.get_content()
if self._is_spinning:
self._indicator_widget.update(self._spinner.next_frame())
self._indicator_widget.remove_class("success")
self._indicator_widget.remove_class("error")
elif self.success:
self._indicator_widget.update("")
self._indicator_widget.add_class("success")
self._indicator_widget.remove_class("error")
else:
self._indicator_widget.update("")
self._indicator_widget.add_class("error")
self._indicator_widget.remove_class("success")
self._text_widget.update(content)
def get_content(self) -> str:
return self._initial_text
def stop_spinning(self, success: bool = True) -> None:
self._is_spinning = False
self.success = success
self.update_display()

View file

@ -4,6 +4,8 @@ from textual.app import ComposeResult
from textual.containers import Vertical
from textual.widgets import Markdown, Static
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
class ToolApprovalWidget(Vertical):
def __init__(self, data: dict) -> None:
@ -27,17 +29,23 @@ class ToolApprovalWidget(Vertical):
class ToolResultWidget(Static):
SHORTCUT = DEFAULT_TOOL_SHORTCUT
def __init__(self, data: dict, collapsed: bool = True) -> None:
super().__init__()
self.data = data
self.collapsed = collapsed
self.add_class("tool-result-widget")
def _hint(self) -> str:
action = "expand" if self.collapsed else "collapse"
return f"({self.SHORTCUT} to {action})"
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
@ -66,7 +74,7 @@ class BashResultWidget(ToolResultWidget):
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
@ -96,7 +104,7 @@ class WriteFileResultWidget(ToolResultWidget):
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
@ -158,7 +166,7 @@ class SearchReplaceResultWidget(ToolResultWidget):
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
@ -185,13 +193,15 @@ class TodoApprovalWidget(ToolApprovalWidget):
class TodoResultWidget(ToolResultWidget):
SHORTCUT = TOOL_SHORTCUTS["todo"]
def compose(self) -> ComposeResult:
message = self.data.get("message", "")
if self.collapsed:
yield Static(message, markup=False)
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
yield Static(f"{message} {self._hint()}", markup=False)
yield Static("")
by_status = self.data.get("todos_by_status", {})
@ -228,7 +238,7 @@ class ReadFileResultWidget(ToolResultWidget):
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)
@ -277,7 +287,7 @@ class GrepResultWidget(ToolResultWidget):
message = self.data.get("message", "")
if self.collapsed:
yield Static(f"{message} (ctrl+o to expand.)", markup=False)
yield Static(f"{message} {self._hint()}", markup=False)
else:
yield Static(message, markup=False)

View file

@ -1,86 +1,163 @@
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Static
from vibe.cli.textual_ui.renderers import get_renderer
from vibe.cli.textual_ui.widgets.blinking_message import BlinkingMessage
from vibe.cli.textual_ui.widgets.messages import ExpandingBorder
from vibe.cli.textual_ui.widgets.status_message import StatusMessage
from vibe.cli.textual_ui.widgets.utils import DEFAULT_TOOL_SHORTCUT, TOOL_SHORTCUTS
from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent
class ToolCallMessage(BlinkingMessage):
def __init__(self, event: ToolCallEvent) -> None:
self.event = event
class ToolCallMessage(StatusMessage):
def __init__(
self, event: ToolCallEvent | None = None, *, tool_name: str | None = None
) -> None:
if event is None and tool_name is None:
raise ValueError("Either event or tool_name must be provided")
self._event = event
self._tool_name = tool_name or (event.tool_name if event else "unknown")
self._is_history = event is None
super().__init__()
self.add_class("tool-call")
if self._is_history:
self._is_spinning = False
def get_content(self) -> str:
if not self.event.tool_class:
return f"{self.event.tool_name}"
adapter = ToolUIDataAdapter(self.event.tool_class)
display = adapter.get_call_display(self.event)
return f"{display.summary}"
if self._event and self._event.tool_class:
adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_call_display(self._event)
return display.summary
return self._tool_name
class ToolResultMessage(Static):
def __init__(
self,
event: ToolResultEvent,
event: ToolResultEvent | None = None,
call_widget: ToolCallMessage | None = None,
collapsed: bool = True,
*,
tool_name: str | None = None,
content: str | None = None,
) -> None:
self.event = event
self.call_widget = call_widget
if event is None and tool_name is None:
raise ValueError("Either event or tool_name must be provided")
self._event = event
self._call_widget = call_widget
self._tool_name = tool_name or (event.tool_name if event else "unknown")
self._content = content
self.collapsed = collapsed
self._content_container: Vertical | None = None
super().__init__()
self.add_class("tool-result")
@property
def tool_name(self) -> str:
return self._tool_name
def _shortcut(self) -> str:
return TOOL_SHORTCUTS.get(self._tool_name, DEFAULT_TOOL_SHORTCUT)
def _hint(self) -> str:
action = "expand" if self.collapsed else "collapse"
return f"({self._shortcut()} to {action})"
def compose(self) -> ComposeResult:
with Horizontal(classes="tool-result-container"):
yield ExpandingBorder(classes="tool-result-border")
self._content_container = Vertical(classes="tool-result-content")
yield self._content_container
async def on_mount(self) -> None:
if self.call_widget:
success = not self.event.error and not self.event.skipped
self.call_widget.stop_blinking(success=success)
await self.render_result()
if self._call_widget:
success = self._event is None or (
not self._event.error and not self._event.skipped
)
self._call_widget.stop_spinning(success=success)
await self._render_result()
async def render_result(self) -> None:
await self.remove_children()
if self.event.error:
self.add_class("error-text")
if self.collapsed:
self.update("Error. (ctrl+o to expand)")
else:
await self.mount(Static(f"Error: {self.event.error}", markup=False))
async def _render_result(self) -> None:
if self._content_container is None:
return
if self.event.skipped:
self.add_class("warning-text")
reason = self.event.skip_reason or "User skipped"
await self._content_container.remove_children()
if self._event is None:
await self._render_simple()
return
if self._event.error:
self.add_class("error-text")
if self.collapsed:
self.update("Skipped. (ctrl+o to expand)")
await self._content_container.mount(
Static(f"Error. {self._hint()}", markup=False)
)
else:
await self.mount(Static(f"Skipped: {reason}", markup=False))
await self._content_container.mount(
Static(f"Error: {self._event.error}", markup=False)
)
return
if self._event.skipped:
self.add_class("warning-text")
reason = self._event.skip_reason or "User skipped"
if self.collapsed:
await self._content_container.mount(
Static(f"Skipped. {self._hint()}", markup=False)
)
else:
await self._content_container.mount(
Static(f"Skipped: {reason}", markup=False)
)
return
self.remove_class("error-text")
self.remove_class("warning-text")
adapter = ToolUIDataAdapter(self.event.tool_class)
display = adapter.get_result_display(self.event)
if self._event.tool_class is None:
await self._render_simple()
return
renderer = get_renderer(self.event.tool_name)
adapter = ToolUIDataAdapter(self._event.tool_class)
display = adapter.get_result_display(self._event)
renderer = get_renderer(self._event.tool_name)
widget_class, data = renderer.get_result_widget(display, self.collapsed)
await self._content_container.mount(
widget_class(data, collapsed=self.collapsed)
)
result_widget = widget_class(data, collapsed=self.collapsed)
await self.mount(result_widget)
async def _render_simple(self) -> None:
if self._content_container is None:
return
if self.collapsed:
await self._content_container.mount(
Static(f"{self._tool_name} completed {self._hint()}", markup=False)
)
return
if self._content:
await self._content_container.mount(Static(self._content, markup=False))
else:
await self._content_container.mount(
Static(f"{self._tool_name} completed.", markup=False)
)
async def set_collapsed(self, collapsed: bool) -> None:
if self.collapsed != collapsed:
self.collapsed = collapsed
await self.render_result()
if self.collapsed == collapsed:
return
self.collapsed = collapsed
await self._render_result()
async def toggle_collapsed(self) -> None:
self.collapsed = not self.collapsed
await self.render_result()
await self._render_result()

View file

@ -0,0 +1,4 @@
from __future__ import annotations
TOOL_SHORTCUTS = {"todo": "ctrl+t"}
DEFAULT_TOOL_SHORTCUT = "ctrl+o"

View file

@ -9,7 +9,7 @@ from rich.text import Text
from textual.color import Color
from textual.widgets import Static
from vibe.core import __version__
from vibe import __version__
from vibe.core.config import VibeConfig
@ -104,8 +104,7 @@ class WelcomeBanner(Static):
self._static_line5_suffix = (
f"{self.LOGO_TEXT_GAP}[dim]{self.config.effective_workdir}[/]"
)
block = (self.SPACE * 4) + self.LOGO_TEXT_GAP
self._static_line7 = f"{block}[dim]Type[/] [{self.BORDER_TARGET_COLOR}]/help[/] [dim]for more information[/]"
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[/]"
@property
def skeleton_color(self) -> str:

View file

@ -8,7 +8,7 @@ from vibe.cli.update_notifier.ports.update_cache_repository import (
UpdateCache,
UpdateCacheRepository,
)
from vibe.core.config_path import VIBE_HOME
from vibe.core.paths.global_paths import VIBE_HOME
class FileSystemUpdateCacheRepository(UpdateCacheRepository):